aboutsummaryrefslogtreecommitdiff
path: root/src/com/android/tv
diff options
context:
space:
mode:
authorYoungsang Cho <youngsang@google.com>2016-10-31 15:28:42 -0700
committerYoungsang Cho <youngsang@google.com>2016-10-31 15:28:42 -0700
commit919e1ed7e914029a1a0054237d86dc7b19ced898 (patch)
treecb30cfbafd80e01d314868cdc36e783d39981119 /src/com/android/tv
parent2933fcfd17f59c086436b270e7c01f2afcd54aa5 (diff)
downloadTV-919e1ed7e914029a1a0054237d86dc7b19ced898.tar.gz
Sync to ub-tv-killing at 6f6e46557accb62c9548e4177d6005aa944dbf33
Change-Id: I873644d6d9d0110c981ef6075cb4019c16bbb94b
Diffstat (limited to 'src/com/android/tv')
-rw-r--r--src/com/android/tv/ApplicationSingletons.java18
-rw-r--r--src/com/android/tv/Features.java61
-rw-r--r--src/com/android/tv/InputSessionManager.java549
-rw-r--r--src/com/android/tv/MainActivity.java776
-rw-r--r--src/com/android/tv/MainActivityWrapper.java5
-rw-r--r--src/com/android/tv/SetupPassthroughActivity.java23
-rw-r--r--src/com/android/tv/TimeShiftManager.java165
-rw-r--r--src/com/android/tv/TvApplication.java230
-rw-r--r--src/com/android/tv/TvOptionsManager.java3
-rw-r--r--src/com/android/tv/config/ConfigKeys.java (renamed from src/com/android/tv/dvr/ui/EmptyHolder.java)10
-rw-r--r--src/com/android/tv/config/DefaultConfigManager.java55
-rw-r--r--src/com/android/tv/config/RemoteConfig.java48
-rw-r--r--src/com/android/tv/config/RemoteConfigFeature.java43
-rw-r--r--src/com/android/tv/data/BaseProgram.java177
-rw-r--r--src/com/android/tv/data/Channel.java52
-rw-r--r--src/com/android/tv/data/ChannelDataManager.java22
-rw-r--r--src/com/android/tv/data/GenreItems.java34
-rw-r--r--src/com/android/tv/data/InternalDataUtils.java133
-rw-r--r--src/com/android/tv/data/Lineup.java94
-rw-r--r--src/com/android/tv/data/ParcelableList.java86
-rw-r--r--src/com/android/tv/data/Program.java501
-rw-r--r--src/com/android/tv/data/ProgramDataManager.java31
-rw-r--r--src/com/android/tv/data/StreamInfo.java3
-rw-r--r--src/com/android/tv/data/WatchedHistoryManager.java2
-rw-r--r--src/com/android/tv/data/epg/EpgFetcher.java387
-rw-r--r--src/com/android/tv/data/epg/EpgReader.java15
-rw-r--r--src/com/android/tv/data/epg/StubEpgReader.java20
-rw-r--r--src/com/android/tv/dialog/PinDialogFragment.java15
-rw-r--r--src/com/android/tv/dialog/SafeDismissDialogFragment.java6
-rw-r--r--src/com/android/tv/dvr/BaseDvrDataManager.java210
-rw-r--r--src/com/android/tv/dvr/ConflictChecker.java277
-rw-r--r--src/com/android/tv/dvr/DvrDataManager.java154
-rw-r--r--src/com/android/tv/dvr/DvrDataManagerImpl.java846
-rw-r--r--src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java215
-rw-r--r--src/com/android/tv/dvr/DvrDbSync.java363
-rw-r--r--src/com/android/tv/dvr/DvrManager.java786
-rw-r--r--src/com/android/tv/dvr/DvrPlayActivity.java47
-rw-r--r--src/com/android/tv/dvr/DvrPlaybackActivity.java67
-rw-r--r--src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java327
-rw-r--r--src/com/android/tv/dvr/DvrPlayer.java425
-rw-r--r--src/com/android/tv/dvr/DvrRecordingService.java35
-rw-r--r--src/com/android/tv/dvr/DvrScheduleManager.java980
-rw-r--r--src/com/android/tv/dvr/DvrSessionManager.java130
-rw-r--r--src/com/android/tv/dvr/DvrStartRecordingReceiver.java3
-rw-r--r--src/com/android/tv/dvr/DvrStorageStatusManager.java376
-rw-r--r--src/com/android/tv/dvr/DvrUiHelper.java450
-rw-r--r--src/com/android/tv/dvr/DvrWatchedPositionManager.java154
-rw-r--r--src/com/android/tv/dvr/EpisodicProgramLoadTask.java382
-rw-r--r--src/com/android/tv/dvr/IdGenerator.java50
-rw-r--r--src/com/android/tv/dvr/InputTaskScheduler.java431
-rw-r--r--src/com/android/tv/dvr/RecordedProgram.java868
-rw-r--r--src/com/android/tv/dvr/RecordingTask.java368
-rw-r--r--src/com/android/tv/dvr/ScheduledProgramReaper.java20
-rw-r--r--src/com/android/tv/dvr/ScheduledRecording.java694
-rw-r--r--src/com/android/tv/dvr/Scheduler.java252
-rw-r--r--src/com/android/tv/dvr/SeriesInfo.java76
-rw-r--r--src/com/android/tv/dvr/SeriesRecording.java755
-rw-r--r--src/com/android/tv/dvr/SeriesRecordingScheduler.java579
-rw-r--r--src/com/android/tv/dvr/WritableDvrDataManager.java45
-rw-r--r--src/com/android/tv/dvr/provider/AsyncDvrDbTask.java137
-rw-r--r--src/com/android/tv/dvr/provider/DvrContract.java302
-rw-r--r--src/com/android/tv/dvr/provider/DvrDatabaseHelper.java362
-rw-r--r--src/com/android/tv/dvr/ui/ActionPresenterSelector.java138
-rw-r--r--src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java59
-rw-r--r--src/com/android/tv/dvr/ui/DetailsContent.java207
-rw-r--r--src/com/android/tv/dvr/ui/DetailsContentPresenter.java300
-rw-r--r--src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java92
-rw-r--r--src/com/android/tv/dvr/ui/DvrActivity.java2
-rw-r--r--src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java103
-rw-r--r--src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java107
-rw-r--r--src/com/android/tv/dvr/ui/DvrBrowseFragment.java610
-rw-r--r--src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java109
-rw-r--r--src/com/android/tv/dvr/ui/DvrConflictFragment.java339
-rw-r--r--src/com/android/tv/dvr/ui/DvrDetailsActivity.java98
-rw-r--r--src/com/android/tv/dvr/ui/DvrDetailsFragment.java344
-rw-r--r--src/com/android/tv/dvr/ui/DvrDialogFragment.java50
-rw-r--r--src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java87
-rw-r--r--src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java78
-rw-r--r--src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java74
-rw-r--r--src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java228
-rw-r--r--src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java71
-rw-r--r--src/com/android/tv/dvr/ui/DvrItemPresenter.java80
-rw-r--r--src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java79
-rw-r--r--src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java82
-rw-r--r--src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java313
-rw-r--r--src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java304
-rw-r--r--src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java82
-rw-r--r--src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java48
-rw-r--r--src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java70
-rw-r--r--src/com/android/tv/dvr/ui/DvrScheduleFragment.java147
-rw-r--r--src/com/android/tv/dvr/ui/DvrSchedulesActivity.java104
-rw-r--r--src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java50
-rw-r--r--src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.java48
-rw-r--r--src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java154
-rw-r--r--src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java82
-rw-r--r--src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java161
-rw-r--r--src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java48
-rw-r--r--src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java104
-rw-r--r--src/com/android/tv/dvr/ui/EmptyItemPresenter.java66
-rw-r--r--src/com/android/tv/dvr/ui/FullScheduleCardHolder.java29
-rw-r--r--src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java84
-rw-r--r--src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java93
-rw-r--r--src/com/android/tv/dvr/ui/PrioritySettingsFragment.java251
-rw-r--r--src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java170
-rw-r--r--src/com/android/tv/dvr/ui/RecordedProgramPresenter.java195
-rw-r--r--src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java65
-rw-r--r--src/com/android/tv/dvr/ui/RecordingCardView.java102
-rw-r--r--src/com/android/tv/dvr/ui/RecordingDetailsFragment.java87
-rw-r--r--src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java97
-rw-r--r--src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java200
-rw-r--r--src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java85
-rw-r--r--src/com/android/tv/dvr/ui/SeriesDeletionFragment.java252
-rw-r--r--src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java375
-rw-r--r--src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java234
-rw-r--r--src/com/android/tv/dvr/ui/SeriesSettingsFragment.java397
-rw-r--r--src/com/android/tv/dvr/ui/SortedArrayAdapter.java208
-rw-r--r--src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java178
-rw-r--r--src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java88
-rw-r--r--src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java86
-rw-r--r--src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java208
-rw-r--r--src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java89
-rw-r--r--src/com/android/tv/dvr/ui/list/ScheduleRow.java203
-rw-r--r--src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java425
-rw-r--r--src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java795
-rw-r--r--src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java122
-rw-r--r--src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java273
-rw-r--r--src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java269
-rw-r--r--src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java143
-rw-r--r--src/com/android/tv/experiments/ExperimentFlag.java42
-rw-r--r--src/com/android/tv/experiments/Experiments.java42
-rw-r--r--src/com/android/tv/guide/ProgramGuide.java67
-rw-r--r--src/com/android/tv/guide/ProgramItemView.java141
-rw-r--r--src/com/android/tv/guide/ProgramManager.java137
-rw-r--r--src/com/android/tv/guide/ProgramRow.java114
-rw-r--r--src/com/android/tv/guide/ProgramTableAdapter.java270
-rw-r--r--src/com/android/tv/menu/ActionCardView.java2
-rw-r--r--src/com/android/tv/menu/AppLinkCardView.java78
-rw-r--r--src/com/android/tv/menu/BaseCardView.java109
-rw-r--r--src/com/android/tv/menu/ChannelCardView.java99
-rw-r--r--src/com/android/tv/menu/ChannelsRowAdapter.java67
-rw-r--r--src/com/android/tv/menu/IMenuView.java7
-rw-r--r--src/com/android/tv/menu/Menu.java85
-rw-r--r--src/com/android/tv/menu/MenuAction.java5
-rw-r--r--src/com/android/tv/menu/MenuLayoutManager.java4
-rw-r--r--src/com/android/tv/menu/MenuRow.java17
-rw-r--r--src/com/android/tv/menu/MenuRowFactory.java8
-rw-r--r--src/com/android/tv/menu/MenuRowView.java19
-rw-r--r--src/com/android/tv/menu/MenuUpdater.java96
-rw-r--r--src/com/android/tv/menu/MenuView.java46
-rw-r--r--src/com/android/tv/menu/PlayControlsButton.java37
-rw-r--r--src/com/android/tv/menu/PlayControlsRow.java14
-rw-r--r--src/com/android/tv/menu/PlayControlsRowView.java297
-rw-r--r--src/com/android/tv/menu/RecordCardView.java189
-rw-r--r--src/com/android/tv/menu/SetupCardView.java53
-rw-r--r--src/com/android/tv/menu/SimpleCardView.java10
-rw-r--r--src/com/android/tv/menu/TvOptionsRowAdapter.java45
-rw-r--r--src/com/android/tv/onboarding/NewSourcesFragment.java33
-rw-r--r--src/com/android/tv/onboarding/OnboardingActivity.java88
-rw-r--r--src/com/android/tv/onboarding/SetupSourcesFragment.java216
-rw-r--r--src/com/android/tv/onboarding/WelcomeFragment.java6
-rw-r--r--src/com/android/tv/receiver/BootCompletedReceiver.java5
-rw-r--r--src/com/android/tv/receiver/GlobalKeyReceiver.java1
-rw-r--r--src/com/android/tv/receiver/PackageIntentsReceiver.java1
-rw-r--r--src/com/android/tv/recommendation/NotificationService.java27
-rw-r--r--src/com/android/tv/recommendation/RecommendationDataManager.java35
-rw-r--r--src/com/android/tv/search/DataManagerSearch.java36
-rw-r--r--src/com/android/tv/search/LocalSearchProvider.java10
-rw-r--r--src/com/android/tv/search/TvProviderSearch.java34
-rw-r--r--src/com/android/tv/setup/SystemSetupActivity.java124
-rw-r--r--src/com/android/tv/tuner/ChannelScanFileParser.java105
-rw-r--r--src/com/android/tv/tuner/DvbDeviceAccessor.java223
-rw-r--r--src/com/android/tv/tuner/TunerHal.java259
-rw-r--r--src/com/android/tv/tuner/TunerInputController.java192
-rw-r--r--src/com/android/tv/tuner/TunerPreferenceProvider.java203
-rw-r--r--src/com/android/tv/tuner/TunerPreferences.java310
-rw-r--r--src/com/android/tv/tuner/UsbTunerHal.java174
-rw-r--r--src/com/android/tv/tuner/cc/CaptionLayout.java76
-rw-r--r--src/com/android/tv/tuner/cc/CaptionTrackRenderer.java340
-rw-r--r--src/com/android/tv/tuner/cc/CaptionWindowLayout.java650
-rw-r--r--src/com/android/tv/tuner/cc/Cea708Parser.java808
-rw-r--r--src/com/android/tv/tuner/data/Cea708Data.java320
-rw-r--r--src/com/android/tv/tuner/data/PsiData.java94
-rw-r--r--src/com/android/tv/tuner/data/PsipData.java689
-rw-r--r--src/com/android/tv/tuner/data/TunerChannel.java396
-rw-r--r--src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java283
-rw-r--r--src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java398
-rw-r--r--src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java140
-rw-r--r--src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java653
-rw-r--r--src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java67
-rw-r--r--src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java335
-rw-r--r--src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java196
-rw-r--r--src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java101
-rw-r--r--src/com/android/tv/tuner/exoplayer/SampleExtractor.java136
-rw-r--r--src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java540
-rw-r--r--src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java94
-rw-r--r--src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java107
-rw-r--r--src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java121
-rw-r--r--src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java164
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java644
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java287
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java306
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java419
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java406
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java71
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java74
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java180
-rw-r--r--src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java128
-rw-r--r--src/com/android/tv/tuner/layout/ScaledLayout.java274
-rw-r--r--src/com/android/tv/tuner/setup/ConnectionTypeFragment.java82
-rw-r--r--src/com/android/tv/tuner/setup/ScanFragment.java505
-rw-r--r--src/com/android/tv/tuner/setup/ScanResultFragment.java118
-rw-r--r--src/com/android/tv/tuner/setup/TunerSetupActivity.java282
-rw-r--r--src/com/android/tv/tuner/setup/WelcomeFragment.java110
-rw-r--r--src/com/android/tv/tuner/source/FileTsStreamer.java470
-rw-r--r--src/com/android/tv/tuner/source/TsDataSource.java50
-rw-r--r--src/com/android/tv/tuner/source/TsDataSourceManager.java135
-rw-r--r--src/com/android/tv/tuner/source/TsStreamWriter.java237
-rw-r--r--src/com/android/tv/tuner/source/TsStreamer.java56
-rw-r--r--src/com/android/tv/tuner/source/TunerTsStreamer.java363
-rw-r--r--src/com/android/tv/tuner/source/TunerTsStreamerManager.java287
-rw-r--r--src/com/android/tv/tuner/ts/SectionParser.java1264
-rw-r--r--src/com/android/tv/tuner/ts/TsParser.java454
-rw-r--r--src/com/android/tv/tuner/tvinput/ChannelDataManager.java706
-rw-r--r--src/com/android/tv/tuner/tvinput/EventDetector.java261
-rw-r--r--src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java210
-rw-r--r--src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java42
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerDebug.java150
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerRecordingSession.java104
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java594
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerSession.java312
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerSessionWorker.java1583
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java166
-rw-r--r--src/com/android/tv/tuner/tvinput/TunerTvInputService.java146
-rw-r--r--src/com/android/tv/tuner/util/ByteArrayBuffer.java149
-rw-r--r--src/com/android/tv/tuner/util/ConvertUtils.java35
-rw-r--r--src/com/android/tv/tuner/util/GlobalSettingsUtils.java36
-rw-r--r--src/com/android/tv/tuner/util/Ints.java28
-rw-r--r--src/com/android/tv/tuner/util/StatusTextUtils.java119
-rw-r--r--src/com/android/tv/tuner/util/StringUtils.java (renamed from src/com/android/tv/dvr/SeasonRecording.java)27
-rw-r--r--src/com/android/tv/tuner/util/SystemPropertiesProxy.java61
-rw-r--r--src/com/android/tv/tuner/util/TisConfiguration.java22
-rw-r--r--src/com/android/tv/tuner/util/TunerInputInfoUtils.java100
-rw-r--r--src/com/android/tv/ui/AppLayerTvView.java13
-rw-r--r--src/com/android/tv/ui/ChannelBannerView.java265
-rw-r--r--src/com/android/tv/ui/GuidedActionsStylistWithDivider.java65
-rw-r--r--src/com/android/tv/ui/OverlayRootView.java51
-rw-r--r--src/com/android/tv/ui/SelectInputView.java9
-rw-r--r--src/com/android/tv/ui/TunableTvView.java559
-rw-r--r--src/com/android/tv/ui/TvOverlayManager.java283
-rw-r--r--src/com/android/tv/ui/TvViewUiManager.java90
-rw-r--r--src/com/android/tv/ui/ViewUtils.java50
-rw-r--r--src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java3
-rw-r--r--src/com/android/tv/ui/sidepanel/DebugOptionFragment.java48
-rw-r--r--src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java101
-rw-r--r--src/com/android/tv/ui/sidepanel/DisplayModeFragment.java6
-rw-r--r--src/com/android/tv/ui/sidepanel/SettingsFragment.java23
-rw-r--r--src/com/android/tv/ui/sidepanel/SideFragment.java17
-rw-r--r--src/com/android/tv/ui/sidepanel/SideFragmentManager.java12
-rw-r--r--src/com/android/tv/util/AccountHelper.java111
-rw-r--r--src/com/android/tv/util/AsyncDbTask.java81
-rw-r--r--src/com/android/tv/util/BitmapUtils.java55
-rw-r--r--src/com/android/tv/util/CompositeComparator.java42
-rw-r--r--src/com/android/tv/util/Filter.java27
-rw-r--r--src/com/android/tv/util/ImageCache.java10
-rw-r--r--src/com/android/tv/util/ImageLoader.java12
-rw-r--r--src/com/android/tv/util/LocationUtils.java120
-rw-r--r--src/com/android/tv/util/OnboardingUtils.java15
-rw-r--r--src/com/android/tv/util/PermissionUtils.java52
-rw-r--r--src/com/android/tv/util/PipInputManager.java2
-rw-r--r--src/com/android/tv/util/RecurringRunner.java6
-rw-r--r--src/com/android/tv/util/SearchManagerHelper.java12
-rw-r--r--src/com/android/tv/util/SetupUtils.java89
-rw-r--r--src/com/android/tv/util/SystemProperties.java6
-rw-r--r--src/com/android/tv/util/TimeShiftUtils.java63
-rw-r--r--src/com/android/tv/util/ToastUtils.java43
-rw-r--r--src/com/android/tv/util/TvInputManagerHelper.java63
-rw-r--r--src/com/android/tv/util/TvProviderUriMatcher.java72
-rw-r--r--src/com/android/tv/util/TvSettings.java6
-rw-r--r--src/com/android/tv/util/TvTrackInfoUtils.java8
-rw-r--r--src/com/android/tv/util/Utils.java350
280 files changed, 46705 insertions, 4729 deletions
diff --git a/src/com/android/tv/ApplicationSingletons.java b/src/com/android/tv/ApplicationSingletons.java
index 5198f7fd..fd125d52 100644
--- a/src/com/android/tv/ApplicationSingletons.java
+++ b/src/com/android/tv/ApplicationSingletons.java
@@ -18,11 +18,15 @@ package com.android.tv;
import com.android.tv.analytics.Analytics;
import com.android.tv.analytics.Tracker;
+import com.android.tv.config.RemoteConfig;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.ProgramDataManager;
import com.android.tv.dvr.DvrDataManager;
import com.android.tv.dvr.DvrManager;
-import com.android.tv.dvr.DvrSessionManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.util.AccountHelper;
import com.android.tv.util.TvInputManagerHelper;
/**
@@ -36,9 +40,15 @@ public interface ApplicationSingletons {
DvrDataManager getDvrDataManager();
+ DvrStorageStatusManager getDvrStorageStatusManager();
+
+ DvrScheduleManager getDvrScheduleManager();
+
DvrManager getDvrManager();
- DvrSessionManager getDvrSessionManger();
+ DvrWatchedPositionManager getDvrWatchedPositionManager();
+
+ InputSessionManager getInputSessionManager();
ProgramDataManager getProgramDataManager();
@@ -47,4 +57,8 @@ public interface ApplicationSingletons {
TvInputManagerHelper getTvInputManagerHelper();
MainActivityWrapper getMainActivityWrapper();
+
+ AccountHelper getAccountHelper();
+
+ RemoteConfig getRemoteConfig();
}
diff --git a/src/com/android/tv/Features.java b/src/com/android/tv/Features.java
index 6a78b632..7e8e3689 100644
--- a/src/com/android/tv/Features.java
+++ b/src/com/android/tv/Features.java
@@ -18,17 +18,18 @@ package com.android.tv;
import static com.android.tv.common.feature.EngOnlyFeature.ENG_ONLY_FEATURE;
import static com.android.tv.common.feature.FeatureUtils.AND;
+import static com.android.tv.common.feature.FeatureUtils.OFF;
import static com.android.tv.common.feature.FeatureUtils.ON;
import static com.android.tv.common.feature.FeatureUtils.OR;
import android.content.Context;
+import android.content.pm.PackageManager;
import android.os.Build;
import android.support.annotation.VisibleForTesting;
import android.support.v4.os.BuildCompat;
import com.android.tv.common.feature.Feature;
import com.android.tv.common.feature.GServiceFeature;
-import com.android.tv.common.feature.PackageVersionFeature;
import com.android.tv.common.feature.PropertyFeature;
import com.android.tv.util.PermissionUtils;
@@ -56,52 +57,56 @@ public final class Features {
public static final Feature EPG_SEARCH =
new PropertyFeature("feature_tv_use_epg_search", false);
- public static final Feature USB_TUNER = new Feature() {
-
- /**
- * This is special handling just for USB Tuner.
- * It does not require any N API's but relies on a improvements in N for AC3 support
- * After release, change class to this to just be
- * {@link BuildCompat#isAtLeastN()}.
- */
+ public static final Feature TUNER = new Feature() {
@Override
public boolean isEnabled(Context context) {
+
+ // This is special handling just for USB Tuner.
+ // It does not require any N API's but relies on a improvements in N for AC3 support
+ // After release, change class to this to just be {@link BuildCompat#isAtLeastN()}.
return Build.VERSION.SDK_INT > Build.VERSION_CODES.M || BuildCompat.isAtLeastN();
}
};
- private static final String PLAY_STORE_PACKAGE_NAME = "com.android.vending";
- private static final int PLAY_STORE_ZIMA_VERSION_CODE = 80441186;
- private static final Feature PLAY_STORE_LINK =
- new PackageVersionFeature(PLAY_STORE_PACKAGE_NAME, PLAY_STORE_ZIMA_VERSION_CODE);
-
- public static final Feature ONBOARDING_PLAY_STORE = PLAY_STORE_LINK;
-
- /**
- * A flag which indicates that the on-boarding experience is used or not.
- *
- * <p>See <a href="http://b/24070322">b/24070322</a>
- */
- public static final Feature ONBOARDING_EXPERIENCE = ONBOARDING_PLAY_STORE;
-
private static final String GSERVICE_KEY_UNHIDE = "live_channels_unhide";
/**
* A flag which indicates that LC app is unhidden even when there is no input.
*/
- public static final Feature UNHIDE = AND(ONBOARDING_EXPERIENCE,
+ public static final Feature UNHIDE =
OR(new GServiceFeature(GSERVICE_KEY_UNHIDE, false), new Feature() {
@Override
public boolean isEnabled(Context context) {
// If LC app runs as non-system app, we unhide the app.
return !PermissionUtils.hasAccessAllEpg(context);
}
- }));
+ });
- @VisibleForTesting
- public static Feature TEST_FEATURE = new PropertyFeature("test_feature", false);
+ public static final Feature PICTURE_IN_PICTURE = new Feature() {
+ private Boolean mEnabled;
- public static final Feature FETCH_EPG = new PropertyFeature("live_channels_fetch_epg", false);
+ @Override
+ public boolean isEnabled(Context context) {
+ if (mEnabled == null) {
+ mEnabled = context.getPackageManager().hasSystemFeature(
+ PackageManager.FEATURE_PICTURE_IN_PICTURE);
+ }
+ return mEnabled;
+ }
+ };
+
+ /**
+ * Enable a conflict dialog between currently watched channel and upcoming recording.
+ */
+ public static final Feature SHOW_UPCOMING_CONFLICT_DIALOG = OFF;
+
+ /**
+ * Use input blacklist to disable partner's tuner input.
+ */
+ public static final Feature USE_PARTNER_INPUT_BLACKLIST = ON;
+
+ @VisibleForTesting
+ public static final Feature TEST_FEATURE = new PropertyFeature("test_feature", false);
private Features() {
}
diff --git a/src/com/android/tv/InputSessionManager.java b/src/com/android/tv/InputSessionManager.java
new file mode 100644
index 00000000..e4b0f456
--- /dev/null
+++ b/src/com/android/tv/InputSessionManager.java
@@ -0,0 +1,549 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvRecordingClient;
+import android.media.tv.TvRecordingClient.RecordingCallback;
+import android.media.tv.TvTrackInfo;
+import android.media.tv.TvView;
+import android.media.tv.TvView.TvInputCallback;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.ui.TunableTvView;
+import com.android.tv.ui.TunableTvView.OnTuneListener;
+import com.android.tv.util.TvInputManagerHelper;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Manages input sessions.
+ * Responsible for:
+ * <ul>
+ * <li>Manage {@link TvView} sessions and recording sessions</li>
+ * <li>Manage capabilities (conflict)</li>
+ * </ul>
+ * <p>
+ * As TvView's methods should be called on the main thread and the {@link RecordingSession} should
+ * look at the state of the {@link TvViewSession} when it calls the framework methods, the framework
+ * calls in RecordingSession are made on the main thread not to introduce the multi-thread problems.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class InputSessionManager {
+ private static final String TAG = "InputSessionManager";
+ private static final boolean DEBUG = false;
+
+ private final Context mContext;
+ private final TvInputManagerHelper mInputManager;
+ private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
+ private final Set<TvViewSession> mTvViewSessions = new ArraySet<>();
+ private final Set<RecordingSession> mRecordingSessions =
+ Collections.synchronizedSet(new ArraySet<>());
+ private final Set<OnTvViewChannelChangeListener> mOnTvViewChannelChangeListeners =
+ new ArraySet<>();
+
+ public InputSessionManager(Context context) {
+ mContext = context.getApplicationContext();
+ mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper();
+ }
+
+ /**
+ * Creates the session for {@link TvView}.
+ * <p>
+ * Do not call {@link TvView#setCallback} after the session is created.
+ */
+ @MainThread
+ @NonNull
+ public TvViewSession createTvViewSession(TvView tvView, TunableTvView tunableTvView,
+ TvInputCallback callback) {
+ TvViewSession session = new TvViewSession(tvView, tunableTvView, callback);
+ mTvViewSessions.add(session);
+ if (DEBUG) Log.d(TAG, "TvView session created: " + session);
+ return session;
+ }
+
+ /**
+ * Releases the {@link TvView} session.
+ */
+ @MainThread
+ public void releaseTvViewSession(TvViewSession session) {
+ mTvViewSessions.remove(session);
+ session.reset();
+ if (DEBUG) Log.d(TAG, "TvView session released: " + session);
+ }
+
+ /**
+ * Creates the session for recording.
+ */
+ @NonNull
+ public RecordingSession createRecordingSession(String inputId, String tag,
+ RecordingCallback callback, Handler handler, long endTimeMs) {
+ RecordingSession session = new RecordingSession(inputId, tag, callback, handler, endTimeMs);
+ mRecordingSessions.add(session);
+ if (DEBUG) Log.d(TAG, "Recording session created: " + session);
+ return session;
+ }
+
+ /**
+ * Releases the recording session.
+ */
+ public void releaseRecordingSession(RecordingSession session) {
+ mRecordingSessions.remove(session);
+ session.release();
+ if (DEBUG) Log.d(TAG, "Recording session released: " + session);
+ }
+
+ /**
+ * Adds the {@link OnTvViewChannelChangeListener}.
+ */
+ @MainThread
+ public void addOnTvViewChannelChangeListener(OnTvViewChannelChangeListener listener) {
+ mOnTvViewChannelChangeListeners.add(listener);
+ }
+
+ /**
+ * Removes the {@link OnTvViewChannelChangeListener}.
+ */
+ @MainThread
+ public void removeOnTvViewChannelChangeListener(OnTvViewChannelChangeListener listener) {
+ mOnTvViewChannelChangeListeners.remove(listener);
+ }
+
+ @MainThread
+ void notifyTvViewChannelChange(Uri channelUri) {
+ for (OnTvViewChannelChangeListener l : mOnTvViewChannelChangeListeners) {
+ l.onTvViewChannelChange(channelUri);
+ }
+ }
+
+ /**
+ * Returns the current {@link TvView} channel.
+ */
+ @MainThread
+ public Uri getCurrentTvViewChannelUri() {
+ for (TvViewSession session : mTvViewSessions) {
+ if (session.mTuned) {
+ return session.mChannelUri;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Retruns the earliest end time of recording sessions in progress of the certain TV input.
+ */
+ @MainThread
+ public Long getEarliestRecordingSessionEndTimeMs(String inputId) {
+ long timeMs = Long.MAX_VALUE;
+ synchronized (mRecordingSessions) {
+ for (RecordingSession session : mRecordingSessions) {
+ if (session.mTuned && TextUtils.equals(inputId, session.mInputId)) {
+ if (session.mEndTimeMs < timeMs) {
+ timeMs = session.mEndTimeMs;
+ }
+ }
+ }
+ }
+ return timeMs == Long.MAX_VALUE ? null : timeMs;
+ }
+
+ @MainThread
+ int getTunedTvViewSessionCount(String inputId) {
+ int tunedCount = 0;
+ for (TvViewSession session : mTvViewSessions) {
+ if (session.mTuned && Objects.equals(inputId, session.mInputId)) {
+ ++tunedCount;
+ }
+ }
+ return tunedCount;
+ }
+
+ @MainThread
+ boolean isTunedForTvView(Uri channelUri) {
+ for (TvViewSession session : mTvViewSessions) {
+ if (session.mTuned && Objects.equals(channelUri, session.mChannelUri)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ int getTunedRecordingSessionCount(String inputId) {
+ synchronized (mRecordingSessions) {
+ int tunedCount = 0;
+ for (RecordingSession session : mRecordingSessions) {
+ if (session.mTuned && Objects.equals(inputId, session.mInputId)) {
+ ++tunedCount;
+ }
+ }
+ return tunedCount;
+ }
+ }
+
+ boolean isTunedForRecording(Uri channelUri) {
+ synchronized (mRecordingSessions) {
+ for (RecordingSession session : mRecordingSessions) {
+ if (session.mTuned && Objects.equals(channelUri, session.mChannelUri)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * The session for {@link TvView}.
+ * <p>
+ * The methods which create or release session for the TV input should be called through this
+ * session.
+ */
+ @MainThread
+ public class TvViewSession {
+ private final TvView mTvView;
+ private final TunableTvView mTunableTvView;
+ private final TvInputCallback mCallback;
+ private Channel mChannel;
+ private String mInputId;
+ private Uri mChannelUri;
+ private Bundle mParams;
+ private OnTuneListener mOnTuneListener;
+ private boolean mTuned;
+ private boolean mNeedToBeRetuned;
+
+ TvViewSession(TvView tvView, TunableTvView tunableTvView, TvInputCallback callback) {
+ mTvView = tvView;
+ mTunableTvView = tunableTvView;
+ mCallback = callback;
+ mTvView.setCallback(new DelegateTvInputCallback(mCallback) {
+ @Override
+ public void onConnectionFailed(String inputId) {
+ if (DEBUG) Log.d(TAG, "TvViewSession: commection failed");
+ mTuned = false;
+ mNeedToBeRetuned = false;
+ super.onConnectionFailed(inputId);
+ notifyTvViewChannelChange(null);
+ }
+
+ @Override
+ public void onDisconnected(String inputId) {
+ if (DEBUG) Log.d(TAG, "TvViewSession: disconnected");
+ mTuned = false;
+ mNeedToBeRetuned = false;
+ super.onDisconnected(inputId);
+ notifyTvViewChannelChange(null);
+ }
+ });
+ }
+
+ /**
+ * Tunes to the channel.
+ * <p>
+ * As this is called only for the warming up, there's no need to be retuned.
+ */
+ public void tune(String inputId, Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "warm-up tune: {input=" + inputId + ", channelUri=" + channelUri + "}");
+ }
+ mInputId = inputId;
+ mChannelUri = channelUri;
+ mTuned = true;
+ mNeedToBeRetuned = false;
+ mTvView.tune(inputId, channelUri);
+ notifyTvViewChannelChange(channelUri);
+ }
+
+ /**
+ * Tunes to the channel.
+ */
+ public void tune(Channel channel, Bundle params, OnTuneListener listener) {
+ if (DEBUG) {
+ Log.d(TAG, "tune: {session=" + this + ", channel=" + channel + ", params=" + params
+ + ", listener=" + listener + ", mTuned=" + mTuned + "}");
+ }
+ mChannel = channel;
+ mInputId = channel.getInputId();
+ mChannelUri = channel.getUri();
+ mParams = params;
+ mOnTuneListener = listener;
+ TvInputInfo input = mInputManager.getTvInputInfo(mInputId);
+ if (input == null || (input.canRecord() && !isTunedForRecording(mChannelUri)
+ && getTunedRecordingSessionCount(mInputId) >= input.getTunerCount())) {
+ if (DEBUG) {
+ if (input == null) {
+ Log.d(TAG, "Can't find input for input ID: " + mInputId);
+ } else {
+ Log.d(TAG, "No more tuners to tune for input: " + input);
+ }
+ }
+ mCallback.onConnectionFailed(mInputId);
+ // Release the previous session to not to hold the unnecessary session.
+ resetByRecording();
+ return;
+ }
+ mTuned = true;
+ mNeedToBeRetuned = false;
+ mTvView.tune(mInputId, mChannelUri, params);
+ notifyTvViewChannelChange(mChannelUri);
+ }
+
+ void retune() {
+ if (DEBUG) Log.d(TAG, "Retune requested.");
+ if (mNeedToBeRetuned) {
+ if (DEBUG) Log.d(TAG, "Retuning: {channel=" + mChannel + "}");
+ mTunableTvView.tuneTo(mChannel, mParams, mOnTuneListener);
+ mNeedToBeRetuned = false;
+ }
+ }
+
+ /**
+ * Plays a given recorded TV program.
+ *
+ * @see TvView#timeShiftPlay
+ */
+ public void timeShiftPlay(String inputId, Uri recordedProgramUri) {
+ mTuned = false;
+ mNeedToBeRetuned = false;
+ mTvView.timeShiftPlay(inputId, recordedProgramUri);
+ notifyTvViewChannelChange(null);
+ }
+
+ /**
+ * Resets this TvView.
+ */
+ public void reset() {
+ if (DEBUG) Log.d(TAG, "Reset TvView session");
+ mTuned = false;
+ mTvView.reset();
+ mNeedToBeRetuned = false;
+ notifyTvViewChannelChange(null);
+ }
+
+ void resetByRecording() {
+ mCallback.onVideoUnavailable(mInputId,
+ TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE);
+ if (mTuned) {
+ if (DEBUG) Log.d(TAG, "Reset TvView session by recording");
+ mTunableTvView.resetByRecording();
+ reset();
+ }
+ mNeedToBeRetuned = true;
+ }
+ }
+
+ /**
+ * The session for recording.
+ * <p>
+ * The caller is responsible for releasing the session when the error occurs.
+ */
+ public class RecordingSession {
+ private final String mInputId;
+ private Uri mChannelUri;
+ private final RecordingCallback mCallback;
+ private final Handler mHandler;
+ private volatile long mEndTimeMs;
+ private TvRecordingClient mClient;
+ private boolean mTuned;
+
+ RecordingSession(String inputId, String tag, RecordingCallback callback,
+ Handler handler, long endTimeMs) {
+ mInputId = inputId;
+ mCallback = callback;
+ mHandler = handler;
+ mClient = new TvRecordingClient(mContext, tag, callback, handler);
+ mEndTimeMs = endTimeMs;
+ }
+
+ void release() {
+ if (DEBUG) Log.d(TAG, "Release of recording session requested.");
+ runOnHandler(mMainThreadHandler, new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) Log.d(TAG, "Releasing of recording session.");
+ mTuned = false;
+ mClient.release();
+ mClient = null;
+ for (TvViewSession session : mTvViewSessions) {
+ if (DEBUG) {
+ Log.d(TAG, "Finding TvView sessions for retune: {tuned="
+ + session.mTuned + ", inputId=" + session.mInputId
+ + ", session=" + session + "}");
+ }
+ if (!session.mTuned && Objects.equals(session.mInputId, mInputId)) {
+ session.retune();
+ break;
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Tunes to the channel for recording.
+ */
+ public void tune(String inputId, Uri channelUri) {
+ runOnHandler(mMainThreadHandler, new Runnable() {
+ @Override
+ public void run() {
+ int tunedRecordingSessionCount = getTunedRecordingSessionCount(inputId);
+ TvInputInfo input = mInputManager.getTvInputInfo(inputId);
+ if (input == null || !input.canRecord()
+ || input.getTunerCount() <= tunedRecordingSessionCount) {
+ runOnHandler(mHandler, new Runnable() {
+ @Override
+ public void run() {
+ mCallback.onConnectionFailed(inputId);
+ }
+ });
+ return;
+ }
+ mTuned = true;
+ int tunedTuneSessionCount = getTunedTvViewSessionCount(inputId);
+ if (!isTunedForTvView(channelUri) && tunedTuneSessionCount > 0
+ && tunedRecordingSessionCount + tunedTuneSessionCount
+ >= input.getTunerCount()) {
+ for (TvViewSession session : mTvViewSessions) {
+ if (session.mTuned && Objects.equals(session.mInputId, inputId)
+ && !isTunedForRecording(session.mChannelUri)) {
+ session.resetByRecording();
+ break;
+ }
+ }
+ }
+ mChannelUri = channelUri;
+ mClient.tune(inputId, channelUri);
+ }
+ });
+ }
+
+ /**
+ * Starts recording.
+ */
+ public void startRecording(Uri programHintUri) {
+ mClient.startRecording(programHintUri);
+ }
+
+ /**
+ * Stops recording.
+ */
+ public void stopRecording() {
+ mClient.stopRecording();
+ }
+
+ /**
+ * Sets recording session's ending time.
+ */
+ public void setEndTimeMs(long endTimeMs) {
+ mEndTimeMs = endTimeMs;
+ }
+
+ private void runOnHandler(Handler handler, Runnable runnable) {
+ if (Looper.myLooper() == handler.getLooper()) {
+ runnable.run();
+ } else {
+ handler.post(runnable);
+ }
+ }
+ }
+
+ private static class DelegateTvInputCallback extends TvInputCallback {
+ private final TvInputCallback mDelegate;
+
+ DelegateTvInputCallback(TvInputCallback delegate) {
+ mDelegate = delegate;
+ }
+
+ @Override
+ public void onConnectionFailed(String inputId) {
+ mDelegate.onConnectionFailed(inputId);
+ }
+
+ @Override
+ public void onDisconnected(String inputId) {
+ mDelegate.onDisconnected(inputId);
+ }
+
+ @Override
+ public void onChannelRetuned(String inputId, Uri channelUri) {
+ mDelegate.onChannelRetuned(inputId, channelUri);
+ }
+
+ @Override
+ public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) {
+ mDelegate.onTracksChanged(inputId, tracks);
+ }
+
+ @Override
+ public void onTrackSelected(String inputId, int type, String trackId) {
+ mDelegate.onTrackSelected(inputId, type, trackId);
+ }
+
+ @Override
+ public void onVideoSizeChanged(String inputId, int width, int height) {
+ mDelegate.onVideoSizeChanged(inputId, width, height);
+ }
+
+ @Override
+ public void onVideoAvailable(String inputId) {
+ mDelegate.onVideoAvailable(inputId);
+ }
+
+ @Override
+ public void onVideoUnavailable(String inputId, int reason) {
+ mDelegate.onVideoUnavailable(inputId, reason);
+ }
+
+ @Override
+ public void onContentAllowed(String inputId) {
+ mDelegate.onContentAllowed(inputId);
+ }
+
+ @Override
+ public void onContentBlocked(String inputId, TvContentRating rating) {
+ mDelegate.onContentBlocked(inputId, rating);
+ }
+
+ @Override
+ public void onTimeShiftStatusChanged(String inputId, int status) {
+ mDelegate.onTimeShiftStatusChanged(inputId, status);
+ }
+ }
+
+ /**
+ * Called when the {@link TvView} channel is changed.
+ */
+ public interface OnTvViewChannelChangeListener {
+ void onTvViewChannelChange(@Nullable Uri channelUri);
+ }
+}
diff --git a/src/com/android/tv/MainActivity.java b/src/com/android/tv/MainActivity.java
index 78fda42a..58850b5f 100644
--- a/src/com/android/tv/MainActivity.java
+++ b/src/com/android/tv/MainActivity.java
@@ -25,10 +25,10 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
+import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
-import android.graphics.PixelFormat;
import android.graphics.Point;
import android.hardware.display.DisplayManager;
import android.media.AudioManager;
@@ -44,6 +44,7 @@ import android.media.tv.TvInputManager.TvInputCallback;
import android.media.tv.TvTrackInfo;
import android.media.tv.TvView.OnUnhandledInputEventListener;
import android.net.Uri;
+import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
@@ -53,8 +54,8 @@ import android.provider.Settings;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
-import android.support.v4.os.BuildCompat;
import android.text.TextUtils;
+import android.util.ArraySet;
import android.util.Log;
import android.view.Display;
import android.view.Gravity;
@@ -62,6 +63,7 @@ import android.view.InputEvent;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
import android.view.Window;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
@@ -80,7 +82,7 @@ import com.android.tv.common.TvCommonUtils;
import com.android.tv.common.TvContentRatingCache;
import com.android.tv.common.WeakHandler;
import com.android.tv.common.feature.CommonFeatures;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.common.ui.setup.OnActionClickListener;
import com.android.tv.data.Channel;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.OnCurrentProgramUpdatedListener;
@@ -88,12 +90,16 @@ import com.android.tv.data.Program;
import com.android.tv.data.ProgramDataManager;
import com.android.tv.data.StreamInfo;
import com.android.tv.data.WatchedHistoryManager;
+import com.android.tv.data.epg.EpgFetcher;
import com.android.tv.dialog.PinDialogFragment;
import com.android.tv.dialog.SafeDismissDialogFragment;
-import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.ConflictChecker;
import com.android.tv.dvr.DvrManager;
-import com.android.tv.dvr.DvrPlayActivity;
+import com.android.tv.dvr.DvrUiHelper;
import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.DvrStopRecordingFragment;
+import com.android.tv.dvr.ui.HalfSizedDialogFragment;
+import com.android.tv.experiments.Experiments;
import com.android.tv.menu.Menu;
import com.android.tv.onboarding.OnboardingActivity;
import com.android.tv.parental.ContentRatingsManager;
@@ -101,24 +107,28 @@ import com.android.tv.parental.ParentalControlSettings;
import com.android.tv.receiver.AudioCapabilitiesReceiver;
import com.android.tv.recommendation.NotificationService;
import com.android.tv.search.ProgramGuideSearchFragment;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.setup.TunerSetupActivity;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
import com.android.tv.ui.AppLayerTvView;
import com.android.tv.ui.ChannelBannerView;
import com.android.tv.ui.InputBannerView;
import com.android.tv.ui.KeypadChannelSwitchView;
-import com.android.tv.ui.OverlayRootView;
import com.android.tv.ui.SelectInputView;
import com.android.tv.ui.SelectInputView.OnInputSelectedCallback;
import com.android.tv.ui.TunableTvView;
+import com.android.tv.ui.TunableTvView.BlockScreenType;
import com.android.tv.ui.TunableTvView.OnTuneListener;
import com.android.tv.ui.TvOverlayManager;
import com.android.tv.ui.TvViewUiManager;
import com.android.tv.ui.sidepanel.ClosedCaptionFragment;
import com.android.tv.ui.sidepanel.CustomizeChannelListFragment;
-import com.android.tv.ui.sidepanel.DebugOptionFragment;
+import com.android.tv.ui.sidepanel.DeveloperOptionFragment;
import com.android.tv.ui.sidepanel.DisplayModeFragment;
import com.android.tv.ui.sidepanel.MultiAudioFragment;
import com.android.tv.ui.sidepanel.SettingsFragment;
import com.android.tv.ui.sidepanel.SideFragment;
+import com.android.tv.util.AccountHelper;
import com.android.tv.util.CaptionSettings;
import com.android.tv.util.ImageCache;
import com.android.tv.util.ImageLoader;
@@ -133,9 +143,6 @@ import com.android.tv.util.SystemProperties;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.TvSettings;
import com.android.tv.util.TvSettings.PipSound;
-import com.android.usbtuner.UsbTunerPreferences;
-import com.android.usbtuner.setup.TunerSetupActivity;
-import com.android.usbtuner.tvinput.UsbTunerTvInputService;
import com.android.tv.util.TvTrackInfoUtils;
import com.android.tv.util.Utils;
@@ -146,12 +153,14 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* The main activity for the Live TV app.
*/
-public class MainActivity extends Activity implements AudioManager.OnAudioFocusChangeListener {
+public class MainActivity extends Activity implements AudioManager.OnAudioFocusChangeListener,
+ OnActionClickListener {
private static final String TAG = "MainActivity";
private static final boolean DEBUG = false;
@@ -177,12 +186,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
private static final int PERMISSIONS_REQUEST_READ_TV_LISTINGS = 1;
+ private static final int PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION = 2;
private static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS";
- private static final String USB_TV_TUNER_INPUT_ID =
- "com.android.tv/com.android.usbtuner.tvinput.UsbTunerTvInputService";
- private static final String DVR_TEST_INPUT_ID = USB_TV_TUNER_INPUT_ID;
-
// Tracker screen names.
public static final String SCREEN_NAME = "Main";
private static final String SCREEN_BEHIND_NAME = "Behind";
@@ -201,8 +207,10 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_VOLUME_MUTE);
BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_MUTE);
BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_SEARCH);
+ BLACKLIST_KEYCODE_TO_TIS.add(KeyEvent.KEYCODE_WINDOW);
}
+
private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1;
private static final int REQUEST_CODE_START_SYSTEM_CAPTIONING_SETTINGS = 2;
@@ -226,10 +234,25 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST, UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO,
UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK})
private @interface ChannelBannerUpdateReason {}
+ /**
+ * Updates channel banner because the channel banner is forced to show.
+ */
private static final int UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW = 1;
+ /**
+ * Updates channel banner because of tuning.
+ */
private static final int UPDATE_CHANNEL_BANNER_REASON_TUNE = 2;
+ /**
+ * Updates channel banner because of fast tuning.
+ */
private static final int UPDATE_CHANNEL_BANNER_REASON_TUNE_FAST = 3;
+ /**
+ * Updates channel banner because of info updating.
+ */
private static final int UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO = 4;
+ /**
+ * Updates channel banner because the current watched channel is locked or unlocked.
+ */
private static final int UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK = 5;
private static final int TVVIEW_SET_MAIN_TIMEOUT_MS = 3000;
@@ -251,11 +274,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
private final DurationTimer mMainDurationTimer = new DurationTimer();
private final DurationTimer mTuneDurationTimer = new DurationTimer();
private DvrManager mDvrManager;
- private DvrDataManager mDvrDataManager;
+ private ConflictChecker mDvrConflictChecker;
+ private View mContentView;
private TunableTvView mTvView;
private TunableTvView mPipView;
- private OverlayRootView mOverlayRootView;
private Bundle mTuneParams;
private boolean mChannelBannerHiddenBySideFragment;
// TODO: Move the scene views into TvTransitionManager or TvOverlayManager.
@@ -295,8 +318,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
private boolean mVisibleBehind;
private boolean mAc3PassthroughSupported;
private boolean mShowNewSourcesFragment = true;
- private Uri mRecordingUri;
- private String mUsbTunerInputId;
+ private String mTunerInputId;
private boolean mOtherActivityLaunched;
private boolean mIsFilmModeSet;
@@ -332,6 +354,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
private String mSource;
private final Handler mHandler = new MainActivityHandler(this);
+ private final Set<OnActionClickListener> mOnActionClickListeners = new ArraySet<>();
private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
@@ -390,12 +413,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
resumePipIfNeeded();
}
mKeypadChannelSwitchView.setChannels(mChannelTuner.getBrowsableChannelList());
- mHandler.post(new Runnable() {
- @Override
- public void run() {
- mOverlayManager.getMenu().setChannelTuner(mChannelTuner);
- }
- });
}
@Override
@@ -422,15 +439,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
};
private ProgramGuideSearchFragment mSearchFragment;
- private TvInputCallback mTvInputCallback = new TvInputCallback() {
+ private final TvInputCallback mTvInputCallback = new TvInputCallback() {
@Override
public void onInputAdded(String inputId) {
- if (mUsbTunerInputId.equals(inputId)
- && UsbTunerPreferences.shouldShowSetupActivity(MainActivity.this)) {
+ if (Features.TUNER.isEnabled(MainActivity.this) && mTunerInputId.equals(inputId)
+ && TunerPreferences.shouldShowSetupActivity(MainActivity.this)) {
Intent intent = TunerSetupActivity.createSetupActivity(MainActivity.this);
startActivity(intent);
- UsbTunerPreferences.setShouldShowSetupActivity(MainActivity.this, false);
- SetupUtils.getInstance(MainActivity.this).markAsKnownInput(mUsbTunerInputId);
+ TunerPreferences.setShouldShowSetupActivity(MainActivity.this, false);
+ SetupUtils.getInstance(MainActivity.this).markAsKnownInput(mTunerInputId);
}
}
};
@@ -445,17 +462,14 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
@Override
protected void onCreate(Bundle savedInstanceState) {
if (DEBUG) Log.d(TAG,"onCreate()");
+ TvApplication.setCurrentRunningProcess(this, true);
super.onCreate(savedInstanceState);
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
- && !PermissionUtils.hasAccessAllEpg(this)) {
- Toast.makeText(this, R.string.msg_not_supported_device, Toast.LENGTH_LONG).show();
- finish();
- return;
- }
- boolean skipToShowOnboarding = getIntent().getAction() == Intent.ACTION_VIEW
- && TvContract.isChannelUriForPassthroughInput(getIntent().getData());
- if (Features.ONBOARDING_EXPERIENCE.isEnabled(this)
- && OnboardingUtils.needToShowOnboarding(this) && !skipToShowOnboarding
+
+ boolean isPassthroughInput = TvContract.isChannelUriForPassthroughInput(getIntent()
+ .getData());
+ boolean skipToShowOnboarding = Intent.ACTION_VIEW.equals(getIntent().getAction())
+ && isPassthroughInput;
+ if (OnboardingUtils.needToShowOnboarding(this) && !skipToShowOnboarding
&& !TvCommonUtils.isRunningInTest()) {
// TODO: The onboarding is turned off in test, because tests are broken by the
// onboarding. We need to enable the feature for tests later.
@@ -464,31 +478,15 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return;
}
- TvApplication tvApplication = (TvApplication) getApplication();
- tvApplication.getMainActivityWrapper().onMainActivityCreated(this);
- if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) {
- Toast.makeText(this, "Using Strict Mode for eng builds", Toast.LENGTH_SHORT).show();
- }
- mTracker = tvApplication.getTracker();
- mTvInputManagerHelper = tvApplication.getTvInputManagerHelper();
- mTvInputManagerHelper.addCallback(mTvInputCallback);
- mUsbTunerInputId = UsbTunerTvInputService.getInputId(this);
- mChannelDataManager = tvApplication.getChannelDataManager();
- mProgramDataManager = tvApplication.getProgramDataManager();
- mProgramDataManager.addOnCurrentProgramUpdatedListener(Channel.INVALID_ID,
- mOnCurrentProgramUpdatedListener);
- mProgramDataManager.setPrefetchEnabled(true);
- mChannelTuner = new ChannelTuner(mChannelDataManager, mTvInputManagerHelper);
- mChannelTuner.addListener(mChannelTunerListener);
- mChannelTuner.start();
- mPipInputManager = new PipInputManager(this, mTvInputManagerHelper, mChannelTuner);
- mPipInputManager.start();
- mMemoryManageables.add(mProgramDataManager);
- mMemoryManageables.add(ImageCache.getInstance());
- mMemoryManageables.add(TvContentRatingCache.getInstance());
- if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) {
- mDvrManager = tvApplication.getDvrManager();
- mDvrDataManager = tvApplication.getDvrDataManager();
+ // Check this permission for the EPG fetch.
+ // TODO: check {@link shouldShowRequestPermissionRationale}.
+ // While testing, no way to allow the permission when the dialog shows up. So we'd better
+ // not show the dialog.
+ if (!TvCommonUtils.isRunningInTest() && Utils.hasInternalTvInputs(this, true)
+ && checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION},
+ PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION);
}
DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE);
@@ -499,9 +497,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
int screenHeight = size.y;
mDefaultRefreshRate = display.getRefreshRate();
- mOverlayRootView = (OverlayRootView) getLayoutInflater().inflate(
- R.layout.overlay_root_view, null, false);
setContentView(R.layout.activity_tv);
+ mContentView = findViewById(android.R.id.content);
mTvView = (TunableTvView) findViewById(R.id.main_tunable_tv_view);
int shrunkenTvViewHeight = getResources().getDimensionPixelSize(
R.dimen.shrunken_tvview_height);
@@ -529,6 +526,41 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return false;
}
});
+
+ long channelId = Utils.getLastWatchedChannelId(this);
+ String inputId = Utils.getLastWatchedTunerInputId(this);
+ if (!isPassthroughInput && inputId != null
+ && channelId != Channel.INVALID_ID) {
+ mTvView.warmUpInput(inputId, TvContract.buildChannelUri(channelId));
+ }
+
+ TvApplication tvApplication = (TvApplication) getApplication();
+ tvApplication.getMainActivityWrapper().onMainActivityCreated(this);
+ if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) {
+ Toast.makeText(this, "Using Strict Mode for eng builds", Toast.LENGTH_SHORT).show();
+ }
+ mTracker = tvApplication.getTracker();
+ mTvInputManagerHelper = tvApplication.getTvInputManagerHelper();
+ if (Features.TUNER.isEnabled(this)) {
+ mTvInputManagerHelper.addCallback(mTvInputCallback);
+ }
+ mTunerInputId = TunerTvInputService.getInputId(this);
+ mChannelDataManager = tvApplication.getChannelDataManager();
+ mProgramDataManager = tvApplication.getProgramDataManager();
+ mProgramDataManager.addOnCurrentProgramUpdatedListener(Channel.INVALID_ID,
+ mOnCurrentProgramUpdatedListener);
+ mProgramDataManager.setPrefetchEnabled(true);
+ mChannelTuner = new ChannelTuner(mChannelDataManager, mTvInputManagerHelper);
+ mChannelTuner.addListener(mChannelTunerListener);
+ mChannelTuner.start();
+ mPipInputManager = new PipInputManager(this, mTvInputManagerHelper, mChannelTuner);
+ mPipInputManager.start();
+ mMemoryManageables.add(mProgramDataManager);
+ mMemoryManageables.add(ImageCache.getInstance());
+ mMemoryManageables.add(TvContentRatingCache.getInstance());
+ if (CommonFeatures.DVR.isEnabled(this)) {
+ mDvrManager = tvApplication.getDvrManager();
+ }
mTimeShiftManager = new TimeShiftManager(this, mTvView, mProgramDataManager, mTracker,
new OnCurrentProgramUpdatedListener() {
@Override
@@ -542,6 +574,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
updateChannelBannerAndShowIfNeeded(
UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW);
break;
+ case TimeShiftManager.TIME_SHIFT_ACTION_ID_PAUSE:
+ case TimeShiftManager.TIME_SHIFT_ACTION_ID_PLAY:
default:
updateChannelBannerAndShowIfNeeded(
UPDATE_CHANNEL_BANNER_REASON_UPDATE_INFO);
@@ -587,7 +621,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
@Override
- public void onPassthroughInputSelected(TvInputInfo input) {
+ public void onPassthroughInputSelected(@NonNull TvInputInfo input) {
Channel currentChannel = mChannelTuner.getCurrentChannel();
String currentInputId = currentChannel == null ? null : currentChannel.getInputId();
if (TextUtils.equals(input.getId(), currentInputId)) {
@@ -607,7 +641,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
});
mSearchFragment = new ProgramGuideSearchFragment();
- mOverlayManager = new TvOverlayManager(this, mChannelTuner,
+ mOverlayManager = new TvOverlayManager(this, mChannelTuner, mTvView,
mKeypadChannelSwitchView, mChannelBannerView, inputBannerView,
selectInputView, sceneContainer, mSearchFragment);
@@ -617,7 +651,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mMediaSession = new MediaSession(this, MEDIA_SESSION_TAG);
mMediaSession.setCallback(new MediaSession.Callback() {
@Override
- public boolean onMediaButtonEvent(Intent mediaButtonIntent) {
+ public boolean onMediaButtonEvent(@NonNull Intent mediaButtonIntent) {
// Consume the media button event here. Should not send it to other apps.
return true;
}
@@ -653,49 +687,54 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
// To avoid not updating Rating systems when changing language.
mTvInputManagerHelper.getContentRatingsManager().update();
-
- initForTest();
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode, String[] permissions,
- int[] grantResults) {
- if (requestCode == PERMISSIONS_REQUEST_READ_TV_LISTINGS) {
- if (grantResults != null && grantResults.length > 0
- && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- // Start reload of dependent data
- mChannelDataManager.reload();
- mProgramDataManager.reload();
-
- // Restart live channels.
- Intent intent = getIntent();
- finish();
- startActivity(intent);
- } else {
- Toast.makeText(this, R.string.msg_read_tv_listing_permission_denied,
- Toast.LENGTH_LONG).show();
- finish();
- }
+ if (CommonFeatures.DVR.isEnabled(this)
+ && Features.SHOW_UPCOMING_CONFLICT_DIALOG.isEnabled(this)) {
+ mDvrConflictChecker = new ConflictChecker(this);
}
+ initForTest();
}
@Override
- public void onAttachedToWindow() {
- super.onAttachedToWindow();
- WindowManager.LayoutParams windowParams = new WindowManager.LayoutParams(
- WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL, 0, PixelFormat.TRANSPARENT);
- windowParams.token = getWindow().getDecorView().getWindowToken();
- ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).addView(mOverlayRootView,
- windowParams);
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ float density = getResources().getDisplayMetrics().density;
+ mTvViewUiManager.onConfigurationChanged((int) (newConfig.screenWidthDp * density),
+ (int) (newConfig.screenHeightDp * density));
}
@Override
- public void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).removeView(mOverlayRootView);
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case PERMISSIONS_REQUEST_READ_TV_LISTINGS:
+ if (grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ // Start reload of dependent data
+ mChannelDataManager.reload();
+ mProgramDataManager.reload();
+
+ // Restart live channels.
+ Intent intent = getIntent();
+ finish();
+ startActivity(intent);
+ } else {
+ Toast.makeText(this, R.string.msg_read_tv_listing_permission_denied,
+ Toast.LENGTH_LONG).show();
+ finish();
+ }
+ break;
+ case PERMISSIONS_REQUEST_ACCESS_COARSE_LOCATION:
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED
+ && Experiments.CLOUD_EPG.get()) {
+ EpgFetcher.getInstance(this).startImmediately();
+ } else {
+ EpgFetcher.getInstance(this).stop();
+ }
+ break;
+ }
}
- private int getDesiredBlockScreenType() {
+ @BlockScreenType private int getDesiredBlockScreenType() {
if (!mActivityResumed) {
return TunableTvView.BLOCK_SCREEN_TYPE_NO_UI;
}
@@ -727,6 +766,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
@Override
protected void onNewIntent(Intent intent) {
+ if (DEBUG) Log.d(TAG,"onNewIntent(): " + intent);
+ if (mOverlayManager == null) {
+ // It's called before onCreate. The intent will be handled at onCreate. b/30725058
+ return;
+ }
mOverlayManager.getSideFragmentManager().hideAll(false);
if (!handleIntent(intent) && !mActivityStarted) {
// If the activity is stopped and not destroyed, finish the activity.
@@ -760,6 +804,8 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
protected void onResume() {
if (DEBUG) Log.d(TAG, "onResume()");
super.onResume();
+ // Refresh the remote config, it is throttled automatically.
+ TvApplication.getSingletons(this).getRemoteConfig().fetch(null);
if (!PermissionUtils.hasAccessAllEpg(this)
&& checkSelfPermission(PERMISSION_READ_TV_LISTINGS)
!= PackageManager.PERMISSION_GRANTED) {
@@ -784,6 +830,16 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
// visible behind.
requestVisibleBehind(true);
}
+ if (Utils.hasRecordingFailedReason(getApplicationContext(),
+ TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE)) {
+ runAfterAttachedToWindow(new Runnable() {
+ @Override
+ public void run() {
+ DvrUiHelper.showDvrInsufficientSpaceErrorDialog(MainActivity.this);
+ }
+ });
+ }
+
if (mChannelTuner.areAllChannelsLoaded()) {
SetupUtils.getInstance(this).markNewChannelsBrowsable();
resumeTvIfNeeded();
@@ -822,11 +878,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
});
}
+ if (mDvrConflictChecker != null) {
+ mDvrConflictChecker.start();
+ }
}
@Override
protected void onPause() {
if (DEBUG) Log.d(TAG, "onPause()");
+ if (mDvrConflictChecker != null) {
+ mDvrConflictChecker.stop();
+ }
finishChannelChangeIfNeeded();
mActivityResumed = false;
mOverlayManager.hideOverlays(TvOverlayManager.FLAG_HIDE_OVERLAYS_DEFAULT);
@@ -882,7 +944,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
if (input == null) {
input = mTvInputManagerHelper.getTvInputInfo(mParentInputIdWhenScreenOff);
if (input == null) {
- SoftPreconditions.checkState(false, TAG, "Input disappear." + input);
+ SoftPreconditions.checkState(false, TAG, "Input disappear.");
finish();
} else {
mInitChannelUri =
@@ -929,16 +991,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
TAG, "startTV assumes that ChannelDataManager is already loaded.");
if (mTvView.isPlaying()) {
// TV has already started.
- if (channelUri == null) {
+ if (channelUri == null || channelUri.equals(mChannelTuner.getCurrentChannelUri())) {
// Simply adjust the volume without tune.
setVolumeByAudioFocusStatus();
return;
}
- if (channelUri.equals(mChannelTuner.getCurrentChannelUri())) {
- // The requested channel is already tuned.
- setVolumeByAudioFocusStatus();
- return;
- }
stopTv();
}
if (mChannelTuner.getCurrentChannel() != null) {
@@ -972,12 +1029,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mTvView.start(mTvInputManagerHelper);
setVolumeByAudioFocusStatus();
- if (mRecordingUri != null) {
- playRecording(mRecordingUri);
- mRecordingUri = null;
- } else {
- tune();
- }
+ tune();
}
@Override
@@ -1116,16 +1168,19 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return mOverlayManager;
}
+ /**
+ * Returns the {@link ConflictChecker}.
+ */
+ @Nullable
+ public ConflictChecker getDvrConflictChecker() {
+ return mDvrConflictChecker;
+ }
+
public Channel getCurrentChannel() {
- return mTvView.isRecordingPlayback() ? mTvView.getCurrentChannel()
- : mChannelTuner.getCurrentChannel();
+ return mChannelTuner.getCurrentChannel();
}
public long getCurrentChannelId() {
- if (mTvView.isRecordingPlayback()) {
- Channel channel = mTvView.getCurrentChannel();
- return channel == null ? Channel.INVALID_ID : channel.getId();
- }
return mChannelTuner.getCurrentChannelId();
}
@@ -1139,34 +1194,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
/**
* Returns the current program which the user is watching right now.<p>
*
- * If the time shifting is available, it can be a past program.
+ * It might be a live program. If the time shifting is available, it can be a past program, too.
*/
public Program getCurrentProgram() {
- return getCurrentProgram(true);
- }
-
- /**
- * Returns {@code true}, if this view is the recording playback mode.
- */
- public boolean isRecordingPlayback() {
- return mTvView.isRecordingPlayback();
- }
-
- /**
- * Returns the recording which is being played right now.
- */
- public RecordedProgram getPlayingRecordedProgram() {
- return mTvView.getPlayingRecordedProgram();
- }
-
- /**
- * Returns the current program which the user is watching right now.<p>
- *
- * @param applyTimeShifted If it is true and the time shifting is available, it can be
- * a past program.
- */
- public Program getCurrentProgram(boolean applyTimeShifted) {
- if (applyTimeShifted && mTimeShiftManager.isAvailable()) {
+ if (!isChannelChangeKeyDownReceived() && mTimeShiftManager.isAvailable()) {
+ // We shouldn't get current program from TimeShiftManager during channel tunning
return mTimeShiftManager.getCurrentProgram();
}
return mProgramDataManager.getCurrentProgram(getCurrentChannelId());
@@ -1185,7 +1217,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return System.currentTimeMillis();
}
- public Channel getBrowsableChannel() {
+ private Channel getBrowsableChannel() {
// TODO: mChannelMap could be dirty for a while when the browsablity of channels
// are changed. In that case, we shouldn't use the value from mChannelMap.
Channel curChannel = mChannelTuner.getCurrentChannel();
@@ -1228,7 +1260,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
public void showMerchantCollection() {
- startActivitySafe(OnboardingUtils.PLAY_STORE_INTENT);
+ startActivitySafe(OnboardingUtils.ONLINE_STORE_INTENT);
}
/**
@@ -1353,15 +1385,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
@Override
- public View findViewById(int id) {
- // In order to locate fragments in non-application window, we should override findViewById.
- // Internally, Activity.findViewById is called to attach a view of a fragment into its
- // container. Without the override, we'll get crash during the fragment attachment.
- View v = mOverlayRootView != null ? mOverlayRootView.findViewById(id) : null;
- return v == null ? super.findViewById(id) : v;
- }
-
- @Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (SystemProperties.LOG_KEYEVENT.getValue()) Log.d(TAG, "dispatchKeyEvent(" + event + ")");
// If an activity is closed on a back key down event, back key down events with none zero
@@ -1381,8 +1404,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
// When side panel is closing, it has the focus.
// Keep the focus, but just don't deliver the key events.
- if ((mOverlayRootView.hasFocusable()
- && !mOverlayManager.getSideFragmentManager().isHiding())
+ if ((mContentView.hasFocusable() && !mOverlayManager.getSideFragmentManager().isHiding())
|| mOverlayManager.getSideFragmentManager().isActive()) {
return super.dispatchKeyEvent(event);
}
@@ -1454,13 +1476,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
}
- if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) {
- mRecordingUri = intent.getParcelableExtra(Utils.EXTRA_KEY_RECORDING_URI);
- if (mRecordingUri != null) {
- return true;
- }
- }
-
// TODO: remove the checkState once N API is finalized.
SoftPreconditions.checkState(TvInputManager.ACTION_SETUP_INPUTS.equals(
"android.media.tv.action.SETUP_INPUTS"));
@@ -1709,7 +1724,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
/**
* Says {@code text} when accessibility is turned on.
*/
- public void sendAccessibilityText(String text) {
+ private void sendAccessibilityText(String text) {
if (mAccessibilityManager.isEnabled()) {
AccessibilityEvent event = AccessibilityEvent.obtain();
event.setClassName(getClass().getName());
@@ -1720,17 +1735,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
}
- private void playRecording(Uri recordingUri) {
- mTvView.playRecording(recordingUri, mOnTuneListener);
- mOnTuneListener.onPlayRecording();
- updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_TUNE);
- }
-
private void tune() {
if (DEBUG) Log.d(TAG, "tune()");
mTuneDurationTimer.start();
- lazyInitializeIfNeeded(LAZY_INITIALIZATION_DELAY);
+ lazyInitializeIfNeeded();
// Prerequisites to be able to tune.
if (mInputIdUnderSetup != null) {
@@ -1742,7 +1751,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
if (!mChannelTuner.isCurrentChannelPassthrough()) {
if (mTvInputManagerHelper.getTunerTvInputSize() == 0) {
Toast.makeText(this, R.string.msg_no_input, Toast.LENGTH_SHORT).show();
- // TODO: Direct the user to a Play Store landing page for TvInputService apps.
finish();
return;
}
@@ -1755,9 +1763,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
if (mChannelDataManager.getChannelCount() > 0) {
mOverlayManager.showIntroDialog();
- } else if (!Features.ONBOARDING_EXPERIENCE.isEnabled(this)) {
- mOverlayManager.showSetupFragment();
- return;
}
}
if (!TvCommonUtils.isRunningInTest() && mShowNewSourcesFragment
@@ -1821,23 +1826,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mLastAllowedRatingForCurrentChannel = null;
}
mHandler.removeMessages(MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE);
- if (mAccessibilityManager.isEnabled()) {
- // For every tune, we need to inform the tuned channel or input to a user,
- // if Talkback is turned on.
- AccessibilityEvent event = AccessibilityEvent.obtain();
- event.setClassName(getClass().getName());
- event.setPackageName(getPackageName());
- event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT);
- if (TvContract.isChannelUriForPassthroughInput(channel.getUri())) {
- TvInputInfo input = mTvInputManagerHelper.getTvInputInfo(channel.getInputId());
- event.getText().add(Utils.loadLabel(this, input));
- } else if (TextUtils.isEmpty(channel.getDisplayName())) {
- event.getText().add(channel.getDisplayNumber());
- } else {
- event.getText().add(channel.getDisplayNumber() + " " + channel.getDisplayName());
- }
- mAccessibilityManager.sendAccessibilityEvent(event);
- }
+ // For every tune, we need to inform the tuned channel or input to a user,
+ // if Talkback is turned on.
+ sendAccessibilityText(!mChannelTuner.isCurrentChannelPassthrough() ?
+ Utils.loadLabel(this, mTvInputManagerHelper.getTvInputInfo(channel.getInputId()))
+ : channel.getDisplayText());
boolean success = mTvView.tuneTo(channel, mTuneParams, mOnTuneListener);
mOnTuneListener.onTune(channel, isUnderShrunkenTvView());
@@ -1870,20 +1863,35 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
updateMediaSession();
}
+ // Runs the runnable after the activity is attached to window to show the fragment transition
+ // animation.
+ // The runnable runs asynchronously to show the animation a little better even when system is
+ // busy at the moment it is called.
+ // If the activity is paused shortly, runnable may not be called because all the fragments
+ // should be closed when the activity is paused.
private void runAfterAttachedToWindow(final Runnable runnable) {
- if (mOverlayRootView.isLaidOut()) {
- runnable.run();
- } else {
- mOverlayRootView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
- @Override
- public void onViewAttachedToWindow(View v) {
- mOverlayRootView.removeOnAttachStateChangeListener(this);
+ final Runnable runOnlyIfActivityIsResumed = new Runnable() {
+ @Override
+ public void run() {
+ if (mActivityResumed) {
runnable.run();
}
+ }
+ };
+ if (mContentView.isAttachedToWindow()) {
+ mHandler.post(runOnlyIfActivityIsResumed);
+ } else {
+ mContentView.getViewTreeObserver().addOnWindowAttachListener(
+ new ViewTreeObserver.OnWindowAttachListener() {
+ @Override
+ public void onWindowAttached() {
+ mContentView.getViewTreeObserver().removeOnWindowAttachListener(this);
+ mHandler.post(runOnlyIfActivityIsResumed);
+ }
- @Override
- public void onViewDetachedFromWindow(View v) { }
- });
+ @Override
+ public void onWindowDetached() { }
+ });
}
}
@@ -1905,82 +1913,108 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return;
}
- final Program program = getCurrentProgram();
- String cardTitleText = program == null ? null : program.getTitle();
+ final Program currentProgram = getCurrentProgram();
+ String cardTitleText = null;
+ String posterArtUri = null;
+ if (currentProgram != null) {
+ cardTitleText = currentProgram.getTitle();
+ posterArtUri = currentProgram.getPosterArtUri();
+ }
if (TextUtils.isEmpty(cardTitleText)) {
- cardTitleText = getCurrentChannel().getDisplayName();
+ cardTitleText = getCurrentChannelName();
}
updateMediaMetadata(cardTitleText, null);
setMediaSessionPlaybackState(true);
- if (program != null && program.getPosterArtUri() != null) {
- program.loadPosterArt(MainActivity.this, mNowPlayingCardWidth, mNowPlayingCardHeight,
- createProgramPosterArtCallback(MainActivity.this, program));
- } else {
- updateMediaMetadataWithAlternativeArt(program);
+ if (posterArtUri == null) {
+ posterArtUri = TvContract.buildChannelLogoUri(getCurrentChannelId()).toString();
}
-
+ updatePosterArt(getCurrentChannel(), currentProgram, cardTitleText, null, posterArtUri);
mMediaSession.setActive(true);
}
- private static ImageLoader.ImageLoaderCallback<MainActivity> createProgramPosterArtCallback(
- MainActivity mainActivity, final Program program) {
- return new ImageLoader.ImageLoaderCallback<MainActivity>(mainActivity) {
- @Override
- public void onBitmapLoaded(MainActivity mainActivity, @Nullable Bitmap posterArt) {
- if (program != mainActivity.getCurrentProgram()
- || mainActivity.getCurrentChannel() == null) {
- return;
- }
- mainActivity.updateProgramPosterArt(program, posterArt);
- }
- };
- }
-
- private void updateProgramPosterArt(Program program, @Nullable Bitmap posterArt) {
- if (getCurrentChannel() == null) {
- return;
- }
+ private void updatePosterArt(Channel currentChannel, Program currentProgram,
+ String cardTitleText, @Nullable Bitmap posterArt, @Nullable String posterArtUri) {
if (posterArt != null) {
- String cardTitleText = program == null ? null : program.getTitle();
- if (TextUtils.isEmpty(cardTitleText)) {
- cardTitleText = getCurrentChannel().getDisplayName();
- }
updateMediaMetadata(cardTitleText, posterArt);
+ } else if (posterArtUri != null) {
+ ImageLoader.loadBitmap(this, posterArtUri, mNowPlayingCardWidth, mNowPlayingCardHeight,
+ new ProgramPosterArtCallback(this, currentChannel,
+ currentProgram, cardTitleText));
} else {
- updateMediaMetadataWithAlternativeArt(program);
+ updateMediaMetadata(cardTitleText, R.drawable.default_now_card);
}
}
- private void updateMediaMetadata(String title, Bitmap posterArt) {
- MediaMetadata.Builder builder = new MediaMetadata.Builder();
- builder.putString(MediaMetadata.METADATA_KEY_TITLE, title);
- if (posterArt != null) {
- builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt);
+ private static class ProgramPosterArtCallback extends
+ ImageLoader.ImageLoaderCallback<MainActivity> {
+ private final Channel mChannel;
+ private final Program mProgram;
+ private final String mCardTitleText;
+
+ public ProgramPosterArtCallback(MainActivity mainActivity, Channel channel, Program program,
+ String cardTitleText) {
+ super(mainActivity);
+ mChannel = channel;
+ mProgram = program;
+ mCardTitleText = cardTitleText;
}
- mMediaSession.setMetadata(builder.build());
+
+ @Override
+ public void onBitmapLoaded(MainActivity mainActivity, @Nullable Bitmap posterArt) {
+ if (mainActivity.isNowPlayingProgram(mChannel, mProgram)) {
+ mainActivity.updatePosterArt(mChannel, mProgram, mCardTitleText, posterArt, null);
+ }
+ }
+ }
+
+ private boolean isNowPlayingProgram(Channel channel, Program program) {
+ return program == null ? (channel != null && getCurrentProgram() == null
+ && channel.equals(getCurrentChannel())) : program.equals(getCurrentProgram());
}
- private void updateMediaMetadataWithAlternativeArt(final Program program) {
+ private void updateMediaMetadata(final String title, final Bitmap posterArt) {
+ new AsyncTask<Void, Void, Void> () {
+ @Override
+ protected Void doInBackground(Void... arg0) {
+ MediaMetadata.Builder builder = new MediaMetadata.Builder();
+ builder.putString(MediaMetadata.METADATA_KEY_TITLE, title);
+ if (posterArt != null) {
+ builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt);
+ }
+ mMediaSession.setMetadata(builder.build());
+ return null;
+ }
+ }.execute();
+ }
+
+ private void updateMediaMetadata(final String title, final int imageResId) {
+ new AsyncTask<Void, Void, Void> () {
+ @Override
+ protected Void doInBackground(Void... arg0) {
+ MediaMetadata.Builder builder = new MediaMetadata.Builder();
+ builder.putString(MediaMetadata.METADATA_KEY_TITLE, title);
+ Bitmap posterArt = BitmapFactory.decodeResource(getResources(), imageResId);
+ if (posterArt != null) {
+ builder.putBitmap(MediaMetadata.METADATA_KEY_ART, posterArt);
+ }
+ mMediaSession.setMetadata(builder.build());
+ return null;
+ }
+ }.execute();
+ }
+
+ private String getCurrentChannelName() {
Channel channel = getCurrentChannel();
- if (channel == null || program != getCurrentProgram()) {
- return;
+ if (channel == null) {
+ return "";
}
-
- String cardTitleText;
if (channel.isPassthrough()) {
TvInputInfo input = getTvInputManagerHelper().getTvInputInfo(channel.getInputId());
- cardTitleText = Utils.loadLabel(this, input);
+ return Utils.loadLabel(this, input);
} else {
- cardTitleText = program == null ? null : program.getTitle();
- if (TextUtils.isEmpty(cardTitleText)) {
- cardTitleText = channel.getDisplayName();
- }
+ return channel.getDisplayName();
}
-
- Bitmap posterArt = BitmapFactory.decodeResource(
- getResources(), R.drawable.default_now_card);
- updateMediaMetadata(cardTitleText, posterArt);
}
private void setMediaSessionPlaybackState(boolean isPlaying) {
@@ -2055,7 +2089,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
private void updateChannelBannerAndShowIfNeeded(@ChannelBannerUpdateReason int reason) {
if(DEBUG) Log.d(TAG, "updateChannelBannerAndShowIfNeeded(reason=" + reason + ")");
- if (!mChannelTuner.isCurrentChannelPassthrough() || mTvView.isRecordingPlayback()) {
+ if (!mChannelTuner.isCurrentChannelPassthrough()) {
int lockType = ChannelBannerView.LOCK_NONE;
if (mTvView.isScreenBlocked()) {
lockType = ChannelBannerView.LOCK_CHANNEL_INFO;
@@ -2261,6 +2295,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
@Override
protected void onDestroy() {
if (DEBUG) Log.d(TAG, "onDestroy()");
+ SideFragment.releasePreloadedRecycledViews();
+ if (mTvView != null) {
+ mTvView.release();
+ }
+ if (mPipView != null) {
+ mPipView.release();
+ }
if (mChannelTuner != null) {
mChannelTuner.removeListener(mChannelTunerListener);
mChannelTuner.stop();
@@ -2299,7 +2340,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mChannelStatusRecurringRunner.stop();
mChannelStatusRecurringRunner = null;
}
- if (mTvInputManagerHelper != null) {
+ if (mTvInputManagerHelper != null && Features.TUNER.isEnabled(this)) {
mTvInputManagerHelper.removeCallback(mTvInputCallback);
}
super.onDestroy();
@@ -2333,9 +2374,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
case KeyEvent.KEYCODE_DPAD_UP:
if (event.getRepeatCount() == 0
&& mChannelTuner.getBrowsableChannelCount() > 0) {
- moveToAdjacentChannel(true, false);
+ // message sending should be done before moving channel, because we use the
+ // existence of message to decide if users are switching channel.
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHANNEL_UP_PRESSED,
System.currentTimeMillis()), CHANNEL_CHANGE_INITIAL_DELAY_MILLIS);
+ moveToAdjacentChannel(true, false);
mTracker.sendChannelUp();
}
return true;
@@ -2343,9 +2386,11 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
case KeyEvent.KEYCODE_DPAD_DOWN:
if (event.getRepeatCount() == 0
&& mChannelTuner.getBrowsableChannelCount() > 0) {
- moveToAdjacentChannel(false, false);
+ // message sending should be done before moving channel, because we use the
+ // existence of message to decide if users are switching channel.
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CHANNEL_DOWN_PRESSED,
System.currentTimeMillis()), CHANNEL_CHANGE_INITIAL_DELAY_MILLIS);
+ moveToAdjacentChannel(false, false);
mTracker.sendChannelDown();
}
return true;
@@ -2362,14 +2407,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
* A KEYCODE_MEDIA_AUDIO_TRACK
* D debug: show debug options
* E updateChannelBannerAndShowIfNeeded
+ * G debug: refresh cloud epg
* I KEYCODE_TV_INPUT
* O debug: show display mode option
* P debug: togglePipView
* S KEYCODE_CAPTIONS: select subtitle
* W debug: toggle screen size
* V KEYCODE_MEDIA_RECORD debug: record the current channel for 30 sec
- * X KEYCODE_BUTTON_X KEYCODE_PROG_BLUE debug: record current channel for a few minutes
- * Y KEYCODE_BUTTON_Y KEYCODE_PROG_GREEN debug: Play a recording
*/
if (SystemProperties.LOG_KEYEVENT.getValue()) {
Log.d(TAG, "onKeyUp(" + keyCode + ", " + event + ")");
@@ -2428,6 +2472,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (!mTvView.isVideoAvailable()
+ && mTvView.getVideoUnavailableReason()
+ == TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE) {
+ DvrUiHelper.startSchedulesActivityForTuneConflict(this,
+ mChannelTuner.getCurrentChannel());
+ return true;
+ }
if (!PermissionUtils.hasModifyParentalControls(this)) {
// TODO: support this feature for non-system LC app. b/23939816
return true;
@@ -2464,7 +2515,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
false);
}
return true;
-
+ case KeyEvent.KEYCODE_WINDOW:
+ enterPictureInPictureMode();
+ return true;
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_NUMPAD_ENTER:
case KeyEvent.KEYCODE_E:
@@ -2481,8 +2534,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_FORCE_SHOW);
}
if (keyCode != KeyEvent.KEYCODE_E) {
- mOverlayManager.showMenu(mTvView.isRecordingPlayback()
- ? Menu.REASON_RECORDING_PLAYBACK : Menu.REASON_NONE);
+ mOverlayManager.showMenu(Menu.REASON_NONE);
}
return true;
case KeyEvent.KEYCODE_CHANNEL_UP:
@@ -2515,9 +2567,62 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mOverlayManager.showBanner();
return true;
}
+ case KeyEvent.KEYCODE_MEDIA_RECORD:
+ case KeyEvent.KEYCODE_V: {
+ Channel currentChannel = getCurrentChannel();
+ if (currentChannel != null && mDvrManager != null) {
+ boolean isRecording =
+ mDvrManager.getCurrentRecording(currentChannel.getId()) != null;
+ if (!isRecording) {
+ if (!mDvrManager.isChannelRecordable(currentChannel)) {
+ Toast.makeText(this, R.string.dvr_msg_cannot_record_program,
+ Toast.LENGTH_SHORT).show();
+ } else {
+ if (!DvrUiHelper.checkStorageStatusAndShowErrorMessage(this,
+ currentChannel.getInputId())) {
+ return true;
+ }
+ Program program = mProgramDataManager
+ .getCurrentProgram(currentChannel.getId());
+ if (program == null) {
+ DvrUiHelper
+ .showChannelRecordDurationOptions(this, currentChannel);
+ } else if (DvrUiHelper.handleCreateSchedule(this, program)) {
+ String msg = getString(
+ R.string.dvr_msg_current_program_scheduled,
+ program.getTitle(), Utils.toTimeString(
+ program.getEndTimeUtcMillis(), false));
+ Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
+ }
+ }
+ } else {
+ DvrUiHelper.showStopRecordingDialog(this, currentChannel.getId(),
+ DvrStopRecordingFragment.REASON_USER_STOP,
+ new HalfSizedDialogFragment.OnActionClickListener() {
+ @Override
+ public void onActionClick(long actionId) {
+ if (actionId == DvrStopRecordingFragment.ACTION_STOP) {
+ ScheduledRecording currentRecording =
+ mDvrManager.getCurrentRecording(
+ currentChannel.getId());
+ if (currentRecording != null) {
+ mDvrManager.stopRecording(currentRecording);
+ }
+ }
+ }
+ });
+ }
+ }
+ return true;
+ }
}
}
- if (SystemProperties.USE_DEBUG_KEYS.getValue()) {
+ if (keyCode == KeyEvent.KEYCODE_WINDOW) {
+ // Consumes the PIP button to prevent entering PIP mode
+ // in case that TV isn't showing properly (e.g. no browsable channel)
+ return true;
+ }
+ if (SystemProperties.USE_DEBUG_KEYS.getValue() || BuildConfig.ENG) {
switch (keyCode) {
case KeyEvent.KEYCODE_W: {
mDebugNonFullSizeScreen = !mDebugNonFullSizeScreen;
@@ -2551,57 +2656,9 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mOverlayManager.getSideFragmentManager().show(new DisplayModeFragment());
return true;
}
-
case KeyEvent.KEYCODE_D:
- mOverlayManager.getSideFragmentManager().show(new DebugOptionFragment());
- return true;
-
- case KeyEvent.KEYCODE_MEDIA_RECORD: // TODO(DVR) handle with debug_keys set
- case KeyEvent.KEYCODE_V: {
- DvrManager dvrManager = TvApplication.getSingletons(this).getDvrManager();
- long startTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5);
- long endTime = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(35);
- dvrManager.addSchedule(getCurrentChannel(), startTime, endTime);
+ mOverlayManager.getSideFragmentManager().show(new DeveloperOptionFragment());
return true;
- }
- case KeyEvent.KEYCODE_PROG_BLUE:
- case KeyEvent.KEYCODE_BUTTON_X:
- case KeyEvent.KEYCODE_X: {
- if (CommonFeatures.DVR.isEnabled(this)) {
- Channel channel = mTvView.getCurrentChannel();
- long channelId = channel.getId();
- Program p = mProgramDataManager.getCurrentProgram(channelId);
- if (p == null) {
- long now = System.currentTimeMillis();
- mDvrManager
- .addSchedule(channel, now, now + TimeUnit.MINUTES.toMillis(1));
- } else {
- mDvrManager.addSchedule(p,
- mDvrManager.getScheduledRecordingsThatConflict(p));
- }
- return true;
- }
- }
- case KeyEvent.KEYCODE_PROG_YELLOW:
- case KeyEvent.KEYCODE_BUTTON_Y:
- case KeyEvent.KEYCODE_Y: {
- if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) {
- // TODO(DVR) only get finished recordings.
- List<RecordedProgram> recordedPrograms = mDvrDataManager
- .getRecordedPrograms();
- Log.d(TAG, "Found " + recordedPrograms.size() + " recordings");
- if (recordedPrograms.isEmpty()) {
- Toast.makeText(this, "No finished recording to play", Toast.LENGTH_LONG)
- .show();
- } else {
- RecordedProgram r = recordedPrograms.get(0);
- Intent intent = new Intent(this, DvrPlayActivity.class);
- intent.putExtra(ScheduledRecording.RECORDING_ID_EXTRA, r.getId());
- startActivity(intent);
- }
- return true;
- }
- }
}
}
return super.onKeyUp(keyCode, event);
@@ -2661,6 +2718,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
});
}
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (!hasFocus) {
+ finishChannelChangeIfNeeded();
+ }
+ }
+
public void togglePipView() {
enablePipView(!mPipEnabled, true);
mOverlayManager.getMenu().update();
@@ -2681,7 +2745,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
startPip(true);
}
- public void enablePipView(boolean enable, boolean fromUserInteraction) {
+ private void enablePipView(boolean enable, boolean fromUserInteraction) {
if (enable == mPipEnabled) {
return;
}
@@ -2774,7 +2838,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return mChannelTuner.isCurrentChannelPassthrough() && !mPipEnabled;
}
- public void tuneToLastWatchedChannelForTunerInput() {
+ private void tuneToLastWatchedChannelForTunerInput() {
if (!mChannelTuner.isCurrentChannelPassthrough()) {
return;
}
@@ -2930,14 +2994,13 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
@Override
- public void startActivity(Intent intent) {
- mOtherActivityLaunched = true;
- super.startActivity(intent);
- }
-
- @Override
public void startActivityForResult(Intent intent, int requestCode) {
mOtherActivityLaunched = true;
+ if (intent.getCategories() == null
+ || !intent.getCategories().contains(Intent.CATEGORY_HOME)) {
+ // Workaround b/30150267
+ requestVisibleBehind(false);
+ }
super.startActivityForResult(intent, requestCode);
}
@@ -2949,7 +3012,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return mTvView.getSelectedTrack(type);
}
- public void selectTrack(int type, TvTrackInfo track) {
+ private void selectTrack(int type, TvTrackInfo track) {
mTvView.selectTrack(type, track == null ? null : track.getId());
if (type == TvTrackInfo.TYPE_AUDIO) {
mTvOptionsManager.onMultiAudioChanged(track == null ? null :
@@ -3021,6 +3084,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
int stringId;
switch (info.getVideoUnavailableReason()) {
case TunableTvView.VIDEO_UNAVAILABLE_REASON_NOT_TUNED:
+ case TunableTvView.VIDEO_UNAVAILABLE_REASON_NO_RESOURCE:
case TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING:
case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING:
case TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY:
@@ -3050,6 +3114,31 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
return mCaptionSettings;
}
+ /**
+ * Adds the {@link OnActionClickListener}.
+ */
+ public void addOnActionClickListener(OnActionClickListener listener) {
+ mOnActionClickListeners.add(listener);
+ }
+
+ /**
+ * Removes the {@link OnActionClickListener}.
+ */
+ public void removeOnActionClickListener(OnActionClickListener listener) {
+ mOnActionClickListeners.remove(listener);
+ }
+
+ @Override
+ public boolean onActionClick(String category, int actionId, Bundle params) {
+ // There should be only one action listener per an action.
+ for (OnActionClickListener l : mOnActionClickListeners) {
+ if (l.onActionClick(category, actionId, params)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
// Initialize TV app for test. The setup process should be finished before the Live TV app is
// started. We only enable all the channels here.
private void initForTest() {
@@ -3061,7 +3150,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
}
// Lazy initialization
- private void lazyInitializeIfNeeded(long delay) {
+ private void lazyInitializeIfNeeded() {
// Already initialized.
if (mLazyInitialized) {
return;
@@ -3071,10 +3160,12 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
- initAnimations();
- initSideFragments();
+ if (mActivityStarted) {
+ initAnimations();
+ initSideFragments();
+ }
}
- }, delay);
+ }, LAZY_INITIALIZATION_DELAY);
}
private void initAnimations() {
@@ -3104,13 +3195,17 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
switch (msg.what) {
case MSG_CHANNEL_DOWN_PRESSED:
long startTime = (Long) msg.obj;
- mainActivity.moveToAdjacentChannel(false, true);
+ // message re-sending should be done before moving channel, because we use the
+ // existence of message to decide if users are switching channel.
sendMessageDelayed(Message.obtain(msg), getDelay(startTime));
+ mainActivity.moveToAdjacentChannel(false, true);
break;
case MSG_CHANNEL_UP_PRESSED:
startTime = (Long) msg.obj;
- mainActivity.moveToAdjacentChannel(true, true);
+ // message re-sending should be done before moving channel, because we use the
+ // existence of message to decide if users are switching channel.
sendMessageDelayed(Message.obtain(msg), getDelay(startTime));
+ mainActivity.moveToAdjacentChannel(true, true);
break;
case MSG_UPDATE_CHANNEL_BANNER_BY_INFO_UPDATE:
mainActivity.updateChannelBannerAndShowIfNeeded(
@@ -3142,13 +3237,6 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mWasUnderShrunkenTvView = wasUnderShrukenTvView;
}
- private void onPlayRecording() {
- mStreamInfoUpdateTimeThresholdMs =
- System.currentTimeMillis() + FIRST_STREAM_INFO_UPDATE_DELAY_MILLIS;
- mChannel = null;
- mWasUnderShrunkenTvView = false;
- }
-
@Override
public void onUnexpectedStop(Channel channel) {
stopTv();
@@ -3157,8 +3245,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
@Override
public void onTuneFailed(Channel channel) {
- Log.w(TAG, "Failed to tune to channel " + channel.getId()
- + "@" + channel.getInputId());
+ Log.w(TAG, "onTuneFailed(" + channel + ")");
if (mTvView.isFadedOut()) {
mTvView.removeFadeEffect();
}
@@ -3232,7 +3319,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
mUnlockAllowedRatingBeforeShrunken = isUnderShrunkenTvView();
mTvView.unblockContent(rating);
}
-
+ mChannelBannerView.setBlockingContentRating(rating);
updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK);
mTvViewUiManager.fadeInTvView();
}
@@ -3242,6 +3329,7 @@ public class MainActivity extends Activity implements AudioManager.OnAudioFocusC
if (!isUnderShrunkenTvView()) {
mUnlockAllowedRatingBeforeShrunken = false;
}
+ mChannelBannerView.setBlockingContentRating(null);
updateChannelBannerAndShowIfNeeded(UPDATE_CHANNEL_BANNER_REASON_LOCK_OR_UNLOCK);
}
}
diff --git a/src/com/android/tv/MainActivityWrapper.java b/src/com/android/tv/MainActivityWrapper.java
index 82e96d14..01733255 100644
--- a/src/com/android/tv/MainActivityWrapper.java
+++ b/src/com/android/tv/MainActivityWrapper.java
@@ -19,7 +19,6 @@ package com.android.tv;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
-import android.support.annotation.UiThread;
import android.util.ArraySet;
import com.android.tv.data.Channel;
@@ -54,7 +53,6 @@ public final class MainActivityWrapper {
/**
* Sets the currently created main activity instance.
*/
- @UiThread
public void onMainActivityCreated(@NonNull MainActivity activity) {
mActivity = activity;
}
@@ -62,7 +60,6 @@ public final class MainActivityWrapper {
/**
* Unsets the main activity instance.
*/
- @UiThread
public void onMainActivityDestroyed(@NonNull MainActivity activity) {
if (mActivity != activity) {
mActivity = null;
@@ -104,7 +101,6 @@ public final class MainActivityWrapper {
/**
* Adds OnCurrentChannelChangeListener.
*/
- @UiThread
public void addOnCurrentChannelChangeListener(OnCurrentChannelChangeListener listener) {
mListeners.add(listener);
}
@@ -112,7 +108,6 @@ public final class MainActivityWrapper {
/**
* Removes OnCurrentChannelChangeListener.
*/
- @UiThread
public void removeOnCurrentChannelChangeListener(OnCurrentChannelChangeListener listener) {
mListeners.remove(listener);
}
diff --git a/src/com/android/tv/SetupPassthroughActivity.java b/src/com/android/tv/SetupPassthroughActivity.java
index e6373505..8a263a26 100644
--- a/src/com/android/tv/SetupPassthroughActivity.java
+++ b/src/com/android/tv/SetupPassthroughActivity.java
@@ -25,8 +25,11 @@ import android.util.Log;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.TvCommonConstants;
+import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.experiments.Experiments;
import com.android.tv.util.SetupUtils;
import com.android.tv.util.TvInputManagerHelper;
+import com.android.tv.util.Utils;
/**
* An activity to launch a TV input setup activity.
@@ -74,7 +77,25 @@ public class SetupPassthroughActivity extends Activity {
Bundle extras = intent.getExtras();
extras.remove(TvCommonConstants.EXTRA_SETUP_INTENT);
setupIntent.putExtras(extras);
- startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY);
+ try {
+ startActivityForResult(setupIntent, REQUEST_START_SETUP_ACTIVITY);
+ } catch (ActivityNotFoundException e) {
+ Log.e(TAG, "Can't find activity: " + setupIntent.getComponent());
+ finish();
+ return;
+ }
+ if (Utils.isInternalTvInput(this, mTvInputInfo.getId()) && Experiments.CLOUD_EPG.get()) {
+ EpgFetcher.getInstance(this).stop();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (mTvInputInfo != null && Utils.isInternalTvInput(this, mTvInputInfo.getId())
+ && Experiments.CLOUD_EPG.get()) {
+ EpgFetcher.getInstance(this).start();
+ }
+ super.onDestroy();
}
@Override
diff --git a/src/com/android/tv/TimeShiftManager.java b/src/com/android/tv/TimeShiftManager.java
index a231c29d..2d6d45c4 100644
--- a/src/com/android/tv/TimeShiftManager.java
+++ b/src/com/android/tv/TimeShiftManager.java
@@ -16,6 +16,7 @@
package com.android.tv;
+import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.os.Handler;
@@ -30,7 +31,6 @@ import android.util.Range;
import com.android.tv.analytics.Tracker;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.WeakHandler;
-import com.android.tv.common.recording.RecordedProgram;
import com.android.tv.data.Channel;
import com.android.tv.data.OnCurrentProgramUpdatedListener;
import com.android.tv.data.Program;
@@ -38,6 +38,7 @@ import com.android.tv.data.ProgramDataManager;
import com.android.tv.ui.TunableTvView;
import com.android.tv.ui.TunableTvView.TimeShiftListener;
import com.android.tv.util.AsyncDbTask;
+import com.android.tv.util.TimeShiftUtils;
import com.android.tv.util.Utils;
import java.lang.annotation.Retention;
@@ -77,10 +78,6 @@ public class TimeShiftManager {
public static final int PLAY_SPEED_4X = 4;
public static final int PLAY_SPEED_5X = 5;
- private static final int SHORT_PROGRAM_THRESHOLD_MILLIS = 46 * 60 * 1000; // 46 mins.
- private static final int[] SHORT_PROGRAM_SPEED_FACTORS = new int[] {2, 4, 12, 48};
- private static final int[] LONG_PROGRAM_SPEED_FACTORS = new int[] {2, 8, 32, 128};
-
@Retention(RetentionPolicy.SOURCE)
@IntDef({PLAY_DIRECTION_FORWARD, PLAY_DIRECTION_BACKWARD})
public @interface PlayDirection{}
@@ -109,6 +106,9 @@ public class TimeShiftManager {
private static final long PREFETCH_TIME_OFFSET_FROM_PROGRAM_END = TimeUnit.MINUTES.toMillis(1);
private static final long PREFETCH_DURATION_FOR_NEXT = TimeUnit.HOURS.toMillis(2);
+ private static final long ALLOWED_START_TIME_OFFSET = TimeUnit.DAYS.toMillis(14);
+ private static final long TWO_WEEKS_MS = TimeUnit.DAYS.toMillis(14);
+
@VisibleForTesting
static final long REQUEST_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(3);
@@ -143,9 +143,9 @@ public class TimeShiftManager {
* due to the elapsed time to pass the message from TIS to Live TV.
* So the boundary threshold is necessary.
* The same goes for the recording start time.
- * It must be three times longer than {@link #REQUEST_CURRENT_POSITION_INTERVAL} at least.
+ * It's the same {@link #REQUEST_CURRENT_POSITION_INTERVAL}.
*/
- private static final long RECORDING_BOUNDARY_THRESHOLD = 3 * REQUEST_CURRENT_POSITION_INTERVAL;
+ private static final long RECORDING_BOUNDARY_THRESHOLD = REQUEST_CURRENT_POSITION_INTERVAL;
private final PlayController mPlayController;
private final ProgramManager mProgramManager;
@@ -178,12 +178,6 @@ public class TimeShiftManager {
mProgramManager = new ProgramManager(programDataManager);
mTracker = tracker;
mOnCurrentProgramUpdatedListener = onCurrentProgramUpdatedListener;
- tvView.setOnScreenBlockedListener(new TunableTvView.OnScreenBlockingChangedListener() {
- @Override
- public void onScreenBlockingChanged(boolean blocked) {
- mPlayController.onAvailabilityChanged();
- }
- });
}
/**
@@ -448,6 +442,8 @@ public class TimeShiftManager {
}
private void updateCurrentProgram() {
+ SoftPreconditions.checkState(isAvailable(), TAG, "Time shift is not available");
+ SoftPreconditions.checkState(mCurrentPositionMediator.mCurrentPositionMs != INVALID_TIME);
Program currentProgram = getProgramAt(mCurrentPositionMediator.mCurrentPositionMs);
if (!Program.isValid(currentProgram)) {
currentProgram = null;
@@ -467,13 +463,6 @@ public class TimeShiftManager {
}
/**
- * Checks whether the TV is playing the recorded content.
- */
- public boolean isRecordingPlayback() {
- return mPlayController.mRecordingPlayback;
- }
-
- /**
* Returns {@code true} if the trick play is available and it's playing to the forward direction
* with normal speed, otherwise {@code false}.
*/
@@ -506,9 +495,9 @@ public class TimeShiftManager {
}
void onAvailabilityChanged() {
+ mCurrentPositionMediator.initialize(mPlayController.mRecordStartTimeMs);
mProgramManager.onAvailabilityChanged(mPlayController.mAvailable,
- mPlayController.mRecordingPlayback ? null : mPlayController.getCurrentChannel(),
- mPlayController.mRecordStartTimeMs);
+ mPlayController.getCurrentChannel(), mPlayController.mRecordStartTimeMs);
updateActions();
// Availability change notification should be always sent
// even if mNotificationEnabled is false.
@@ -564,28 +553,19 @@ public class TimeShiftManager {
}
private int getPlaybackSpeed() {
- int[] playbackSpeedList;
- if (getCurrentProgram() == null || getCurrentProgram().getEndTimeUtcMillis()
- - getCurrentProgram().getStartTimeUtcMillis() > SHORT_PROGRAM_THRESHOLD_MILLIS) {
- playbackSpeedList = LONG_PROGRAM_SPEED_FACTORS;
+ if (mPlayController.mDisplayedPlaySpeed == PLAY_SPEED_1X) {
+ return 1;
} else {
- playbackSpeedList = SHORT_PROGRAM_SPEED_FACTORS;
- }
- switch (mPlayController.mDisplayedPlaySpeed) {
- case PLAY_SPEED_1X:
- return 1;
- case PLAY_SPEED_2X:
- return playbackSpeedList[0];
- case PLAY_SPEED_3X:
- return playbackSpeedList[1];
- case PLAY_SPEED_4X:
- return playbackSpeedList[2];
- case PLAY_SPEED_5X:
- return playbackSpeedList[3];
- default:
+ long durationMs =
+ (getCurrentProgram() == null ? 0 : getCurrentProgram().getDurationMillis());
+ if (mPlayController.mDisplayedPlaySpeed > PLAY_SPEED_5X) {
Log.w(TAG, "Unknown displayed play speed is chosen : "
+ mPlayController.mDisplayedPlaySpeed);
- return 1;
+ return TimeShiftUtils.getMaxPlaybackSpeed(durationMs);
+ } else {
+ return TimeShiftUtils.getPlaybackSpeed(
+ mPlayController.mDisplayedPlaySpeed - PLAY_SPEED_2X, durationMs);
+ }
}
}
@@ -595,6 +575,7 @@ public class TimeShiftManager {
private class PlayController {
private final TunableTvView mTvView;
+ private long mAvailablityChangedTimeMs;
private long mRecordStartTimeMs;
private long mRecordEndTimeMs;
@@ -603,7 +584,6 @@ public class TimeShiftManager {
@PlayDirection private int mPlayDirection = PLAY_DIRECTION_FORWARD;
private int mPlaybackSpeed;
private boolean mAvailable;
- private boolean mRecordingPlayback;
/**
* Indicates that the trick play is not playing the current time position.
@@ -619,11 +599,25 @@ public class TimeShiftManager {
mTvView.setTimeShiftListener(new TimeShiftListener() {
@Override
public void onAvailabilityChanged() {
+ if (DEBUG) {
+ Log.d(TAG, "onAvailabilityChanged(available="
+ + mTvView.isTimeShiftAvailable() + ")");
+ }
PlayController.this.onAvailabilityChanged();
}
@Override
public void onRecordStartTimeChanged(long recordStartTimeMs) {
+ if (!SoftPreconditions.checkState(mAvailable, TAG,
+ "Trick play is not available.")) {
+ return;
+ }
+ if (recordStartTimeMs < mAvailablityChangedTimeMs - ALLOWED_START_TIME_OFFSET) {
+ Log.e(TAG, "The start time is too earlier than the time of availability: {"
+ + "startTime: " + recordStartTimeMs + ", availability: "
+ + mAvailablityChangedTimeMs);
+ return;
+ }
if (mRecordStartTimeMs == recordStartTimeMs) {
return;
}
@@ -649,7 +643,7 @@ public class TimeShiftManager {
}
void onAvailabilityChanged() {
- boolean newAvailable = mTvView.isTimeShiftAvailable() && !mTvView.isScreenBlocked();
+ boolean newAvailable = mTvView.isTimeShiftAvailable();
if (mAvailable == newAvailable) {
return;
}
@@ -661,27 +655,22 @@ public class TimeShiftManager {
mDisplayedPlaySpeed = PLAY_SPEED_1X;
mPlaybackSpeed = 1;
mPlayDirection = PLAY_DIRECTION_FORWARD;
- mRecordingPlayback = mTvView.isRecordingPlayback();
- if (mRecordingPlayback) {
- RecordedProgram recordedProgram = mTvView.getPlayingRecordedProgram();
- SoftPreconditions.checkNotNull(recordedProgram);
- mIsPlayOffsetChanged = true;
- mRecordStartTimeMs = 0;
- mRecordEndTimeMs = recordedProgram.getDurationMillis();
- } else {
- mIsPlayOffsetChanged = false;
- mRecordStartTimeMs = System.currentTimeMillis();
- mRecordEndTimeMs = CURRENT_TIME;
- }
- mCurrentPositionMediator.initialize(mRecordStartTimeMs);
mHandler.removeMessages(MSG_GET_CURRENT_POSITION);
if (mAvailable) {
+ mAvailablityChangedTimeMs = System.currentTimeMillis();
+ mIsPlayOffsetChanged = false;
+ mRecordStartTimeMs = mAvailablityChangedTimeMs;
+ mRecordEndTimeMs = CURRENT_TIME;
// When the media availability message has come.
mPlayController.setPlayStatus(PLAY_STATUS_PLAYING);
mHandler.sendEmptyMessageDelayed(MSG_GET_CURRENT_POSITION,
REQUEST_CURRENT_POSITION_INTERVAL);
} else {
+ mAvailablityChangedTimeMs = INVALID_TIME;
+ mIsPlayOffsetChanged = false;
+ mRecordStartTimeMs = INVALID_TIME;
+ mRecordEndTimeMs = INVALID_TIME;
// When the tune command is sent.
mPlayController.setPlayStatus(PLAY_STATUS_PAUSED);
}
@@ -806,6 +795,7 @@ public class TimeShiftManager {
}
}
+ @SuppressLint("SwitchIntDef")
private void increaseDisplayedPlaySpeed() {
switch (mDisplayedPlaySpeed) {
case PLAY_SPEED_1X:
@@ -867,7 +857,7 @@ public class TimeShiftManager {
mPrograms.clear();
mEmptyFetchCount = 0;
mChannel = channel;
- if (channel == null || channel.isPassthrough()) {
+ if (channel == null || channel.isPassthrough() || currentPositionMs == INVALID_TIME) {
return;
}
if (available) {
@@ -913,32 +903,14 @@ public class TimeShiftManager {
if (mProgramLoadTask == null || mProgramLoadTask.isCancelled()) {
startNext();
} else {
- switch (mProgramLoadTask.getStatus()) {
- case PENDING:
- if (mProgramLoadTask.overlaps(mProgramLoadQueue)) {
- if (mProgramLoadTask.cancel(true)) {
- mProgramLoadQueue.add(mProgramLoadTask.getPeriod());
- mProgramLoadTask = null;
- startNext();
- }
- }
- break;
- case RUNNING:
- // Remove pending task fully satisfied by the current
- Range<Long> current = mProgramLoadTask.getPeriod();
- Iterator<Range<Long>> i = mProgramLoadQueue.iterator();
- while (i.hasNext()) {
- Range<Long> r = i.next();
- if (current.contains(r)) {
- i.remove();
- }
- }
- break;
- case FINISHED:
- // The task should have already cleared it self, clear and restart anyways.
- Log.w(TAG, mProgramLoadTask + " is finished, but was not cleared");
- startNext();
- break;
+ // Remove pending task fully satisfied by the current
+ Range<Long> current = mProgramLoadTask.getPeriod();
+ Iterator<Range<Long>> i = mProgramLoadQueue.iterator();
+ while (i.hasNext()) {
+ Range<Long> r = i.next();
+ if (current.contains(r)) {
+ i.remove();
+ }
}
}
}
@@ -1025,10 +997,9 @@ public class TimeShiftManager {
}
private void removeDummyPrograms() {
- for (int i = 0; i < mPrograms.size(); ++i) {
- Program program = mPrograms.get(i);
- if (!program.isValid()) {
- mPrograms.remove(i--);
+ for (Iterator<Program> it = mPrograms.listIterator(); it.hasNext(); ) {
+ if (!it.next().isValid()) {
+ it.remove();
}
}
}
@@ -1068,6 +1039,10 @@ public class TimeShiftManager {
// to show the time-line duration of {@link MAX_DUMMY_PROGRAM_DURATION} at most
// for a dummy program.
private List<Program> createDummyPrograms(long startTimeMs, long endTimeMs) {
+ SoftPreconditions.checkArgument(endTimeMs - startTimeMs <= TWO_WEEKS_MS, TAG,
+ "createDummyProgram: long duration of dummy programs are requested ("
+ + Utils.toTimeString(startTimeMs) + ", "
+ + Utils.toTimeString(endTimeMs));
if (startTimeMs >= endTimeMs) {
return Collections.emptyList();
}
@@ -1162,7 +1137,7 @@ public class TimeShiftManager {
if (DEBUG) Log.d(TAG, "Scheduling with " + delay + "(ms) delays.");
}
- // Prefecth programs within PREFETCH_DURATION_FOR_NEXT from now.
+ // Prefetch programs within PREFETCH_DURATION_FOR_NEXT from now.
private void prefetchPrograms() {
long startTimeMs;
Program lastValidProgram = getLastValidProgram();
@@ -1185,7 +1160,7 @@ public class TimeShiftManager {
private class LoadProgramsForCurrentChannelTask
extends AsyncDbTask.LoadProgramsForChannelTask {
- public LoadProgramsForCurrentChannelTask(ContentResolver contentResolver,
+ LoadProgramsForCurrentChannelTask(ContentResolver contentResolver,
Range<Long> period) {
super(contentResolver, mChannel.getId(), period);
}
@@ -1252,7 +1227,9 @@ public class TimeShiftManager {
}
private void startNextLoadingIfNeeded() {
- mProgramLoadTask = null;
+ if (mProgramLoadTask == this) {
+ mProgramLoadTask = null;
+ }
// Need to post to handler, because the task is still running.
mHandler.post(new Runnable() {
@Override
@@ -1262,7 +1239,7 @@ public class TimeShiftManager {
});
}
- public boolean overlaps(Queue<Range<Long>> programLoadQueue) {
+ boolean overlaps(Queue<Range<Long>> programLoadQueue) {
for (Range<Long> r : programLoadQueue) {
if (mPeriod.contains(r.getLower()) || mPeriod.contains(r.getUpper())) {
return true;
@@ -1281,7 +1258,9 @@ public class TimeShiftManager {
void initialize(long timeMs) {
mSeekRequestTimeMs = INVALID_TIME;
mCurrentPositionMs = timeMs;
- TimeShiftManager.this.onCurrentPositionChanged();
+ if (timeMs != INVALID_TIME) {
+ TimeShiftManager.this.onCurrentPositionChanged();
+ }
}
void onSeekRequested(long seekTimeMs) {
@@ -1357,7 +1336,7 @@ public class TimeShiftManager {
}
private static class TimeShiftHandler extends WeakHandler<TimeShiftManager> {
- public TimeShiftHandler(TimeShiftManager ref) {
+ TimeShiftHandler(TimeShiftManager ref) {
super(ref);
}
diff --git a/src/com/android/tv/TvApplication.java b/src/com/android/tv/TvApplication.java
index ef105c94..0e18a259 100644
--- a/src/com/android/tv/TvApplication.java
+++ b/src/com/android/tv/TvApplication.java
@@ -22,9 +22,9 @@ import android.app.Application;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
-import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
import android.media.tv.TvInputManager.TvInputCallback;
@@ -32,7 +32,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.support.annotation.Nullable;
-import android.support.v4.os.BuildCompat;
+import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
@@ -42,37 +42,47 @@ import com.android.tv.analytics.StubAnalytics;
import com.android.tv.analytics.Tracker;
import com.android.tv.common.BuildConfig;
import com.android.tv.common.SharedPreferencesUtils;
+import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.TvCommonUtils;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.common.ui.setup.animation.SetupAnimationHelper;
+import com.android.tv.config.DefaultConfigManager;
+import com.android.tv.config.RemoteConfig;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.ProgramDataManager;
import com.android.tv.dvr.DvrDataManager;
import com.android.tv.dvr.DvrDataManagerImpl;
import com.android.tv.dvr.DvrManager;
import com.android.tv.dvr.DvrRecordingService;
-import com.android.tv.dvr.DvrSessionManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+import com.android.tv.util.AccountHelper;
import com.android.tv.util.Clock;
import com.android.tv.util.SetupUtils;
import com.android.tv.util.SystemProperties;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
-import com.android.usbtuner.UsbTunerPreferences;
-import com.android.usbtuner.setup.TunerSetupActivity;
-import com.android.usbtuner.tvinput.UsbTunerTvInputService;
import java.util.List;
public class TvApplication extends Application implements ApplicationSingletons {
private static final String TAG = "TvApplication";
private static final boolean DEBUG = false;
+ private RemoteConfig mRemoteConfig;
/**
- * Returns the @{@link ApplicationSingletons} using the application context.
+ * Broadcast Action: The user has updated LC to a new version that supports tuner input.
+ * {@link TunerInputController} will recevice this intent to check the existence of tuner
+ * input when the new version is first launched.
*/
- public static ApplicationSingletons getSingletons(Context context) {
- return (ApplicationSingletons) context.getApplicationContext();
- }
+ public static final String ACTION_APPLICATION_FIRST_LAUNCHED =
+ "com.android.tv.action.APPLICATION_FIRST_LAUNCHED";
+ private static final String PREFERENCE_IS_FIRST_LAUNCH = "is_first_launch";
+
private String mVersionName = "";
private final MainActivityWrapper mMainActivityWrapper = new MainActivityWrapper();
@@ -84,14 +94,30 @@ public class TvApplication extends Application implements ApplicationSingletons
private ChannelDataManager mChannelDataManager;
private ProgramDataManager mProgramDataManager;
private DvrManager mDvrManager;
+ private DvrScheduleManager mDvrScheduleManager;
private DvrDataManager mDvrDataManager;
+ private DvrStorageStatusManager mDvrStorageStatusManager;
+ private DvrWatchedPositionManager mDvrWatchedPositionManager;
@Nullable
- private DvrSessionManager mDvrSessionManager;
+ private InputSessionManager mInputSessionManager;
+ private AccountHelper mAccountHelper;
+ // When this variable is null, we don't know in which process TvApplication runs.
+ private Boolean mRunningInMainProcess;
@Override
public void onCreate() {
super.onCreate();
- SharedPreferencesUtils.initialize(this);
+ SharedPreferencesUtils.initialize(this, new Runnable() {
+ @Override
+ public void run() {
+ if (mRunningInMainProcess != null && mRunningInMainProcess) {
+ checkTunerServiceOnFirstLaunch();
+ }
+ }
+ });
+ // TunerPreferences is used to enable/disable the tuner input even when TUNER feature is
+ // disabled.
+ TunerPreferences.initialize(this);
try {
PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
mVersionName = pInfo.versionName;
@@ -100,17 +126,21 @@ public class TvApplication extends Application implements ApplicationSingletons
mVersionName = "";
}
Log.i(TAG, "Starting Live TV " + getVersionName());
+
+
// Only set StrictMode for ENG builds because the build server only produces userdebug
// builds.
if (BuildConfig.ENG && SystemProperties.ALLOW_STRICT_MODE.getValue()) {
- StrictMode.setThreadPolicy(
- new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog().build());
- StrictMode.VmPolicy.Builder vmPolicyBuilder = new StrictMode.VmPolicy.Builder()
- .detectAll().penaltyLog();
- if (BuildConfig.ENG && SystemProperties.ALLOW_DEATH_PENALTY.getValue() &&
- !TvCommonUtils.isRunningInTest()) {
- // TODO turn on death penalty for tests when they stop leaking MainActivity
+ StrictMode.ThreadPolicy.Builder threadPolicyBuilder =
+ new StrictMode.ThreadPolicy.Builder().detectAll().penaltyLog();
+ StrictMode.VmPolicy.Builder vmPolicyBuilder =
+ new StrictMode.VmPolicy.Builder().detectAll().penaltyLog();
+ if (!TvCommonUtils.isRunningInTest()) {
+ threadPolicyBuilder.penaltyDialog();
+ // Turn off death penalty for tests b/23355898
+ vmPolicyBuilder.penaltyDeath();
}
+ StrictMode.setThreadPolicy(threadPolicyBuilder.build());
StrictMode.setVmPolicy(vmPolicyBuilder.build());
}
if (BuildConfig.ENG && !SystemProperties.ALLOW_ANALYTICS_IN_ENG.getValue()) {
@@ -121,27 +151,63 @@ public class TvApplication extends Application implements ApplicationSingletons
mTracker = mAnalytics.getDefaultTracker();
mTvInputManagerHelper = new TvInputManagerHelper(this);
mTvInputManagerHelper.start();
- mTvInputManagerHelper.addCallback(new TvInputCallback() {
- @Override
- public void onInputAdded(String inputId) {
- handleInputCountChanged();
- }
-
- @Override
- public void onInputRemoved(String inputId) {
- handleInputCountChanged();
- }
- });
- if (CommonFeatures.DVR.isEnabled(this) && BuildCompat.isAtLeastN()) {
- mDvrManager = new DvrManager(this);
- //NOTE: DvrRecordingService just keeps running.
- DvrRecordingService.startService(this);
- }
// In SetupFragment, transitions are set in the constructor. Because the fragment can be
// created in Activity.onCreate() by the framework, SetupAnimationHelper should be
// initialized here before Activity.onCreate() is called.
SetupAnimationHelper.initialize(this);
- if (DEBUG) Log.i(TAG, "Started Live TV " + mVersionName);
+ Log.i(TAG, "Started Live TV " + mVersionName);
+ }
+
+ private void setCurrentRunningProcess(boolean isMainProcess) {
+ if (mRunningInMainProcess != null) {
+ SoftPreconditions.checkState(isMainProcess == mRunningInMainProcess);
+ return;
+ }
+ mRunningInMainProcess = isMainProcess;
+ if (CommonFeatures.DVR.isEnabled(this)) {
+ mDvrStorageStatusManager = new DvrStorageStatusManager(this, mRunningInMainProcess);
+ }
+ if (mRunningInMainProcess) {
+ mTvInputManagerHelper.addCallback(new TvInputCallback() {
+ @Override
+ public void onInputAdded(String inputId) {
+ if (Features.TUNER.isEnabled(TvApplication.this) && TextUtils.equals(inputId,
+ TunerTvInputService.getInputId(TvApplication.this))) {
+ TunerInputInfoUtils.updateTunerInputInfo(TvApplication.this);
+ }
+ handleInputCountChanged();
+ }
+
+ @Override
+ public void onInputRemoved(String inputId) {
+ handleInputCountChanged();
+ }
+ });
+ if (Features.TUNER.isEnabled(this)) {
+ // If the tuner input service is added before the app is started, we need to
+ // handle it here.
+ TunerInputInfoUtils.updateTunerInputInfo(this);
+ }
+ if (CommonFeatures.DVR.isEnabled(this)) {
+ mDvrScheduleManager = new DvrScheduleManager(this);
+ mDvrManager = new DvrManager(this);
+ //NOTE: DvrRecordingService just keeps running.
+ DvrRecordingService.startService(this);
+ }
+ }
+ }
+
+ private void checkTunerServiceOnFirstLaunch() {
+ SharedPreferences sharedPreferences = this.getSharedPreferences(
+ SharedPreferencesUtils.SHARED_PREF_FEATURES, Context.MODE_PRIVATE);
+ boolean isFirstLaunch = sharedPreferences.getBoolean(PREFERENCE_IS_FIRST_LAUNCH, true);
+ if (isFirstLaunch) {
+ if (DEBUG) Log.d(TAG, "Congratulations, it's the first launch!");
+ sendBroadcast(new Intent(ACTION_APPLICATION_FIRST_LAUNCHED));
+ SharedPreferences.Editor editor = sharedPreferences.edit();
+ editor.putBoolean(PREFERENCE_IS_FIRST_LAUNCH, false);
+ editor.apply();
+ }
}
/**
@@ -152,13 +218,32 @@ public class TvApplication extends Application implements ApplicationSingletons
return mDvrManager;
}
+ /**
+ * Returns the {@link DvrScheduleManager}.
+ */
+ @Override
+ public DvrScheduleManager getDvrScheduleManager() {
+ return mDvrScheduleManager;
+ }
+
+ /**
+ * Returns the {@link DvrWatchedPositionManager}.
+ */
+ @Override
+ public DvrWatchedPositionManager getDvrWatchedPositionManager() {
+ if (mDvrWatchedPositionManager == null) {
+ mDvrWatchedPositionManager = new DvrWatchedPositionManager(this);
+ }
+ return mDvrWatchedPositionManager;
+ }
+
@Override
@TargetApi(Build.VERSION_CODES.N)
- public DvrSessionManager getDvrSessionManger() {
- if (mDvrSessionManager == null) {
- mDvrSessionManager = new DvrSessionManager(this);
+ public InputSessionManager getInputSessionManager() {
+ if (mInputSessionManager == null) {
+ mInputSessionManager = new InputSessionManager(this);
}
- return mDvrSessionManager;
+ return mInputSessionManager;
}
/**
@@ -177,7 +262,6 @@ public class TvApplication extends Application implements ApplicationSingletons
return mTracker;
}
-
/**
* Returns {@link ChannelDataManager}.
*/
@@ -209,14 +293,23 @@ public class TvApplication extends Application implements ApplicationSingletons
@Override
public DvrDataManager getDvrDataManager() {
if (mDvrDataManager == null) {
- DvrDataManagerImpl dvrDataManager = new DvrDataManagerImpl(this, Clock.SYSTEM);
- mDvrDataManager = dvrDataManager;
- dvrDataManager.start();
+ DvrDataManagerImpl dvrDataManager = new DvrDataManagerImpl(this, Clock.SYSTEM);
+ mDvrDataManager = dvrDataManager;
+ dvrDataManager.start();
}
return mDvrDataManager;
}
/**
+ * Returns {@link DvrStorageStatusManager}.
+ */
+ @TargetApi(Build.VERSION_CODES.N)
+ @Override
+ public DvrStorageStatusManager getDvrStorageStatusManager() {
+ return mDvrStorageStatusManager;
+ }
+
+ /**
* Returns {@link TvInputManagerHelper}.
*/
@Override
@@ -233,6 +326,26 @@ public class TvApplication extends Application implements ApplicationSingletons
}
/**
+ * Returns the {@link AccountHelper}.
+ */
+ @Override
+ public AccountHelper getAccountHelper() {
+ if (mAccountHelper == null) {
+ mAccountHelper = new AccountHelper(getApplicationContext());
+ }
+ return mAccountHelper;
+ }
+
+ @Override
+ public RemoteConfig getRemoteConfig() {
+ if (mRemoteConfig == null) {
+ // No need to synchronize this, it does not hurt to create two and throw one away.
+ mRemoteConfig = DefaultConfigManager.createInstance(this).getRemoteConfig();
+ }
+ return mRemoteConfig;
+ }
+
+ /**
* SelectInputActivity is set in {@link SelectInputActivity#onCreate} and cleared in
* {@link SelectInputActivity#onDestroy}.
*/
@@ -322,7 +435,7 @@ public class TvApplication extends Application implements ApplicationSingletons
* Checks the input counts and enable/disable TvActivity. Also updates the input list in
* {@link SetupUtils}.
*
- * @param calledByTunerServiceChanged true if it is called when UsbTunerTvInputService
+ * @param calledByTunerServiceChanged true if it is called when TunerTvInputService
* is enabled or disabled.
* @param tunerServiceEnabled it's available only when calledByTunerServiceChanged is true.
* @param dontKillApp when TvActivity is enabled or disabled by this method, the app restarts
@@ -340,7 +453,7 @@ public class TvApplication extends Application implements ApplicationSingletons
if (!skipTunerInputCheck) {
for (TvInputInfo input : inputs) {
if (calledByTunerServiceChanged && !tunerServiceEnabled
- && UsbTunerTvInputService.getInputId(this).equals(input.getId())) {
+ && TunerTvInputService.getInputId(this).equals(input.getId())) {
continue;
}
if (input.getType() == TvInputInfo.TYPE_TUNER) {
@@ -361,4 +474,29 @@ public class TvApplication extends Application implements ApplicationSingletons
}
SetupUtils.getInstance(TvApplication.this).onInputListUpdated(inputManager);
}
+
+ /**
+ * Returns the @{@link ApplicationSingletons} using the application context.
+ */
+ public static ApplicationSingletons getSingletons(Context context) {
+ return (ApplicationSingletons) context.getApplicationContext();
+ }
+
+ /**
+ * Sets true, if TvApplication is running on the main process. If TvApplication runs on
+ * tuner process or other process, it sets false.
+ *
+ * Note: it should be called at the beginning of Service.onCreate Activity.onCreate, or
+ * BroadcastReceiver.onCreate. When it is firstly called after launch, it runs process
+ * specific initializations.
+ */
+ public static void setCurrentRunningProcess(Context context, boolean isMainProcess) {
+ if (context.getApplicationContext() instanceof TvApplication) {
+ TvApplication tvApplication = (TvApplication) context.getApplicationContext();
+ tvApplication.setCurrentRunningProcess(isMainProcess);
+ } else {
+ // Application context can be MockTvApplication.
+ Log.w(TAG, "It is not a context of TvApplication");
+ }
+ }
}
diff --git a/src/com/android/tv/TvOptionsManager.java b/src/com/android/tv/TvOptionsManager.java
index f104e75d..7871cbe7 100644
--- a/src/com/android/tv/TvOptionsManager.java
+++ b/src/com/android/tv/TvOptionsManager.java
@@ -39,7 +39,8 @@ public class TvOptionsManager {
public static final int OPTION_SYSTEMWIDE_PIP = 3;
public static final int OPTION_MULTI_AUDIO = 4;
public static final int OPTION_MORE_CHANNELS = 5;
- public static final int OPTION_SETTINGS = 6;
+ public static final int OPTION_DEVELOPER = 6;
+ public static final int OPTION_SETTINGS = 7;
public static final int OPTION_PIP_INPUT = 100;
public static final int OPTION_PIP_SWAP = 101;
diff --git a/src/com/android/tv/dvr/ui/EmptyHolder.java b/src/com/android/tv/config/ConfigKeys.java
index 45cd3a36..7df033d2 100644
--- a/src/com/android/tv/dvr/ui/EmptyHolder.java
+++ b/src/com/android/tv/config/ConfigKeys.java
@@ -14,14 +14,14 @@
* limitations under the License.
*/
-package com.android.tv.dvr.ui;
+package com.android.tv.config;
/**
- * Special object meaning a row is empty;
+ * Static list of config keys.
*/
-final class EmptyHolder {
- static final EmptyHolder EMPTY_HOLDER = new EmptyHolder();
+public final class ConfigKeys {
- private EmptyHolder() {
+
+ private ConfigKeys() {
}
}
diff --git a/src/com/android/tv/config/DefaultConfigManager.java b/src/com/android/tv/config/DefaultConfigManager.java
new file mode 100644
index 00000000..f5a6e959
--- /dev/null
+++ b/src/com/android/tv/config/DefaultConfigManager.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.config;
+
+import android.content.Context;
+
+/**
+ * Stub Remote Config.
+ */
+public class DefaultConfigManager {
+ public static DefaultConfigManager createInstance(Context context) {
+ return new DefaultConfigManager();
+ }
+
+ private StubRemoteConfig mRemoteConfig = new StubRemoteConfig();
+
+ public RemoteConfig getRemoteConfig() {
+ return mRemoteConfig;
+ }
+
+ private static class StubRemoteConfig implements RemoteConfig {
+ @Override
+ public void fetch(OnRemoteConfigUpdatedListener listener) {
+
+ }
+
+ @Override
+ public String getString(String key) {
+ return null;
+ }
+
+ @Override
+ public boolean getBoolean(String key) {
+ return false;
+ }
+ }
+}
+
+
+
+
diff --git a/src/com/android/tv/config/RemoteConfig.java b/src/com/android/tv/config/RemoteConfig.java
new file mode 100644
index 00000000..0f7d2c53
--- /dev/null
+++ b/src/com/android/tv/config/RemoteConfig.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.config;
+
+/**
+ * Manages Live TV Configuration, allowing remote updates.
+ *
+ * <p>This is a thin wrapper around
+ * <a href="https://firebase.google.com/docs/remote-config/"></a>Firebase Remote Config</a>
+ */
+public interface RemoteConfig {
+
+ /**
+ * Notified on successful completion of a {@link #fetch)}
+ */
+ interface OnRemoteConfigUpdatedListener {
+ void onRemoteConfigUpdated();
+ }
+
+ /**
+ * Starts a fetch and notifies {@code listener} on successful completion.
+ */
+ void fetch(OnRemoteConfigUpdatedListener listener);
+
+ /**
+ * Gets value as a string corresponding to the specified key.
+ */
+ String getString(String key);
+
+ /**
+ * Gets value as a boolean corresponding to the specified key.
+ */
+ boolean getBoolean(String key);
+}
diff --git a/src/com/android/tv/config/RemoteConfigFeature.java b/src/com/android/tv/config/RemoteConfigFeature.java
new file mode 100644
index 00000000..502e6a9c
--- /dev/null
+++ b/src/com/android/tv/config/RemoteConfigFeature.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.config;
+
+import android.content.Context;
+
+import com.android.tv.TvApplication;
+import com.android.tv.common.feature.Feature;
+
+/**
+ * A {@link Feature} controlled by a {@link RemoteConfig} boolean.
+ */
+public class RemoteConfigFeature implements Feature {
+ private final String mKey;
+
+ /** Creates a {@link RemoteConfigFeature for the {@code key}. */
+ public static RemoteConfigFeature fromKey(String key) {
+ return new RemoteConfigFeature(key);
+ }
+
+ private RemoteConfigFeature(String key) {
+ mKey = key;
+ }
+
+ @Override
+ public boolean isEnabled(Context context) {
+ return TvApplication.getSingletons(context).getRemoteConfig().getBoolean(mKey);
+ }
+}
diff --git a/src/com/android/tv/data/BaseProgram.java b/src/com/android/tv/data/BaseProgram.java
new file mode 100644
index 00000000..f420de02
--- /dev/null
+++ b/src/com/android/tv/data/BaseProgram.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.data;
+
+import android.content.Context;
+
+import java.util.Comparator;
+
+/**
+ * Base class for {@link com.android.tv.data.Program} and
+ * {@link com.android.tv.dvr.RecordedProgram}.
+ */
+public abstract class BaseProgram {
+ /**
+ * Comparator used to compare {@link BaseProgram} according to its season and episodes number.
+ * If a program's season or episode number is null, it will be consider "smaller" than programs
+ * with season or episode numbers.
+ */
+ public static final Comparator<BaseProgram> EPISODE_COMPARATOR =
+ new EpisodeComparator(false);
+
+ /**
+ * Comparator used to compare {@link BaseProgram} according to its season and episodes number
+ * with season numbers in a reversed order. If a program's season or episode number is null, it
+ * will be consider "smaller" than programs with season or episode numbers.
+ */
+ public static final Comparator<BaseProgram> SEASON_REVERSED_EPISODE_COMPARATOR =
+ new EpisodeComparator(true);
+
+ private static class EpisodeComparator implements Comparator<BaseProgram> {
+ private final boolean mReversedSeason;
+
+ EpisodeComparator(boolean reversedSeason) {
+ mReversedSeason = reversedSeason;
+ }
+
+ @Override
+ public int compare(BaseProgram lhs, BaseProgram rhs) {
+ if (lhs == rhs) {
+ return 0;
+ }
+ int seasonNumberCompare =
+ numberCompare(lhs.getSeasonNumber(), rhs.getSeasonNumber());
+ if (seasonNumberCompare != 0) {
+ return mReversedSeason ? -seasonNumberCompare : seasonNumberCompare;
+ } else {
+ return numberCompare(lhs.getEpisodeNumber(), rhs.getEpisodeNumber());
+ }
+ }
+ }
+
+ /**
+ * Compares two strings represent season numbers or episode numbers of programs.
+ */
+ public static int numberCompare(String s1, String s2) {
+ if (s1 == s2) {
+ return 0;
+ } else if (s1 == null) {
+ return -1;
+ } else if (s2 == null) {
+ return 1;
+ } else if (s1.equals(s2)) {
+ return 0;
+ }
+ try {
+ return Integer.compare(Integer.parseInt(s1), Integer.parseInt(s2));
+ } catch (NumberFormatException e) {
+ return s1.compareTo(s2);
+ }
+ }
+
+ /**
+ * Returns ID of the program.
+ */
+ abstract public long getId();
+
+ /**
+ * Returns the title of the program.
+ */
+ abstract public String getTitle();
+
+ /**
+ * Returns the program's title withe its season and episode number.
+ */
+ abstract public String getTitleWithEpisodeNumber(Context context);
+
+ /**
+ * Returns the displayed title of the program episode.
+ */
+ abstract public String getEpisodeDisplayTitle(Context context);
+
+ /**
+ * Returns the description of the program.
+ */
+ abstract public String getDescription();
+
+ /**
+ * Returns the long description of the program.
+ */
+ abstract public String getLongDescription();
+
+ /**
+ * Returns the start time of the program in Milliseconds.
+ */
+ abstract public long getStartTimeUtcMillis();
+
+ /**
+ * Returns the end time of the program in Milliseconds.
+ */
+ abstract public long getEndTimeUtcMillis();
+
+ /**
+ * Returns the duration of the program in Milliseconds.
+ */
+ abstract public long getDurationMillis();
+
+ /**
+ * Returns the series ID.
+ */
+ abstract public String getSeriesId();
+
+ /**
+ * Returns the season number.
+ */
+ abstract public String getSeasonNumber();
+
+ /**
+ * Returns the episode number.
+ */
+ abstract public String getEpisodeNumber();
+
+ /**
+ * Returns URI of the program's poster.
+ */
+ abstract public String getPosterArtUri();
+
+ /**
+ * Returns URI of the program's thumbnail.
+ */
+ abstract public String getThumbnailUri();
+
+ /**
+ * Returns the array of the ID's of the canonical genres.
+ */
+ abstract public int[] getCanonicalGenreIds();
+
+ /**
+ * Returns channel's ID of the program.
+ */
+ abstract public long getChannelId();
+
+ /**
+ * Returns if the program is valid.
+ */
+ abstract public boolean isValid();
+
+ /**
+ * Generates the series ID for the other inputs than the tuner TV input.
+ */
+ public static String generateSeriesId(String packageName, String title) {
+ return packageName + "/" + title;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/data/Channel.java b/src/com/android/tv/data/Channel.java
index 86437ab2..30f84236 100644
--- a/src/com/android/tv/data/Channel.java
+++ b/src/com/android/tv/data/Channel.java
@@ -16,7 +16,6 @@
package com.android.tv.data;
-import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
@@ -24,14 +23,12 @@ import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.net.Uri;
-import android.os.Build;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
-import com.android.tv.common.CollectionUtils;
import com.android.tv.common.TvCommonConstants;
import com.android.tv.util.ImageLoader;
import com.android.tv.util.TvInputManagerHelper;
@@ -73,7 +70,7 @@ public final class Channel {
private static final int APP_LINK_TYPE_NOT_SET = 0;
private static final String INVALID_PACKAGE_NAME = "packageName";
- private static final String[] PROJECTION_BASE = {
+ public static final String[] PROJECTION = {
// Columns must match what is read in Channel.fromCursor()
TvContract.Channels._ID,
TvContract.Channels.COLUMN_PACKAGE_NAME,
@@ -85,12 +82,6 @@ public final class Channel {
TvContract.Channels.COLUMN_VIDEO_FORMAT,
TvContract.Channels.COLUMN_BROWSABLE,
TvContract.Channels.COLUMN_LOCKED,
- };
-
- // Additional fields added in MNC.
- @SuppressLint("InlinedApi")
- private static final String[] PROJECTION_ADDED_IN_MNC = {
- // Columns should match what is read in Channel.fromCursor()
TvContract.Channels.COLUMN_APP_LINK_TEXT,
TvContract.Channels.COLUMN_APP_LINK_COLOR,
TvContract.Channels.COLUMN_APP_LINK_ICON_URI,
@@ -98,16 +89,6 @@ public final class Channel {
TvContract.Channels.COLUMN_APP_LINK_INTENT_URI,
};
- public static final String[] PROJECTION = createProjection();
-
- private static String[] createProjection() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- return CollectionUtils.concatAll(PROJECTION_BASE, PROJECTION_ADDED_IN_MNC);
- } else {
- return PROJECTION_BASE;
- }
- }
-
/**
* Creates {@code Channel} object from cursor.
*
@@ -128,13 +109,11 @@ public final class Channel {
channel.mVideoFormat = Utils.intern(cursor.getString(index++));
channel.mBrowsable = cursor.getInt(index++) == 1;
channel.mLocked = cursor.getInt(index++) == 1;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- channel.mAppLinkText = cursor.getString(index++);
- channel.mAppLinkColor = cursor.getInt(index++);
- channel.mAppLinkIconUri = cursor.getString(index++);
- channel.mAppLinkPosterArtUri = cursor.getString(index++);
- channel.mAppLinkIntentUri = cursor.getString(index++);
- }
+ channel.mAppLinkText = cursor.getString(index++);
+ channel.mAppLinkColor = cursor.getInt(index++);
+ channel.mAppLinkIconUri = cursor.getString(index++);
+ channel.mAppLinkPosterArtUri = cursor.getString(index++);
+ channel.mAppLinkIntentUri = cursor.getString(index++);
return channel;
}
@@ -171,11 +150,6 @@ public final class Channel {
private long mDvrId;
- /**
- * TODO(DVR): Need to fill the following data.
- */
- private boolean mRecordable;
-
private Channel() {
// Do nothing.
}
@@ -226,6 +200,15 @@ public final class Channel {
return mIsPassthrough;
}
+ /**
+ * Gets identification text for displaying or debugging.
+ * It's made from Channels' display number plus their display name.
+ */
+ public String getDisplayText() {
+ return TextUtils.isEmpty(mDisplayName) ? mDisplayNumber
+ : mDisplayNumber + " " + mDisplayName;
+ }
+
public String getAppLinkText() {
return mAppLinkText;
}
@@ -578,6 +561,8 @@ public final class Channel {
getUri().toString());
mAppLinkType = APP_LINK_TYPE_CHANNEL;
return;
+ } else {
+ Log.w(TAG, "No activity exists to handle : " + mAppLinkIntentUri);
}
} catch (URISyntaxException e) {
Log.w(TAG, "Unable to set app link for " + mAppLinkIntentUri, e);
@@ -650,8 +635,7 @@ public final class Channel {
result = ChannelNumber.compare(lhs.getDisplayNumber(), rhs.getDisplayNumber());
if (mDetectDuplicatesEnabled && result == 0) {
Log.w(TAG, "Duplicate channels detected! - \""
- + lhs.getDisplayNumber() + " " + lhs.getDisplayName() + "\" and \""
- + rhs.getDisplayNumber() + " " + rhs.getDisplayName() + "\"");
+ + lhs.getDisplayText() + "\" and \"" + rhs.getDisplayText() + "\"");
}
return result;
}
diff --git a/src/com/android/tv/data/ChannelDataManager.java b/src/com/android/tv/data/ChannelDataManager.java
index 84a16111..6f9ea6d7 100644
--- a/src/com/android/tv/data/ChannelDataManager.java
+++ b/src/com/android/tv/data/ChannelDataManager.java
@@ -50,6 +50,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
/**
* The class to manage channel data.
@@ -72,7 +73,7 @@ public class ChannelDataManager {
private QueryAllChannelsTask mChannelsUpdateTask;
private final List<Runnable> mPostRunnablesAfterChannelUpdate = new ArrayList<>();
- private final Set<Listener> mListeners = new ArraySet<>();
+ private final Set<Listener> mListeners = new CopyOnWriteArraySet<>();
private final Map<Long, ChannelWrapper> mChannelWrapperMap = new HashMap<>();
private final Map<String, MutableInt> mChannelCountMap = new HashMap<>();
private final Channel.DefaultComparator mChannelComparator;
@@ -296,6 +297,16 @@ public class ChannelDataManager {
}
/**
+ * Checks if the channel exists in DB.
+ *
+ * <p>Note that the channels of the removed inputs can not be obtained from {@link #getChannel}.
+ * In that case this method is used to check if the channel exists in the DB.
+ */
+ public boolean doesChannelExistInDb(long channelId) {
+ return mChannelWrapperMap.get(channelId) != null;
+ }
+
+ /**
* Returns true if and only if there exists at least one channel and all channels are hidden.
*/
public boolean areAllChannelsHidden() {
@@ -360,22 +371,19 @@ public class ChannelDataManager {
}
public void notifyChannelBrowsableChanged() {
- // Copy the original collection to allow the callee to modify the listeners.
- for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) {
+ for (Listener l : mListeners) {
l.onChannelBrowsableChanged();
}
}
private void notifyChannelListUpdated() {
- // Copy the original collection to allow the callee to modify the listeners.
- for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) {
+ for (Listener l : mListeners) {
l.onChannelListUpdated();
}
}
private void notifyLoadFinished() {
- // Copy the original collection to allow the callee to modify the listeners.
- for (Listener l : mListeners.toArray(new Listener[mListeners.size()])) {
+ for (Listener l : mListeners) {
l.onLoadFinished();
}
}
diff --git a/src/com/android/tv/data/GenreItems.java b/src/com/android/tv/data/GenreItems.java
index b1110612..b12fd1aa 100644
--- a/src/com/android/tv/data/GenreItems.java
+++ b/src/com/android/tv/data/GenreItems.java
@@ -16,10 +16,8 @@
package com.android.tv.data;
-import android.annotation.SuppressLint;
import android.content.Context;
import android.media.tv.TvContract.Programs.Genres;
-import android.os.Build;
import com.android.tv.R;
@@ -29,23 +27,7 @@ public class GenreItems {
*/
public static final int ID_ALL_CHANNELS = 0;
- private static final String[] CANONICAL_GENRES_L = {
- null, // All channels
- Genres.FAMILY_KIDS,
- Genres.SPORTS,
- Genres.SHOPPING,
- Genres.MOVIES,
- Genres.COMEDY,
- Genres.TRAVEL,
- Genres.DRAMA,
- Genres.EDUCATION,
- Genres.ANIMAL_WILDLIFE,
- Genres.NEWS,
- Genres.GAMING
- };
-
- @SuppressLint("InlinedApi")
- private static final String[] CANONICAL_GENRES_L_MR1 = {
+ private static final String[] CANONICAL_GENRES = {
null, // All channels
Genres.FAMILY_KIDS,
Genres.SPORTS,
@@ -66,25 +48,13 @@ public class GenreItems {
Genres.TECH_SCIENCE
};
- private static final String[] CANONICAL_GENRES = createGenres();
-
- private static String[] createGenres() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
- return CANONICAL_GENRES_L;
- } else {
- return CANONICAL_GENRES_L_MR1;
- }
- }
-
private GenreItems() { }
/**
* Returns array of all genre labels.
*/
public static String[] getLabels(Context context) {
- String[] items = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1
- ? context.getResources().getStringArray(R.array.genre_labels_l)
- : context.getResources().getStringArray(R.array.genre_labels_l_mr1);
+ String[] items = context.getResources().getStringArray(R.array.genre_labels);
if (items.length != CANONICAL_GENRES.length) {
throw new IllegalArgumentException("Genre data mismatch");
}
diff --git a/src/com/android/tv/data/InternalDataUtils.java b/src/com/android/tv/data/InternalDataUtils.java
new file mode 100644
index 00000000..6054f089
--- /dev/null
+++ b/src/com/android/tv/data/InternalDataUtils.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.data;
+
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.tv.data.Program.CriticScore;
+import com.android.tv.dvr.RecordedProgram;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.List;
+
+/**
+ * A utility class to parse and store data from the
+ * {@link android.media.tv.TvContract.Programs#COLUMN_INTERNAL_PROVIDER_DATA} field in the
+ * {@link android.media.tv.TvContract.Programs}.
+ */
+public final class InternalDataUtils {
+ private static final boolean DEBUG = false;
+ private static final String TAG = "InternalDataUtils";
+
+ private InternalDataUtils() {
+ //do nothing
+ }
+
+ /**
+ * Deserializes a byte array into objects to be stored in the Program class.
+ *
+ * <p> Series ID and critic scores are loaded from the bytes.
+ *
+ * @param bytes the bytes to be deserialized
+ * @param builder the builder for the Program class
+ */
+ public static void deserializeInternalProviderData(byte[] bytes, Program.Builder builder) {
+ if (bytes == null || bytes.length == 0) {
+ return;
+ }
+ try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+ builder.setSeriesId((String) in.readObject());
+ builder.setCriticScores((List<CriticScore>) in.readObject());
+ } catch (NullPointerException e) {
+ Log.e(TAG, "no bytes to deserialize");
+ } catch (IOException e) {
+ Log.e(TAG, "Could not deserialize internal provider contents");
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "class not found in internal provider contents");
+ }
+ }
+
+ /**
+ * Convenience method for converting relevant data in Program class to a serialized blob type
+ * for storage in internal_provider_data field.
+ * @param program the program which contains the objects to be serialized
+ * @return serialized blob-type data
+ */
+ @Nullable
+ public static byte[] serializeInternalProviderData(Program program) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bos)) {
+ if (!TextUtils.isEmpty(program.getSeriesId()) || program.getCriticScores() != null) {
+ out.writeObject(program.getSeriesId());
+ out.writeObject(program.getCriticScores());
+ return bos.toByteArray();
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Could not serialize internal provider contents for program: "
+ + program.getTitle());
+ }
+ return null;
+ }
+
+ /**
+ * Deserializes a byte array into objects to be stored in the RecordedProgram class.
+ *
+ * <p> Series ID is loaded from the bytes.
+ *
+ * @param bytes the bytes to be deserialized
+ * @param builder the builder for the RecordedProgram class
+ */
+ public static void deserializeInternalProviderData(byte[] bytes,
+ RecordedProgram.Builder builder) {
+ if (bytes == null || bytes.length == 0) {
+ return;
+ }
+ try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+ builder.setSeriesId((String) in.readObject());
+ } catch (NullPointerException e) {
+ Log.e(TAG, "no bytes to deserialize");
+ } catch (IOException e) {
+ Log.e(TAG, "Could not deserialize internal provider contents");
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "class not found in internal provider contents");
+ }
+ }
+
+ /**
+ * Serializes relevant objects in {@link android.media.tv.TvContract.Programs} to byte array.
+ * @return the serialized byte array
+ */
+ public static byte[] serializeInternalProviderData(RecordedProgram program) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bos)) {
+ if (!TextUtils.isEmpty(program.getSeriesId())) {
+ out.writeObject(program.getSeriesId());
+ return bos.toByteArray();
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Could not serialize internal provider contents for program: "
+ + program.getTitle());
+ }
+ return null;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/data/Lineup.java b/src/com/android/tv/data/Lineup.java
new file mode 100644
index 00000000..d0e9d7ba
--- /dev/null
+++ b/src/com/android/tv/data/Lineup.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.data;
+
+import android.support.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A class that represents a lineup.
+ */
+public class Lineup {
+ /**
+ * The ID of this lineup.
+ */
+ public final String id;
+
+ /**
+ * The type associated with this lineup.
+ */
+ public final int type;
+
+ /**
+ * The human readable name associated with this lineup.
+ */
+ public final String name;
+
+ /**
+ * Location this lineup can be found.
+ * This is a human readable description of a geographic location.
+ */
+ public final String location;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({LINEUP_CABLE, LINEUP_SATELLITE, LINEUP_BROADCAST_DIGITAL, LINEUP_BROADCAST_ANALOG,
+ LINEUP_IPTV, LINEUP_MVPD})
+ public @interface LineupType {}
+
+ /**
+ * Lineup type for cable.
+ */
+ public static final int LINEUP_CABLE = 0;
+
+ /**
+ * Lineup type for satelite.
+ */
+ public static final int LINEUP_SATELLITE = 1;
+
+ /**
+ * Lineup type for broadcast digital.
+ */
+ public static final int LINEUP_BROADCAST_DIGITAL = 2;
+
+ /**
+ * Lineup type for broadcast analog.
+ */
+ public static final int LINEUP_BROADCAST_ANALOG = 3;
+
+ /**
+ * Lineup type for IPTV.
+ */
+ public static final int LINEUP_IPTV = 4;
+
+ /**
+ * Indicates the lineup is either satelite, cable or IPTV but we are not sure which specific
+ * type.
+ */
+ public static final int LINEUP_MVPD = 5;
+
+ /**
+ * Creates a lineup.
+ */
+ public Lineup(String id, int type, String name, String location) {
+ this.id = id;
+ this.type = type;
+ this.name = name;
+ this.location = location;
+ }
+}
diff --git a/src/com/android/tv/data/ParcelableList.java b/src/com/android/tv/data/ParcelableList.java
new file mode 100644
index 00000000..78f444e4
--- /dev/null
+++ b/src/com/android/tv/data/ParcelableList.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.data;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A convenience class for the list of {@link Parcelable}s.
+ */
+public final class ParcelableList<T extends Parcelable> implements Parcelable {
+ /**
+ * Create instance from {@link Parcel}.
+ */
+ public static ParcelableList fromParcel(Parcel in) {
+ ParcelableList list = new ParcelableList();
+ int length = in.readInt();
+ if (length > 0) {
+ for (int i = 0; i < length; ++i) {
+ list.mList.add(in.readParcelable(Thread.currentThread().getContextClassLoader()));
+ }
+ }
+ return list;
+ }
+
+ /**
+ * A creator for {@link ParcelableList}.
+ */
+ public static final Creator<ParcelableList> CREATOR = new Creator<ParcelableList>() {
+ @Override
+ public ParcelableList createFromParcel(Parcel in) {
+ return ParcelableList.fromParcel(in);
+ }
+
+ @Override
+ public ParcelableList[] newArray(int size) {
+ return new ParcelableList[size];
+ }
+ };
+
+ private final List<T> mList = new ArrayList<>();
+
+ private ParcelableList() { }
+
+ public ParcelableList(Collection<T> initialList) {
+ mList.addAll(initialList);
+ }
+
+ /**
+ * Returns the list.
+ */
+ public List<T> getList() {
+ return new ArrayList<T>(mList);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int paramInt) {
+ out.writeInt(mList.size());
+ for (T data : mList) {
+ out.writeParcelable(data, 0);
+ }
+ }
+}
diff --git a/src/com/android/tv/data/Program.java b/src/com/android/tv/data/Program.java
index af5f93bb..b9cd3d8d 100644
--- a/src/com/android/tv/data/Program.java
+++ b/src/com/android/tv/data/Program.java
@@ -20,8 +20,12 @@ import android.content.Context;
import android.database.Cursor;
import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
+import android.os.Parcel;
+import android.os.Parcelable;
import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
+import android.support.annotation.VisibleForTesting;
import android.support.v4.os.BuildCompat;
import android.text.TextUtils;
import android.util.Log;
@@ -33,13 +37,16 @@ import com.android.tv.common.TvContentRatingCache;
import com.android.tv.util.ImageLoader;
import com.android.tv.util.Utils;
+import java.io.Serializable;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.List;
import java.util.Objects;
/**
* A convenience class to create and insert program information entries into the database.
*/
-public final class Program implements Comparable<Program> {
+public final class Program extends BaseProgram implements Comparable<Program>, Parcelable {
private static final boolean DEBUG = false;
private static final boolean DEBUG_DUMP_DESCRIPTION = false;
private static final String TAG = "Program";
@@ -47,10 +54,12 @@ public final class Program implements Comparable<Program> {
private static final String[] PROJECTION_BASE = {
// Columns must match what is read in Program.fromCursor()
TvContract.Programs._ID,
+ TvContract.Programs.COLUMN_PACKAGE_NAME,
TvContract.Programs.COLUMN_CHANNEL_ID,
TvContract.Programs.COLUMN_TITLE,
TvContract.Programs.COLUMN_EPISODE_TITLE,
TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ TvContract.Programs.COLUMN_LONG_DESCRIPTION,
TvContract.Programs.COLUMN_POSTER_ART_URI,
TvContract.Programs.COLUMN_THUMBNAIL_URI,
TvContract.Programs.COLUMN_CANONICAL_GENRE,
@@ -58,10 +67,12 @@ public final class Program implements Comparable<Program> {
TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
TvContract.Programs.COLUMN_VIDEO_WIDTH,
- TvContract.Programs.COLUMN_VIDEO_HEIGHT
+ TvContract.Programs.COLUMN_VIDEO_HEIGHT,
+ TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA
};
// Columns which is deprecated in NYC
+ @SuppressWarnings("deprecation")
private static final String[] PROJECTION_DEPRECATED_IN_NYC = {
TvContract.Programs.COLUMN_SEASON_NUMBER,
TvContract.Programs.COLUMN_EPISODE_NUMBER
@@ -70,7 +81,8 @@ public final class Program implements Comparable<Program> {
private static final String[] PROJECTION_ADDED_IN_NYC = {
TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER,
TvContract.Programs.COLUMN_SEASON_TITLE,
- TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER
+ TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER,
+ TvContract.Programs.COLUMN_RECORDING_PROHIBITED
};
public static final String[] PROJECTION = createProjection();
@@ -82,6 +94,18 @@ public final class Program implements Comparable<Program> {
}
/**
+ * Returns the column index for {@code column}, -1 if the column doesn't exist.
+ */
+ public static int getColumnIndex(String column) {
+ for (int i = 0; i < PROJECTION.length; ++i) {
+ if (PROJECTION[i].equals(column)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
* Creates {@code Program} object from cursor.
*
* <p>The query that created the cursor MUST use {@link #PROJECTION}.
@@ -91,10 +115,13 @@ public final class Program implements Comparable<Program> {
Builder builder = new Builder();
int index = 0;
builder.setId(cursor.getLong(index++));
+ String packageName = cursor.getString(index++);
+ builder.setPackageName(packageName);
builder.setChannelId(cursor.getLong(index++));
builder.setTitle(cursor.getString(index++));
builder.setEpisodeTitle(cursor.getString(index++));
builder.setDescription(cursor.getString(index++));
+ builder.setLongDescription(cursor.getString(index++));
builder.setPosterArtUri(cursor.getString(index++));
builder.setThumbnailUri(cursor.getString(index++));
builder.setCanonicalGenres(cursor.getString(index++));
@@ -104,10 +131,15 @@ public final class Program implements Comparable<Program> {
builder.setEndTimeUtcMillis(cursor.getLong(index++));
builder.setVideoWidth((int) cursor.getLong(index++));
builder.setVideoHeight((int) cursor.getLong(index++));
+ if (Utils.isInBundledPackageSet(packageName)) {
+ InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder);
+ }
+ index++;
if (BuildCompat.isAtLeastN()) {
builder.setSeasonNumber(cursor.getString(index++));
builder.setSeasonTitle(cursor.getString(index++));
builder.setEpisodeNumber(cursor.getString(index++));
+ builder.setRecordingProhibited(cursor.getInt(index++) == 1);
} else {
builder.setSeasonNumber(cursor.getString(index++));
builder.setEpisodeNumber(cursor.getString(index++));
@@ -115,9 +147,55 @@ public final class Program implements Comparable<Program> {
return builder.build();
}
+ public static Program fromParcel(Parcel in) {
+ Program program = new Program();
+ program.mId = in.readLong();
+ program.mPackageName = in.readString();
+ program.mChannelId = in.readLong();
+ program.mTitle = in.readString();
+ program.mSeriesId = in.readString();
+ program.mEpisodeTitle = in.readString();
+ program.mSeasonNumber = in.readString();
+ program.mSeasonTitle = in.readString();
+ program.mEpisodeNumber = in.readString();
+ program.mStartTimeUtcMillis = in.readLong();
+ program.mEndTimeUtcMillis = in.readLong();
+ program.mDescription = in.readString();
+ program.mLongDescription = in.readString();
+ program.mVideoWidth = in.readInt();
+ program.mVideoHeight = in.readInt();
+ program.mCriticScores = in.readArrayList(Thread.currentThread().getContextClassLoader());
+ program.mPosterArtUri = in.readString();
+ program.mThumbnailUri = in.readString();
+ program.mCanonicalGenreIds = in.createIntArray();
+ int length = in.readInt();
+ if (length > 0) {
+ program.mContentRatings = new TvContentRating[length];
+ for (int i = 0; i < length; ++i) {
+ program.mContentRatings[i] = TvContentRating.unflattenFromString(in.readString());
+ }
+ }
+ program.mRecordingProhibited = in.readByte() != (byte) 0;
+ return program;
+ }
+
+ public static final Parcelable.Creator<Program> CREATOR = new Parcelable.Creator<Program>() {
+ @Override
+ public Program createFromParcel(Parcel in) {
+ return Program.fromParcel(in);
+ }
+
+ @Override
+ public Program[] newArray(int size) {
+ return new Program[size];
+ }
+ };
+
private long mId;
+ private String mPackageName;
private long mChannelId;
private String mTitle;
+ private String mSeriesId;
private String mEpisodeTitle;
private String mSeasonNumber;
private String mSeasonTitle;
@@ -125,17 +203,19 @@ public final class Program implements Comparable<Program> {
private long mStartTimeUtcMillis;
private long mEndTimeUtcMillis;
private String mDescription;
+ private String mLongDescription;
private int mVideoWidth;
private int mVideoHeight;
+ private List<CriticScore> mCriticScores;
private String mPosterArtUri;
private String mThumbnailUri;
private int[] mCanonicalGenreIds;
private TvContentRating[] mContentRatings;
+ private boolean mRecordingProhibited;
/**
* TODO(DVR): Need to fill the following data.
*/
- private boolean mRecordable;
private boolean mRecordingScheduled;
private Program() {
@@ -146,6 +226,13 @@ public final class Program implements Comparable<Program> {
return mId;
}
+ /**
+ * Returns the package name of this program.
+ */
+ public String getPackageName() {
+ return mPackageName;
+ }
+
public long getChannelId() {
return mChannelId;
}
@@ -153,6 +240,7 @@ public final class Program implements Comparable<Program> {
/**
* Returns {@code true} if this program is valid or {@code false} otherwise.
*/
+ @Override
public boolean isValid() {
return mChannelId >= 0;
}
@@ -164,35 +252,77 @@ public final class Program implements Comparable<Program> {
return program != null && program.isValid();
}
+ @Override
public String getTitle() {
return mTitle;
}
+ /**
+ * Returns the series ID.
+ */
+ @Override
+ public String getSeriesId() {
+ return mSeriesId;
+ }
+
+ /**
+ * Returns the episode title.
+ */
public String getEpisodeTitle() {
return mEpisodeTitle;
}
+ /**
+ * Returns season number, episode number and episode title for display.
+ */
+ @Override
public String getEpisodeDisplayTitle(Context context) {
- if (!TextUtils.isEmpty(mSeasonNumber) && !TextUtils.isEmpty(mEpisodeNumber)
- && !TextUtils.isEmpty(mEpisodeTitle)) {
- return String.format(context.getResources().getString(R.string.episode_format),
- mSeasonNumber, mEpisodeNumber, mEpisodeTitle);
+ if (!TextUtils.isEmpty(mEpisodeNumber)) {
+ String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle;
+ if (TextUtils.equals(mSeasonNumber, "0")) {
+ // Do not show "S0: ".
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format_no_season_number),
+ mEpisodeNumber, episodeTitle);
+ } else {
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format),
+ mSeasonNumber, mEpisodeNumber, episodeTitle);
+ }
}
return mEpisodeTitle;
}
+ @Override
+ public String getTitleWithEpisodeNumber(Context context) {
+ if (TextUtils.isEmpty(mTitle)) {
+ return mTitle;
+ }
+ if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) {
+ return TextUtils.isEmpty(mEpisodeNumber) ? mTitle : context.getString(
+ R.string.program_title_with_episode_number_no_season, mTitle, mEpisodeNumber);
+ } else {
+ return context.getString(R.string.program_title_with_episode_number, mTitle,
+ mSeasonNumber, mEpisodeNumber);
+ }
+ }
+
+ @Override
public String getSeasonNumber() {
return mSeasonNumber;
}
+ @Override
public String getEpisodeNumber() {
return mEpisodeNumber;
}
+ @Override
public long getStartTimeUtcMillis() {
return mStartTimeUtcMillis;
}
+ @Override
public long getEndTimeUtcMillis() {
return mEndTimeUtcMillis;
}
@@ -200,14 +330,21 @@ public final class Program implements Comparable<Program> {
/**
* Returns the program duration.
*/
+ @Override
public long getDurationMillis() {
return mEndTimeUtcMillis - mStartTimeUtcMillis;
}
+ @Override
public String getDescription() {
return mDescription;
}
+ @Override
+ public String getLongDescription() {
+ return mLongDescription;
+ }
+
public int getVideoWidth() {
return mVideoWidth;
}
@@ -216,22 +353,40 @@ public final class Program implements Comparable<Program> {
return mVideoHeight;
}
+ /**
+ * Returns the list of Critic Scores for this program
+ */
+ @Nullable
+ public List<CriticScore> getCriticScores() {
+ return mCriticScores;
+ }
+
public TvContentRating[] getContentRatings() {
return mContentRatings;
}
+ @Override
public String getPosterArtUri() {
return mPosterArtUri;
}
+ @Override
public String getThumbnailUri() {
return mThumbnailUri;
}
/**
+ * Returns {@code true} if the recording of this program is prohibited.
+ */
+ public boolean isRecordingProhibited() {
+ return mRecordingProhibited;
+ }
+
+ /**
* Returns array of canonical genres for this program.
* This is expected to be called rarely.
*/
+ @Nullable
public String[] getCanonicalGenres() {
if (mCanonicalGenreIds == null) {
return null;
@@ -244,6 +399,14 @@ public final class Program implements Comparable<Program> {
}
/**
+ * Returns array of canonical genre ID's for this program.
+ */
+ @Override
+ public int[] getCanonicalGenreIds() {
+ return mCanonicalGenreIds;
+ }
+
+ /**
* Returns if this program has the genre.
*/
public boolean hasGenre(int genreId) {
@@ -262,10 +425,12 @@ public final class Program implements Comparable<Program> {
@Override
public int hashCode() {
+ // Hash with all the properties because program ID can be invalid for the dummy programs.
return Objects.hash(mChannelId, mStartTimeUtcMillis, mEndTimeUtcMillis,
- mTitle, mEpisodeTitle, mDescription, mVideoWidth, mVideoHeight,
- mPosterArtUri, mThumbnailUri, Arrays.hashCode(mContentRatings),
- Arrays.hashCode(mCanonicalGenreIds), mSeasonNumber, mSeasonTitle, mEpisodeNumber);
+ mTitle, mSeriesId, mEpisodeTitle, mDescription, mLongDescription, mVideoWidth,
+ mVideoHeight, mPosterArtUri, mThumbnailUri, Arrays.hashCode(mContentRatings),
+ Arrays.hashCode(mCanonicalGenreIds), mSeasonNumber, mSeasonTitle, mEpisodeNumber,
+ mRecordingProhibited);
}
@Override
@@ -273,13 +438,17 @@ public final class Program implements Comparable<Program> {
if (!(other instanceof Program)) {
return false;
}
+ // Compare all the properties because program ID can be invalid for the dummy programs.
Program program = (Program) other;
- return mChannelId == program.mChannelId
+ return Objects.equals(mPackageName, program.mPackageName)
+ && mChannelId == program.mChannelId
&& mStartTimeUtcMillis == program.mStartTimeUtcMillis
&& mEndTimeUtcMillis == program.mEndTimeUtcMillis
&& Objects.equals(mTitle, program.mTitle)
+ && Objects.equals(mSeriesId, program.mSeriesId)
&& Objects.equals(mEpisodeTitle, program.mEpisodeTitle)
&& Objects.equals(mDescription, program.mDescription)
+ && Objects.equals(mLongDescription, program.mLongDescription)
&& mVideoWidth == program.mVideoWidth
&& mVideoHeight == program.mVideoHeight
&& Objects.equals(mPosterArtUri, program.mPosterArtUri)
@@ -288,7 +457,8 @@ public final class Program implements Comparable<Program> {
&& Arrays.equals(mCanonicalGenreIds, program.mCanonicalGenreIds)
&& Objects.equals(mSeasonNumber, program.mSeasonNumber)
&& Objects.equals(mSeasonTitle, program.mSeasonTitle)
- && Objects.equals(mEpisodeNumber, program.mEpisodeNumber);
+ && Objects.equals(mEpisodeNumber, program.mEpisodeNumber)
+ && mRecordingProhibited == program.mRecordingProhibited;
}
@Override
@@ -299,9 +469,11 @@ public final class Program implements Comparable<Program> {
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
- builder.append("Program[" + mId + "]{")
- .append("channelId=").append(mChannelId)
+ builder.append("Program[").append(mId)
+ .append("]{channelId=").append(mChannelId)
+ .append(", packageName=").append(mPackageName)
.append(", title=").append(mTitle)
+ .append(", seriesId=").append(mSeriesId)
.append(", episodeTitle=").append(mEpisodeTitle)
.append(", seasonNumber=").append(mSeasonNumber)
.append(", seasonTitle=").append(mSeasonTitle)
@@ -314,9 +486,11 @@ public final class Program implements Comparable<Program> {
.append(TvContentRatingCache.contentRatingsToString(mContentRatings))
.append(", posterArtUri=").append(mPosterArtUri)
.append(", thumbnailUri=").append(mThumbnailUri)
- .append(", canonicalGenres=").append(Arrays.toString(mCanonicalGenreIds));
+ .append(", canonicalGenres=").append(Arrays.toString(mCanonicalGenreIds))
+ .append(", recordingProhibited=").append(mRecordingProhibited);
if (DEBUG_DUMP_DESCRIPTION) {
- builder.append(", description=").append(mDescription);
+ builder.append(", description=").append(mDescription)
+ .append(", longDescription=").append(mLongDescription);
}
return builder.append("}").toString();
}
@@ -327,8 +501,10 @@ public final class Program implements Comparable<Program> {
}
mId = other.mId;
+ mPackageName = other.mPackageName;
mChannelId = other.mChannelId;
mTitle = other.mTitle;
+ mSeriesId = other.mSeriesId;
mEpisodeTitle = other.mEpisodeTitle;
mSeasonNumber = other.mSeasonNumber;
mSeasonTitle = other.mSeasonTitle;
@@ -336,21 +512,37 @@ public final class Program implements Comparable<Program> {
mStartTimeUtcMillis = other.mStartTimeUtcMillis;
mEndTimeUtcMillis = other.mEndTimeUtcMillis;
mDescription = other.mDescription;
+ mLongDescription = other.mLongDescription;
mVideoWidth = other.mVideoWidth;
mVideoHeight = other.mVideoHeight;
+ mCriticScores = other.mCriticScores;
mPosterArtUri = other.mPosterArtUri;
mThumbnailUri = other.mThumbnailUri;
mCanonicalGenreIds = other.mCanonicalGenreIds;
mContentRatings = other.mContentRatings;
+ mRecordingProhibited = other.mRecordingProhibited;
+ }
+
+ /**
+ * Checks whether the program is episodic or not.
+ */
+ public boolean isEpisodic() {
+ return mSeriesId != null;
}
+ /**
+ * A Builder for the Program class
+ */
public static final class Builder {
private final Program mProgram;
- private long mId;
+ /**
+ * Creates a Builder for this Program class
+ */
public Builder() {
mProgram = new Program();
// Fill initial data.
+ mProgram.mPackageName = null;
mProgram.mChannelId = Channel.INVALID_ID;
mProgram.mTitle = null;
mProgram.mSeasonNumber = null;
@@ -359,113 +551,258 @@ public final class Program implements Comparable<Program> {
mProgram.mStartTimeUtcMillis = -1;
mProgram.mEndTimeUtcMillis = -1;
mProgram.mDescription = null;
+ mProgram.mLongDescription = null;
+ mProgram.mRecordingProhibited = false;
+ mProgram.mCriticScores = null;
}
+ /**
+ * Creates a builder for this Program class
+ * by setting default values equivalent to another Program
+ * @param other the program to be copied
+ */
+ @VisibleForTesting
public Builder(Program other) {
mProgram = new Program();
mProgram.copyFrom(other);
}
+ /**
+ * Sets the ID of this program
+ * @param id the ID
+ * @return a reference to this object
+ */
public Builder setId(long id) {
mProgram.mId = id;
return this;
}
+ /**
+ * Sets the package name for this program
+ * @param packageName the package name
+ * @return a reference to this object
+ */
+ public Builder setPackageName(String packageName){
+ mProgram.mPackageName = packageName;
+ return this;
+ }
+
+ /**
+ * Sets the channel ID for this program
+ * @param channelId the channel ID
+ * @return a reference to this object
+ */
public Builder setChannelId(long channelId) {
mProgram.mChannelId = channelId;
return this;
}
+ /**
+ * Sets the program title
+ * @param title the title
+ * @return a reference to this object
+ */
public Builder setTitle(String title) {
mProgram.mTitle = title;
return this;
}
+ /**
+ * Sets the series ID.
+ * @param seriesId the series ID
+ * @return a reference to this object
+ */
+ public Builder setSeriesId(String seriesId) {
+ mProgram.mSeriesId = seriesId;
+ return this;
+ }
+
+ /**
+ * Sets the episode title if this is a series program
+ * @param episodeTitle the episode title
+ * @return a reference to this object
+ */
public Builder setEpisodeTitle(String episodeTitle) {
mProgram.mEpisodeTitle = episodeTitle;
return this;
}
+ /**
+ * Sets the season number if this is a series program
+ * @param seasonNumber the season number
+ * @return a reference to this object
+ */
public Builder setSeasonNumber(String seasonNumber) {
mProgram.mSeasonNumber = seasonNumber;
return this;
}
+
+ /**
+ * Sets the season title if this is a series program
+ * @param seasonTitle the season title
+ * @return a reference to this object
+ */
public Builder setSeasonTitle(String seasonTitle) {
mProgram.mSeasonTitle = seasonTitle;
return this;
}
+ /**
+ * Sets the episode number if this is a series program
+ * @param episodeNumber the episode number
+ * @return a reference to this object
+ */
public Builder setEpisodeNumber(String episodeNumber) {
mProgram.mEpisodeNumber = episodeNumber;
return this;
}
+ /**
+ * Sets the start time of this program
+ * @param startTimeUtcMillis the start time in UTC milliseconds
+ * @return a reference to this object
+ */
public Builder setStartTimeUtcMillis(long startTimeUtcMillis) {
mProgram.mStartTimeUtcMillis = startTimeUtcMillis;
return this;
}
+ /**
+ * Sets the end time of this program
+ * @param endTimeUtcMillis the end time in UTC milliseconds
+ * @return a reference to this object
+ */
public Builder setEndTimeUtcMillis(long endTimeUtcMillis) {
mProgram.mEndTimeUtcMillis = endTimeUtcMillis;
return this;
}
+ /**
+ * Sets a description
+ * @param description the description
+ * @return a reference to this object
+ */
public Builder setDescription(String description) {
mProgram.mDescription = description;
return this;
}
+ /**
+ * Sets a long description
+ * @param longDescription the long description
+ * @return a reference to this object
+ */
+ public Builder setLongDescription(String longDescription) {
+ mProgram.mLongDescription = longDescription;
+ return this;
+ }
+
+ /**
+ * Defines the video width of this program
+ * @param width
+ * @return a reference to this object
+ */
public Builder setVideoWidth(int width) {
mProgram.mVideoWidth = width;
return this;
}
+ /**
+ * Defines the video height of this program
+ * @param height
+ * @return a reference to this object
+ */
public Builder setVideoHeight(int height) {
mProgram.mVideoHeight = height;
return this;
}
+ /**
+ * Sets the content ratings for this program
+ * @param contentRatings the content ratings
+ * @return a reference to this object
+ */
public Builder setContentRatings(TvContentRating[] contentRatings) {
mProgram.mContentRatings = contentRatings;
return this;
}
+ /**
+ * Sets the poster art URI
+ * @param posterArtUri the poster art URI
+ * @return a reference to this object
+ */
public Builder setPosterArtUri(String posterArtUri) {
mProgram.mPosterArtUri = posterArtUri;
return this;
}
+ /**
+ * Sets the thumbnail URI
+ * @param thumbnailUri the thumbnail URI
+ * @return a reference to this object
+ */
public Builder setThumbnailUri(String thumbnailUri) {
mProgram.mThumbnailUri = thumbnailUri;
return this;
}
+ /**
+ * Sets the canonical genres by id
+ * @param genres the genres
+ * @return a reference to this object
+ */
public Builder setCanonicalGenres(String genres) {
- if (TextUtils.isEmpty(genres)) {
- return this;
- }
- String[] canonicalGenres = TvContract.Programs.Genres.decode(genres);
- if (canonicalGenres.length > 0) {
- int[] temp = new int[canonicalGenres.length];
- int i = 0;
- for (String canonicalGenre : canonicalGenres) {
- int genreId = GenreItems.getId(canonicalGenre);
- if (genreId == GenreItems.ID_ALL_CHANNELS) {
- // Skip if the genre is unknown.
- continue;
- }
- temp[i++] = genreId;
- }
- if (i < canonicalGenres.length) {
- temp = Arrays.copyOf(temp, i);
+ mProgram.mCanonicalGenreIds = Utils.getCanonicalGenreIds(genres);
+ return this;
+ }
+
+ /**
+ * Sets the recording prohibited flag
+ * @param recordingProhibited recording prohibited flag
+ * @return a reference to this object
+ */
+ public Builder setRecordingProhibited(boolean recordingProhibited) {
+ mProgram.mRecordingProhibited = recordingProhibited;
+ return this;
+ }
+
+ /**
+ * Adds a critic score
+ * @param criticScore the critic score
+ * @return a reference to this object
+ */
+ public Builder addCriticScore(CriticScore criticScore) {
+ if (criticScore.score != null) {
+ if (mProgram.mCriticScores == null) {
+ mProgram.mCriticScores = new ArrayList<>();
}
- mProgram.mCanonicalGenreIds=temp;
+ mProgram.mCriticScores.add(criticScore);
}
return this;
}
+ /**
+ * Sets the critic scores
+ * @param criticScores the critic scores
+ * @return a reference to this objects
+ */
+ public Builder setCriticScores(List<CriticScore> criticScores) {
+ mProgram.mCriticScores = criticScores;
+ return this;
+ }
+
+ /**
+ * Returns a reference to the Program object being constructed
+ * @return the Program object constructed
+ */
public Program build() {
+ // Generate the series ID for the episodic program of other TV input.
+ if (TextUtils.isEmpty(mProgram.mSeriesId)
+ && !TextUtils.isEmpty(mProgram.mEpisodeNumber)) {
+ setSeriesId(BaseProgram.generateSeriesId(mProgram.mPackageName, mProgram.mTitle));
+ }
Program program = new Program();
program.copyFrom(mProgram);
return program;
@@ -509,4 +846,96 @@ public final class Program implements Comparable<Program> {
}
return isDuplicate;
}
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int paramInt) {
+ out.writeLong(mId);
+ out.writeString(mPackageName);
+ out.writeLong(mChannelId);
+ out.writeString(mTitle);
+ out.writeString(mSeriesId);
+ out.writeString(mEpisodeTitle);
+ out.writeString(mSeasonNumber);
+ out.writeString(mSeasonTitle);
+ out.writeString(mEpisodeNumber);
+ out.writeLong(mStartTimeUtcMillis);
+ out.writeLong(mEndTimeUtcMillis);
+ out.writeString(mDescription);
+ out.writeString(mLongDescription);
+ out.writeInt(mVideoWidth);
+ out.writeInt(mVideoHeight);
+ out.writeList(mCriticScores);
+ out.writeString(mPosterArtUri);
+ out.writeString(mThumbnailUri);
+ out.writeIntArray(mCanonicalGenreIds);
+ out.writeInt(mContentRatings == null ? 0 : mContentRatings.length);
+ if (mContentRatings != null) {
+ for (TvContentRating rating : mContentRatings) {
+ out.writeString(rating.flattenToString());
+ }
+ }
+ out.writeByte((byte) (mRecordingProhibited ? 1 : 0));
+ }
+
+ /**
+ * Holds one type of critic score and its source.
+ */
+ public static final class CriticScore implements Serializable, Parcelable {
+ /**
+ * The source of the rating.
+ */
+ public final String source;
+ /**
+ * The score.
+ */
+ public final String score;
+ /**
+ * The url of the logo image
+ */
+ public final String logoUrl;
+
+ public static final Parcelable.Creator<CriticScore> CREATOR =
+ new Parcelable.Creator<CriticScore>() {
+ @Override
+ public CriticScore createFromParcel(Parcel in) {
+ String source = in.readString();
+ String score = in.readString();
+ String logoUri = in.readString();
+ return new CriticScore(source, score, logoUri);
+ }
+
+ @Override
+ public CriticScore[] newArray(int size) {
+ return new CriticScore[size];
+ }
+ };
+
+ /**
+ * Constructor for this class.
+ * @param source the source of the rating
+ * @param score the score
+ */
+ public CriticScore(String source, String score, String logoUrl) {
+ this.source = source;
+ this.score = score;
+ this.logoUrl = logoUrl;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int i) {
+ out.writeString(source);
+ out.writeString(score);
+ out.writeString(logoUrl);
+ }
+ }
}
diff --git a/src/com/android/tv/data/ProgramDataManager.java b/src/com/android/tv/data/ProgramDataManager.java
index 88db91b9..d2af33a7 100644
--- a/src/com/android/tv/data/ProgramDataManager.java
+++ b/src/com/android/tv/data/ProgramDataManager.java
@@ -36,6 +36,7 @@ import android.util.LruCache;
import com.android.tv.common.MemoryManageable;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.experiments.Experiments;
import com.android.tv.util.AsyncDbTask;
import com.android.tv.util.Clock;
import com.android.tv.util.MultiLongSparseArray;
@@ -108,17 +109,17 @@ public class ProgramDataManager implements MemoryManageable {
private boolean mPauseProgramUpdate = false;
private final LruCache<Long, Program> mZeroLengthProgramCache = new LruCache<>(10);
-
- // TODO: Change to final.
- private EpgFetcher mEpgFetcher;
+ private final EpgFetcher mEpgFetcher;
public ProgramDataManager(Context context) {
- this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper());
- mEpgFetcher = new EpgFetcher(context);
+ this(context.getContentResolver(), Clock.SYSTEM, Looper.myLooper(),
+ EpgFetcher.getInstance(context));
}
@VisibleForTesting
- ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper) {
+ ProgramDataManager(ContentResolver contentResolver, Clock time, Looper looper,
+ EpgFetcher epgFetcher) {
+ mEpgFetcher = epgFetcher;
mClock = time;
mContentResolver = contentResolver;
mHandler = new MyHandler(looper);
@@ -174,7 +175,7 @@ public class ProgramDataManager implements MemoryManageable {
}
mContentResolver.registerContentObserver(Programs.CONTENT_URI,
true, mProgramObserver);
- if (mEpgFetcher != null) {
+ if (mEpgFetcher != null && Experiments.CLOUD_EPG.get()) {
mEpgFetcher.start();
}
}
@@ -624,22 +625,6 @@ public class ProgramDataManager implements MemoryManageable {
}
}
- /**
- * Gets an single {@link Program} from {@link TvContract.Programs#CONTENT_URI}.
- */
- public static class QueryProgramTask extends AsyncDbTask.AsyncQueryItemTask<Program> {
-
- public QueryProgramTask(ContentResolver contentResolver, long programId) {
- super(contentResolver, TvContract.buildProgramUri(programId), Program.PROJECTION, null,
- null, null);
- }
-
- @Override
- protected Program fromCursor(Cursor c) {
- return Program.fromCursor(c);
- }
- }
-
private class MyHandler extends Handler {
public MyHandler(Looper looper) {
super(looper);
diff --git a/src/com/android/tv/data/StreamInfo.java b/src/com/android/tv/data/StreamInfo.java
index df842737..fe461f14 100644
--- a/src/com/android/tv/data/StreamInfo.java
+++ b/src/com/android/tv/data/StreamInfo.java
@@ -16,6 +16,8 @@
package com.android.tv.data;
+import android.media.tv.TvContentRating;
+
public interface StreamInfo {
int VIDEO_DEFINITION_LEVEL_UNKNOWN = 0;
int VIDEO_DEFINITION_LEVEL_SD = 1;
@@ -26,6 +28,7 @@ public interface StreamInfo {
int AUDIO_CHANNEL_COUNT_UNKNOWN = 0;
Channel getCurrentChannel();
+ TvContentRating getBlockedContentRating();
int getVideoWidth();
int getVideoHeight();
diff --git a/src/com/android/tv/data/WatchedHistoryManager.java b/src/com/android/tv/data/WatchedHistoryManager.java
index fc6672d2..59319338 100644
--- a/src/com/android/tv/data/WatchedHistoryManager.java
+++ b/src/com/android/tv/data/WatchedHistoryManager.java
@@ -219,7 +219,7 @@ public class WatchedHistoryManager {
}
Long duration = durationMap.get(channelId);
if (duration == null) {
- duration = 0l;
+ duration = 0L;
}
if (duration >= RECENT_CHANNEL_THRESHOLD_MS) {
continue;
diff --git a/src/com/android/tv/data/epg/EpgFetcher.java b/src/com/android/tv/data/epg/EpgFetcher.java
index 9ff527d8..3b093b6a 100644
--- a/src/com/android/tv/data/epg/EpgFetcher.java
+++ b/src/com/android/tv/data/epg/EpgFetcher.java
@@ -16,37 +16,48 @@
package com.android.tv.data.epg;
+import android.Manifest;
+import android.annotation.SuppressLint;
import android.content.ContentProviderOperation;
-import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.OperationApplicationException;
+import android.content.pm.PackageManager;
import android.database.Cursor;
+import android.location.Address;
+import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Programs;
+import android.media.tv.TvContract.Programs.Genres;
import android.media.tv.TvInputInfo;
-import android.media.tv.TvInputManager.TvInputCallback;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.preference.PreferenceManager;
+import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.os.BuildCompat;
import android.text.TextUtils;
import android.util.Log;
-import com.android.tv.Features;
import com.android.tv.TvApplication;
import com.android.tv.common.WeakHandler;
import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.InternalDataUtils;
+import com.android.tv.data.Lineup;
import com.android.tv.data.Program;
+import com.android.tv.util.LocationUtils;
import com.android.tv.util.RecurringRunner;
-import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -61,116 +72,275 @@ public class EpgFetcher {
private static final long EPG_PREFETCH_RECURRING_PERIOD_MS = TimeUnit.HOURS.toMillis(4);
private static final long EPG_READER_INIT_WAIT_MS = TimeUnit.MINUTES.toMillis(1);
+ private static final long LOCATION_INIT_WAIT_MS = TimeUnit.SECONDS.toMillis(10);
+ private static final long LOCATION_ERROR_WAIT_MS = TimeUnit.HOURS.toMillis(1);
private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(30);
private static final int BATCH_OPERATION_COUNT = 100;
+ private static final String SUPPORTED_COUNTRY_CODE = Locale.US.getCountry();
+ private static final String CONTENT_RATING_SEPARATOR = ",";
+
// Value: Long
private static final String KEY_LAST_UPDATED_EPG_TIMESTAMP =
"com.android.tv.data.epg.EpgFetcher.LastUpdatedEpgTimestamp";
+ // Value: String
+ private static final String KEY_LAST_LINEUP_ID =
+ "com.android.tv.data.epg.EpgFetcher.LastLineupId";
+
+ private static EpgFetcher sInstance;
private final Context mContext;
- private final TvInputManagerHelper mInputHelper;
- private final TvInputCallback mInputCallback;
- private HandlerThread mHandlerThread;
+ private final ChannelDataManager mChannelDataManager;
+ private final EpgReader mEpgReader;
private EpgFetcherHandler mHandler;
private RecurringRunner mRecurringRunner;
+ private boolean mStarted;
private long mLastEpgTimestamp = -1;
+ private String mLineupId;
+
+ public static synchronized EpgFetcher getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new EpgFetcher(context.getApplicationContext());
+ }
+ return sInstance;
+ }
+
+ /**
+ * Creates and returns {@link EpgReader}.
+ */
+ public static EpgReader createEpgReader(Context context) {
+ return new StubEpgReader(context);
+ }
- public EpgFetcher(Context context) {
+ private EpgFetcher(Context context) {
mContext = context;
- mInputHelper = TvApplication.getSingletons(mContext).getTvInputManagerHelper();
- mInputCallback = new TvInputCallback() {
+ mEpgReader = new StubEpgReader(mContext);
+ mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager();
+ mChannelDataManager.addListener(new ChannelDataManager.Listener() {
@Override
- public void onInputAdded(String inputId) {
- if (Utils.isInternalTvInput(mContext, inputId)) {
- mHandler.removeMessages(MSG_FETCH_EPG);
- mHandler.sendEmptyMessage(MSG_FETCH_EPG);
- }
+ public void onLoadFinished() {
+ if (DEBUG) Log.d(TAG, "ChannelDataManager.onLoadFinished()");
+ handleChannelChanged();
+ }
+
+ @Override
+ public void onChannelListUpdated() {
+ if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelListUpdated()");
+ handleChannelChanged();
+ }
+
+ @Override
+ public void onChannelBrowsableChanged() {
+ if (DEBUG) Log.d(TAG, "ChannelDataManager.onChannelBrowsableChanged()");
+ handleChannelChanged();
+ }
+ });
+ }
+
+ private void handleChannelChanged() {
+ if (mStarted) {
+ if (needToStop()) {
+ stop();
}
- };
+ } else {
+ start();
+ }
+ }
+
+ private boolean needToStop() {
+ return !canStart();
+ }
+
+ private boolean canStart() {
+ if (DEBUG) Log.d(TAG, "canStart()");
+ boolean hasInternalTunerChannel = false;
+ for (TvInputInfo input : TvApplication.getSingletons(mContext).getTvInputManagerHelper()
+ .getTvInputInfos(true, true)) {
+ String inputId = input.getId();
+ if (Utils.isInternalTvInput(mContext, inputId)
+ && mChannelDataManager.getChannelCountForInput(inputId) > 0) {
+ hasInternalTunerChannel = true;
+ break;
+ }
+ }
+ if (!hasInternalTunerChannel) {
+ if (DEBUG) Log.d(TAG, "No internal tuner channels.");
+ return false;
+ }
+
+ if (!TextUtils.isEmpty(getLastLineupId())) {
+ return true;
+ }
+ if (mContext.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED) {
+ if (DEBUG) Log.d(TAG, "No permission to check the current location.");
+ return false;
+ }
+
+ try {
+ Address address = LocationUtils.getCurrentAddress(mContext);
+ if (address != null
+ && !TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) {
+ if (DEBUG) Log.d(TAG, "Country not supported: " + address.getCountryCode());
+ return false;
+ }
+ } catch (SecurityException e) {
+ Log.w(TAG, "No permission to get the current location", e);
+ return false;
+ } catch (IOException e) {
+ Log.w(TAG, "IO Exception when getting the current location", e);
+ }
+ return true;
}
/**
* Starts fetching EPG.
*/
+ @MainThread
public void start() {
- if (DEBUG) Log.d(TAG, "Request to start fetching EPG.");
- if (!Features.FETCH_EPG.isEnabled(mContext)) {
+ if (DEBUG) Log.d(TAG, "start()");
+ if (mStarted) {
+ if (DEBUG) Log.d(TAG, "EpgFetcher thread already started.");
return;
}
- if (mHandlerThread == null) {
- mHandlerThread = new HandlerThread("EpgFetcher");
- mHandlerThread.start();
- mHandler = new EpgFetcherHandler(mHandlerThread.getLooper(), this);
- mInputHelper.addCallback(mInputCallback);
- mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS,
- new Runnable() {
- @Override
- public void run() {
- mHandler.removeMessages(MSG_FETCH_EPG);
- mHandler.sendEmptyMessage(MSG_FETCH_EPG);
- }
- }, null);
- mRecurringRunner.start();
+ if (!canStart()) {
+ return;
+ }
+ mStarted = true;
+ if (DEBUG) Log.d(TAG, "Starting EpgFetcher thread.");
+ HandlerThread handlerThread = new HandlerThread("EpgFetcher");
+ handlerThread.start();
+ mHandler = new EpgFetcherHandler(handlerThread.getLooper(), this);
+ mRecurringRunner = new RecurringRunner(mContext, EPG_PREFETCH_RECURRING_PERIOD_MS,
+ new EpgRunner(), null);
+ mRecurringRunner.start();
+ if (DEBUG) Log.d(TAG, "EpgFetcher thread started successfully.");
+ }
+
+ /**
+ * Starts fetching EPG immediately if possible without waiting for the timer.
+ */
+ @MainThread
+ public void startImmediately() {
+ start();
+ if (mStarted) {
+ if (DEBUG) Log.d(TAG, "Starting fetcher immediately");
+ fetchEpg();
}
}
/**
* Stops fetching EPG.
*/
+ @MainThread
public void stop() {
- if (mHandlerThread == null) {
+ if (DEBUG) Log.d(TAG, "stop()");
+ if (!mStarted) {
return;
}
+ mStarted = false;
mRecurringRunner.stop();
mHandler.removeCallbacksAndMessages(null);
- mHandler = null;
- mHandlerThread.quit();
- mHandlerThread = null;
+ mHandler.getLooper().quit();
+ }
+
+ private void fetchEpg() {
+ fetchEpg(0);
+ }
+
+ private void fetchEpg(long delay) {
+ mHandler.removeMessages(MSG_FETCH_EPG);
+ mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, delay);
}
private void onFetchEpg() {
if (DEBUG) Log.d(TAG, "Start fetching EPG.");
- // Check for the internal inputs.
- boolean hasInternalInput = false;
- for (TvInputInfo input : mInputHelper.getTvInputInfos(true, true)) {
- if (Utils.isInternalTvInput(mContext, input.getId())) {
- hasInternalInput = true;
- break;
- }
- }
- if (!hasInternalInput) {
- if (DEBUG) Log.d(TAG, "No internal input found.");
- return;
- }
- // Check if EPG reader is available.
- EpgReader epgReader = new StubEpgReader(mContext);
- if (!epgReader.isAvailable()) {
+ if (!mEpgReader.isAvailable()) {
if (DEBUG) Log.d(TAG, "EPG reader is not temporarily available.");
- mHandler.removeMessages(MSG_FETCH_EPG);
- mHandler.sendEmptyMessageDelayed(MSG_FETCH_EPG, EPG_READER_INIT_WAIT_MS);
+ fetchEpg(EPG_READER_INIT_WAIT_MS);
return;
}
+ String lineupId = getLastLineupId();
+ if (lineupId == null) {
+ Address address;
+ try {
+ address = LocationUtils.getCurrentAddress(mContext);
+ } catch (IOException e) {
+ if (DEBUG) Log.d(TAG, "Couldn't get the current location.", e);
+ fetchEpg(LOCATION_ERROR_WAIT_MS);
+ return;
+ } catch (SecurityException e) {
+ Log.w(TAG, "No permission to get the current location.");
+ return;
+ }
+ if (address == null) {
+ if (DEBUG) Log.d(TAG, "Null address returned.");
+ fetchEpg(LOCATION_INIT_WAIT_MS);
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "Current location is " + address);
+
+ lineupId = getLineupForAddress(address);
+ if (lineupId != null) {
+ if (DEBUG) Log.d(TAG, "Saving lineup " + lineupId + "found for " + address);
+ setLastLineupId(lineupId);
+ } else {
+ if (DEBUG) Log.d(TAG, "No lineup found for " + address);
+ return;
+ }
+ }
+
// Check the EPG Timestamp.
- long epgTimestamp = epgReader.getEpgTimestamp();
+ long epgTimestamp = mEpgReader.getEpgTimestamp();
if (epgTimestamp <= getLastUpdatedEpgTimestamp()) {
if (DEBUG) Log.d(TAG, "No new EPG.");
return;
}
- List<Channel> channels = epgReader.getChannels();
+ boolean updated = false;
+ List<Channel> channels = mEpgReader.getChannels(lineupId);
for (Channel channel : channels) {
- List<Program> programs = new ArrayList<>(epgReader.getPrograms(channel.getId()));
+ List<Program> programs = new ArrayList<>(mEpgReader.getPrograms(channel.getId()));
Collections.sort(programs);
if (DEBUG) {
- Log.d(TAG, "Fetching " + programs.size() + " programs for channel " + channel);
+ Log.d(TAG, "Fetched " + programs.size() + " programs for channel " + channel);
+ }
+ if (updateEpg(channel.getId(), programs)) {
+ updated = true;
}
- updateEpg(channel.getId(), programs);
}
+ final boolean epgUpdated = updated;
setLastUpdatedEpgTimestamp(epgTimestamp);
+ mHandler.removeMessages(MSG_FETCH_EPG);
+ if (DEBUG) Log.d(TAG, "Fetching EPG is finished.");
+ }
+
+ @Nullable
+ private String getLineupForAddress(Address address) {
+ String lineup = null;
+ if (TextUtils.equals(address.getCountryCode(), SUPPORTED_COUNTRY_CODE)) {
+ String postalCode = address.getPostalCode();
+ if (!TextUtils.isEmpty(postalCode)) {
+ lineup = getLineupForPostalCode(postalCode);
+ }
+ }
+ return lineup;
+ }
+
+ @Nullable
+ private String getLineupForPostalCode(String postalCode) {
+ List<Lineup> lineups = mEpgReader.getLineups(postalCode);
+ for (Lineup lineup : lineups) {
+ // TODO(EPG): handle more than OTA digital
+ if (lineup.type == Lineup.LINEUP_BROADCAST_DIGITAL) {
+ if (DEBUG) Log.d(TAG, "Setting lineup to " + lineup.name + "(" + lineup.id + ")");
+ return lineup.id;
+ }
+ }
+ return null;
}
private long getLastUpdatedEpgTimestamp() {
@@ -184,18 +354,33 @@ public class EpgFetcher {
private void setLastUpdatedEpgTimestamp(long timestamp) {
mLastEpgTimestamp = timestamp;
PreferenceManager.getDefaultSharedPreferences(mContext).edit().putLong(
- KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp);
+ KEY_LAST_UPDATED_EPG_TIMESTAMP, timestamp).commit();
+ }
+
+ private String getLastLineupId() {
+ if (mLineupId == null) {
+ mLineupId = PreferenceManager.getDefaultSharedPreferences(mContext)
+ .getString(KEY_LAST_LINEUP_ID, null);
+ }
+ if (DEBUG) Log.d(TAG, "Last lineup_id " + mLineupId);
+ return mLineupId;
}
- private void updateEpg(long channelId, List<Program> newPrograms) {
+ private void setLastLineupId(String lineupId) {
+ mLineupId = lineupId;
+ PreferenceManager.getDefaultSharedPreferences(mContext).edit()
+ .putString(KEY_LAST_LINEUP_ID, lineupId).commit();
+ }
+
+ private boolean updateEpg(long channelId, List<Program> newPrograms) {
final int fetchedProgramsCount = newPrograms.size();
if (fetchedProgramsCount == 0) {
- return;
+ return false;
}
+ boolean updated = false;
long startTimeMs = System.currentTimeMillis();
long endTimeMs = startTimeMs + PROGRAM_QUERY_DURATION;
- List<Program> oldPrograms = queryPrograms(mContext.getContentResolver(), channelId,
- startTimeMs, endTimeMs);
+ List<Program> oldPrograms = queryPrograms(channelId, startTimeMs, endTimeMs);
Program currentOldProgram = oldPrograms.size() > 0 ? oldPrograms.get(0) : null;
int oldProgramsIndex = 0;
int newProgramsIndex = 0;
@@ -224,15 +409,13 @@ public class EpgFetcher {
oldProgramsIndex++;
newProgramsIndex++;
} else if (isSameTitleAndOverlap(oldProgram, newProgram)) {
- if (!oldProgram.equals(oldProgram)) {
- // Partial match. Update the old program with the new one.
- // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
- // could be application specific settings which belong to the old program.
- ops.add(ContentProviderOperation.newUpdate(
- TvContract.buildProgramUri(oldProgram.getId()))
- .withValues(toContentValues(newProgram))
- .build());
- }
+ // Partial match. Update the old program with the new one.
+ // NOTE: Use 'update' in this case instead of 'insert' and 'delete'. There
+ // could be application specific settings which belong to the old program.
+ ops.add(ContentProviderOperation.newUpdate(
+ TvContract.buildProgramUri(oldProgram.getId()))
+ .withValues(toContentValues(newProgram))
+ .build());
oldProgramsIndex++;
newProgramsIndex++;
} else if (oldProgram.getEndTimeUtcMillis()
@@ -271,25 +454,26 @@ public class EpgFetcher {
}
}
mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
+ updated = true;
} catch (RemoteException | OperationApplicationException e) {
Log.e(TAG, "Failed to insert programs.", e);
- return;
+ return updated;
}
ops.clear();
}
}
if (DEBUG) {
- Log.d(TAG, "Fetched " + fetchedProgramsCount + " programs for channel " + channelId);
+ Log.d(TAG, "Updated " + fetchedProgramsCount + " programs for channel " + channelId);
}
+ return updated;
}
- private List<Program> queryPrograms(ContentResolver contentResolver, long channelId,
- long startTimeMs, long endTimeMs) {
+ private List<Program> queryPrograms(long channelId, long startTimeMs, long endTimeMs) {
try (Cursor c = mContext.getContentResolver().query(
TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs),
Program.PROJECTION, null, null, Programs.COLUMN_START_TIME_UTC_MILLIS)) {
if (c == null) {
- return Collections.EMPTY_LIST;
+ return Collections.emptyList();
}
ArrayList<Program> programs = new ArrayList<>();
while (c.moveToNext()) {
@@ -313,18 +497,48 @@ public class EpgFetcher {
&& newProgram.getStartTimeUtcMillis() <= oldProgram.getEndTimeUtcMillis();
}
+ @SuppressLint("InlinedApi")
+ @SuppressWarnings("deprecation")
private static ContentValues toContentValues(Program program) {
ContentValues values = new ContentValues();
values.put(TvContract.Programs.COLUMN_CHANNEL_ID, program.getChannelId());
putValue(values, TvContract.Programs.COLUMN_TITLE, program.getTitle());
putValue(values, TvContract.Programs.COLUMN_EPISODE_TITLE, program.getEpisodeTitle());
- putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber());
- putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber());
+ if (BuildCompat.isAtLeastN()) {
+ putValue(values, TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER,
+ program.getSeasonNumber());
+ putValue(values, TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER,
+ program.getEpisodeNumber());
+ } else {
+ putValue(values, TvContract.Programs.COLUMN_SEASON_NUMBER, program.getSeasonNumber());
+ putValue(values, TvContract.Programs.COLUMN_EPISODE_NUMBER, program.getEpisodeNumber());
+ }
putValue(values, TvContract.Programs.COLUMN_SHORT_DESCRIPTION, program.getDescription());
putValue(values, TvContract.Programs.COLUMN_POSTER_ART_URI, program.getPosterArtUri());
+ putValue(values, TvContract.Programs.COLUMN_THUMBNAIL_URI, program.getThumbnailUri());
+ String[] canonicalGenres = program.getCanonicalGenres();
+ if (canonicalGenres != null && canonicalGenres.length > 0) {
+ putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE,
+ Genres.encode(canonicalGenres));
+ } else {
+ putValue(values, TvContract.Programs.COLUMN_CANONICAL_GENRE, "");
+ }
+ TvContentRating[] ratings = program.getContentRatings();
+ if (ratings != null && ratings.length > 0) {
+ StringBuilder sb = new StringBuilder(ratings[0].flattenToString());
+ for (int i = 1; i < ratings.length; ++i) {
+ sb.append(CONTENT_RATING_SEPARATOR);
+ sb.append(ratings[i].flattenToString());
+ }
+ putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, sb.toString());
+ } else {
+ putValue(values, TvContract.Programs.COLUMN_CONTENT_RATING, "");
+ }
values.put(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
program.getStartTimeUtcMillis());
values.put(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS, program.getEndTimeUtcMillis());
+ putValue(values, TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA,
+ InternalDataUtils.serializeInternalProviderData(program));
return values;
}
@@ -336,6 +550,14 @@ public class EpgFetcher {
}
}
+ private static void putValue(ContentValues contentValues, String key, byte[] value) {
+ if (value == null || value.length == 0) {
+ contentValues.putNull(key);
+ } else {
+ contentValues.put(key, value);
+ }
+ }
+
private static class EpgFetcherHandler extends WeakHandler<EpgFetcher> {
public EpgFetcherHandler (@NonNull Looper looper, EpgFetcher ref) {
super(looper, ref);
@@ -353,4 +575,11 @@ public class EpgFetcher {
}
}
}
+
+ private class EpgRunner implements Runnable {
+ @Override
+ public void run() {
+ fetchEpg();
+ }
+ }
}
diff --git a/src/com/android/tv/data/epg/EpgReader.java b/src/com/android/tv/data/epg/EpgReader.java
index 1c7712f4..4f3b6f52 100644
--- a/src/com/android/tv/data/epg/EpgReader.java
+++ b/src/com/android/tv/data/epg/EpgReader.java
@@ -16,10 +16,13 @@
package com.android.tv.data.epg;
+import android.support.annotation.NonNull;
import android.support.annotation.WorkerThread;
import com.android.tv.data.Channel;
+import com.android.tv.data.Lineup;
import com.android.tv.data.Program;
+import com.android.tv.dvr.SeriesInfo;
import java.util.List;
@@ -42,7 +45,12 @@ public interface EpgReader {
/**
* Returns the channels list.
*/
- List<Channel> getChannels();
+ List<Channel> getChannels(@NonNull String lineupId);
+
+ /**
+ * Returns the lineups list.
+ */
+ List<Lineup> getLineups(@NonNull String postalCode);
/**
* Returns the programs for the given channel. The result is sorted by the start time.
@@ -50,4 +58,9 @@ public interface EpgReader {
* TvProvider.
*/
List<Program> getPrograms(long channelId);
+
+ /**
+ * Returns the series information for the given series ID.
+ */
+ SeriesInfo getSeriesInfo(String seriesId);
}
diff --git a/src/com/android/tv/data/epg/StubEpgReader.java b/src/com/android/tv/data/epg/StubEpgReader.java
index 2896e8e5..64093f89 100644
--- a/src/com/android/tv/data/epg/StubEpgReader.java
+++ b/src/com/android/tv/data/epg/StubEpgReader.java
@@ -19,7 +19,9 @@ package com.android.tv.data.epg;
import android.content.Context;
import com.android.tv.data.Channel;
+import com.android.tv.data.Lineup;
import com.android.tv.data.Program;
+import com.android.tv.dvr.SeriesInfo;
import java.util.Collections;
import java.util.List;
@@ -28,7 +30,7 @@ import java.util.List;
* A stub class to read EPG.
*/
public class StubEpgReader implements EpgReader{
- public StubEpgReader(Context context) {
+ public StubEpgReader(@SuppressWarnings("unused") Context context) {
}
@Override
@@ -42,12 +44,22 @@ public class StubEpgReader implements EpgReader{
}
@Override
- public List<Channel> getChannels() {
- return Collections.EMPTY_LIST;
+ public List<Channel> getChannels(String lineupId) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<Lineup> getLineups(String postalCode) {
+ return Collections.emptyList();
}
@Override
public List<Program> getPrograms(long channelId) {
- return Collections.EMPTY_LIST;
+ return Collections.emptyList();
+ }
+
+ @Override
+ public SeriesInfo getSeriesInfo(String seriesId) {
+ return null;
}
}
diff --git a/src/com/android/tv/dialog/PinDialogFragment.java b/src/com/android/tv/dialog/PinDialogFragment.java
index 3952bb0b..d9d6c73f 100644
--- a/src/com/android/tv/dialog/PinDialogFragment.java
+++ b/src/com/android/tv/dialog/PinDialogFragment.java
@@ -75,6 +75,11 @@ public class PinDialogFragment extends SafeDismissDialogFragment {
// PIN code dialog for checking old PIN. This is internal only.
private static final int PIN_DIALOG_TYPE_OLD_PIN = 4;
+ /**
+ * PIN code dialog for unlocking DVR playback
+ */
+ public static final int PIN_DIALOG_TYPE_UNLOCK_DVR = 5;
+
private static final int PIN_DIALOG_RESULT_SUCCESS = 0;
private static final int PIN_DIALOG_RESULT_FAIL = 1;
@@ -104,14 +109,20 @@ public class PinDialogFragment extends SafeDismissDialogFragment {
private SharedPreferences mSharedPreferences;
private String mPrevPin;
private String mPin;
+ private String mRatingString;
private int mWrongPinCount;
private long mDisablePinUntil;
private final Handler mHandler = new Handler();
public PinDialogFragment(int type, ResultListener listener) {
+ this(type, listener, null);
+ }
+
+ public PinDialogFragment(int type, ResultListener listener, String rating) {
mType = type;
mListener = listener;
mRetCode = PIN_DIALOG_RESULT_FAIL;
+ mRatingString = rating;
}
@Override
@@ -174,6 +185,9 @@ public class PinDialogFragment extends SafeDismissDialogFragment {
case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
mTitleView.setText(R.string.pin_enter_unlock_program);
break;
+ case PIN_DIALOG_TYPE_UNLOCK_DVR:
+ mTitleView.setText(getString(R.string.pin_enter_unlock_dvr, mRatingString));
+ break;
case PIN_DIALOG_TYPE_ENTER_PIN:
mTitleView.setText(R.string.pin_enter_pin);
break;
@@ -269,6 +283,7 @@ public class PinDialogFragment extends SafeDismissDialogFragment {
switch (mType) {
case PIN_DIALOG_TYPE_UNLOCK_CHANNEL:
case PIN_DIALOG_TYPE_UNLOCK_PROGRAM:
+ case PIN_DIALOG_TYPE_UNLOCK_DVR:
case PIN_DIALOG_TYPE_ENTER_PIN:
// TODO: Implement limited number of retrials and timeout logic.
if (TextUtils.isEmpty(getPin()) || pin.equals(getPin())) {
diff --git a/src/com/android/tv/dialog/SafeDismissDialogFragment.java b/src/com/android/tv/dialog/SafeDismissDialogFragment.java
index 1569f0a9..f671a87d 100644
--- a/src/com/android/tv/dialog/SafeDismissDialogFragment.java
+++ b/src/com/android/tv/dialog/SafeDismissDialogFragment.java
@@ -47,7 +47,9 @@ public abstract class SafeDismissDialogFragment extends DialogFragment
public void onAttach(Activity activity) {
super.onAttach(activity);
mAttached = true;
- mActivity = (MainActivity) activity;
+ if (activity instanceof MainActivity) {
+ mActivity = (MainActivity) activity;
+ }
mTracker = TvApplication.getSingletons(activity).getTracker();
if (mDismissPending) {
mDismissPending = false;
@@ -100,7 +102,7 @@ public abstract class SafeDismissDialogFragment extends DialogFragment
public boolean onKeyUp(int keyCode, KeyEvent event) {
// When a dialog is showing, key events are handled by the dialog instead of
// MainActivity. Therefore, unless a key is a global key, it should be handled here.
- if (mAttached && keyCode == KeyEvent.KEYCODE_SEARCH) {
+ if (mAttached && keyCode == KeyEvent.KEYCODE_SEARCH && mActivity != null) {
mActivity.showSearchActivity();
return true;
}
diff --git a/src/com/android/tv/dvr/BaseDvrDataManager.java b/src/com/android/tv/dvr/BaseDvrDataManager.java
index 0fb469be..89661df3 100644
--- a/src/com/android/tv/dvr/BaseDvrDataManager.java
+++ b/src/com/android/tv/dvr/BaseDvrDataManager.java
@@ -20,17 +20,24 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
import android.util.ArraySet;
import android.util.Log;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.feature.CommonFeatures;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.dvr.ScheduledRecording.RecordingState;
import com.android.tv.util.Clock;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
/**
* Base implementation of @{link DataManagerInternal}.
@@ -42,8 +49,14 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager {
private final static boolean DEBUG = false;
protected final Clock mClock;
+ private final Set<OnDvrScheduleLoadFinishedListener> mOnDvrScheduleLoadFinishedListeners =
+ new CopyOnWriteArraySet<>();
+ private final Set<OnRecordedProgramLoadFinishedListener>
+ mOnRecordedProgramLoadFinishedListeners = new CopyOnWriteArraySet<>();
private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>();
+ private final Set<SeriesRecordingListener> mSeriesRecordingListeners = new ArraySet<>();
private final Set<RecordedProgramListener> mRecordedProgramListeners = new ArraySet<>();
+ private final HashMap<Long, ScheduledRecording> mDeletedScheduleMap = new HashMap<>();
BaseDvrDataManager(Context context, Clock clock) {
SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG);
@@ -51,6 +64,28 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager {
}
@Override
+ public void addDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener) {
+ mOnDvrScheduleLoadFinishedListeners.add(listener);
+ }
+
+ @Override
+ public void removeDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener) {
+ mOnDvrScheduleLoadFinishedListeners.remove(listener);
+ }
+
+ @Override
+ public void addRecordedProgramLoadFinishedListener(
+ OnRecordedProgramLoadFinishedListener listener) {
+ mOnRecordedProgramLoadFinishedListeners.add(listener);
+ }
+
+ @Override
+ public void removeRecordedProgramLoadFinishedListener(
+ OnRecordedProgramLoadFinishedListener listener) {
+ mOnRecordedProgramLoadFinishedListeners.remove(listener);
+ }
+
+ @Override
public final void addScheduledRecordingListener(ScheduledRecordingListener listener) {
mScheduledRecordingListeners.add(listener);
}
@@ -61,6 +96,16 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager {
}
@Override
+ public final void addSeriesRecordingListener(SeriesRecordingListener listener) {
+ mSeriesRecordingListeners.add(listener);
+ }
+
+ @Override
+ public final void removeSeriesRecordingListener(SeriesRecordingListener listener) {
+ mSeriesRecordingListeners.remove(listener);
+ }
+
+ @Override
public final void addRecordedProgramListener(RecordedProgramListener listener) {
mRecordedProgramListeners.add(listener);
}
@@ -71,71 +116,124 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager {
}
/**
- * Calls {@link RecordedProgramListener#onRecordedProgramAdded(RecordedProgram)}
+ * Calls {@link OnDvrScheduleLoadFinishedListener#onDvrScheduleLoadFinished} for each listener.
+ */
+ protected final void notifyDvrScheduleLoadFinished() {
+ for (OnDvrScheduleLoadFinishedListener l : mOnDvrScheduleLoadFinishedListeners) {
+ if (DEBUG) Log.d(TAG, "notify DVR schedule load finished");
+ l.onDvrScheduleLoadFinished();
+ }
+ }
+
+ /**
+ * Calls {@link OnRecordedProgramLoadFinishedListener#onRecordedProgramLoadFinished()}
+ * for each listener.
+ */
+ protected final void notifyRecordedProgramLoadFinished() {
+ for (OnRecordedProgramLoadFinishedListener l : mOnRecordedProgramLoadFinishedListeners) {
+ if (DEBUG) Log.d(TAG, "notify recorded programs load finished");
+ l.onRecordedProgramLoadFinished();
+ }
+ }
+
+ /**
+ * Calls {@link RecordedProgramListener#onRecordedProgramsAdded}
* for each listener.
*/
- protected final void notifyRecordedProgramAdded(RecordedProgram recordedProgram) {
+ protected final void notifyRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
for (RecordedProgramListener l : mRecordedProgramListeners) {
- if (DEBUG) Log.d(TAG, "notify " + l + "added " + recordedProgram);
- l.onRecordedProgramAdded(recordedProgram);
+ if (DEBUG) Log.d(TAG, "notify " + l + " added " + Arrays.asList(recordedPrograms));
+ l.onRecordedProgramsAdded(recordedPrograms);
}
}
/**
- * Calls {@link RecordedProgramListener#onRecordedProgramChanged(RecordedProgram)}
+ * Calls {@link RecordedProgramListener#onRecordedProgramsChanged}
* for each listener.
*/
- protected final void notifyRecordedProgramChanged(RecordedProgram recordedProgram) {
+ protected final void notifyRecordedProgramsChanged(RecordedProgram... recordedPrograms) {
for (RecordedProgramListener l : mRecordedProgramListeners) {
- if (DEBUG) Log.d(TAG, "notify " + l + "changed " + recordedProgram);
- l.onRecordedProgramChanged(recordedProgram);
+ if (DEBUG) Log.d(TAG, "notify " + l + " changed " + Arrays.asList(recordedPrograms));
+ l.onRecordedProgramsChanged(recordedPrograms);
}
}
/**
- * Calls {@link RecordedProgramListener#onRecordedProgramRemoved(RecordedProgram)}
+ * Calls {@link RecordedProgramListener#onRecordedProgramsRemoved}
* for each listener.
*/
- protected final void notifyRecordedProgramRemoved(RecordedProgram recordedProgram) {
+ protected final void notifyRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
for (RecordedProgramListener l : mRecordedProgramListeners) {
- if (DEBUG) Log.d(TAG, "notify " + l + "removed " + recordedProgram);
- l.onRecordedProgramRemoved(recordedProgram);
+ if (DEBUG) Log.d(TAG, "notify " + l + " removed " + Arrays.asList(recordedPrograms));
+ l.onRecordedProgramsRemoved(recordedPrograms);
+ }
+ }
+
+ /**
+ * Calls {@link SeriesRecordingListener#onSeriesRecordingAdded}
+ * for each listener.
+ */
+ protected final void notifySeriesRecordingAdded(SeriesRecording... seriesRecordings) {
+ for (SeriesRecordingListener l : mSeriesRecordingListeners) {
+ if (DEBUG) Log.d(TAG, "notify " + l + " added " + Arrays.asList(seriesRecordings));
+ l.onSeriesRecordingAdded(seriesRecordings);
+ }
+ }
+
+ /**
+ * Calls {@link SeriesRecordingListener#onSeriesRecordingRemoved}
+ * for each listener.
+ */
+ protected final void notifySeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ for (SeriesRecordingListener l : mSeriesRecordingListeners) {
+ if (DEBUG) Log.d(TAG, "notify " + l + " removed " + Arrays.asList(seriesRecordings));
+ l.onSeriesRecordingRemoved(seriesRecordings);
+ }
+ }
+
+ /**
+ * Calls
+ * {@link SeriesRecordingListener#onSeriesRecordingChanged}
+ * for each listener.
+ */
+ protected final void notifySeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ for (SeriesRecordingListener l : mSeriesRecordingListeners) {
+ if (DEBUG) Log.d(TAG, "notify " + l + " changed " + Arrays.asList(seriesRecordings));
+ l.onSeriesRecordingChanged(seriesRecordings);
}
}
/**
- * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded(ScheduledRecording)}
+ * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded}
* for each listener.
*/
- protected final void notifyScheduledRecordingAdded(ScheduledRecording scheduledRecording) {
+ protected final void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecording) {
for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
- if (DEBUG) Log.d(TAG, "notify " + l + "added " + scheduledRecording);
+ if (DEBUG) Log.d(TAG, "notify " + l + " added " + Arrays.asList(scheduledRecording));
l.onScheduledRecordingAdded(scheduledRecording);
}
}
/**
- * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved(ScheduledRecording)}
+ * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved}
* for each listener.
*/
- protected final void notifyScheduledRecordingRemoved(ScheduledRecording scheduledRecording) {
+ protected final void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecording) {
for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
- if (DEBUG) {
- Log.d(TAG, "notify " + l + "removed " + scheduledRecording);
- }
+ if (DEBUG) Log.d(TAG, "notify " + l + " removed " + Arrays.asList(scheduledRecording));
l.onScheduledRecordingRemoved(scheduledRecording);
}
}
/**
* Calls
- * {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged(ScheduledRecording)}
+ * {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged}
* for each listener.
*/
protected final void notifyScheduledRecordingStatusChanged(
- ScheduledRecording scheduledRecording) {
+ ScheduledRecording... scheduledRecording) {
for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
- if (DEBUG) Log.d(TAG, "notify " + l + "changed " + scheduledRecording);
+ if (DEBUG) Log.d(TAG, "notify " + l + " changed " + Arrays.asList(scheduledRecording));
l.onScheduledRecordingStatusChanged(scheduledRecording);
}
}
@@ -155,16 +253,70 @@ public abstract class BaseDvrDataManager implements WritableDvrDataManager {
}
@Override
+ public List<ScheduledRecording> getAvailableScheduledRecordings() {
+ return filterEndTimeIsPast(getRecordingsWithState(
+ ScheduledRecording.STATE_RECORDING_IN_PROGRESS,
+ ScheduledRecording.STATE_RECORDING_NOT_STARTED));
+ }
+
+ @Override
public List<ScheduledRecording> getStartedRecordings() {
- return filterEndTimeIsPast(
- getRecordingsWithState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS));
+ return filterEndTimeIsPast(getRecordingsWithState(
+ ScheduledRecording.STATE_RECORDING_IN_PROGRESS));
}
@Override
public List<ScheduledRecording> getNonStartedScheduledRecordings() {
- return filterEndTimeIsPast(
- getRecordingsWithState(ScheduledRecording.STATE_RECORDING_NOT_STARTED));
+ return filterEndTimeIsPast(getRecordingsWithState(
+ ScheduledRecording.STATE_RECORDING_NOT_STARTED));
+ }
+
+ @Override
+ public void changeState(ScheduledRecording scheduledRecording, @RecordingState int newState) {
+ if (scheduledRecording.getState() != newState) {
+ updateScheduledRecording(ScheduledRecording.buildFrom(scheduledRecording)
+ .setState(newState).build());
+ }
+ }
+
+ @Override
+ public Collection<ScheduledRecording> getDeletedSchedules() {
+ return mDeletedScheduleMap.values();
+ }
+
+ @NonNull
+ @Override
+ public Collection<Long> getDisallowedProgramIds() {
+ return mDeletedScheduleMap.keySet();
}
- protected abstract List<ScheduledRecording> getRecordingsWithState(int state);
+ /**
+ * Returns the map which contains the deleted schedules which are mapped from the program ID.
+ */
+ protected Map<Long, ScheduledRecording> getDeletedScheduleMap() {
+ return mDeletedScheduleMap;
+ }
+
+ /**
+ * Returns the schedules whose state is contained by states.
+ */
+ protected abstract List<ScheduledRecording> getRecordingsWithState(int... states);
+
+ @Override
+ public List<RecordedProgram> getRecordedPrograms(long seriesRecordingId) {
+ SeriesRecording seriesRecording = getSeriesRecording(seriesRecordingId);
+ if (seriesRecording == null) {
+ return Collections.emptyList();
+ }
+ List<RecordedProgram> result = new ArrayList<>();
+ for (RecordedProgram r : getRecordedPrograms()) {
+ if (seriesRecording.getSeriesId().equals(r.getSeriesId())) {
+ result.add(r);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void forgetStorage(String inputId) { }
}
diff --git a/src/com/android/tv/dvr/ConflictChecker.java b/src/com/android/tv/dvr/ConflictChecker.java
new file mode 100644
index 00000000..201e379e
--- /dev/null
+++ b/src/com/android/tv/dvr/ConflictChecker.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.TargetApi;
+import android.content.ContentUris;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Message;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.OnTvViewChannelChangeListener;
+import com.android.tv.MainActivity;
+import com.android.tv.TvApplication;
+import com.android.tv.common.WeakHandler;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Checking the runtime conflict of DVR recording.
+ * <p>
+ * This class runs only while the {@link MainActivity} is resumed and holds the upcoming conflicts.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+@MainThread
+public class ConflictChecker {
+ private static final String TAG = "ConflictChecker";
+ private static final boolean DEBUG = false;
+
+ private static final int MSG_CHECK_CONFLICT = 1;
+
+ private static final long CHECK_RETRY_PERIOD_MS = TimeUnit.SECONDS.toMillis(30);
+
+ /**
+ * To show watch conflict dialog, the start time of the earliest conflicting schedule should be
+ * less than or equal to this time.
+ */
+ private static final long MAX_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.MINUTES.toMillis(5);
+ /**
+ * To show watch conflict dialog, the start time of the earliest conflicting schedule should be
+ * greater than or equal to this time.
+ */
+ private static final long MIN_WATCH_CONFLICT_CHECK_TIME_MS = TimeUnit.SECONDS.toMillis(30);
+
+ private final MainActivity mMainActivity;
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrScheduleManager mScheduleManager;
+ private final InputSessionManager mSessionManager;
+ private final ConflictCheckerHandler mHandler = new ConflictCheckerHandler(this);
+
+ private final List<ScheduledRecording> mUpcomingConflicts = new ArrayList<>();
+ private final Set<OnUpcomingConflictChangeListener> mOnUpcomingConflictChangeListeners =
+ new ArraySet<>();
+ private final Map<Long, List<ScheduledRecording>> mCheckedConflictsMap = new HashMap<>();
+
+ private final ScheduledRecordingListener mScheduledRecordingListener =
+ new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingStatusChanged: " + scheduledRecordings);
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+ };
+
+ private final OnTvViewChannelChangeListener mOnTvViewChannelChangeListener =
+ new OnTvViewChannelChangeListener() {
+ @Override
+ public void onTvViewChannelChange(@Nullable Uri channelUri) {
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ }
+ };
+
+ private boolean mStarted;
+
+ public ConflictChecker(MainActivity mainActivity) {
+ mMainActivity = mainActivity;
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(mainActivity);
+ mChannelDataManager = appSingletons.getChannelDataManager();
+ mScheduleManager = appSingletons.getDvrScheduleManager();
+ mSessionManager = appSingletons.getInputSessionManager();
+ }
+
+ /**
+ * Starts checking the conflict.
+ */
+ public void start() {
+ if (mStarted) {
+ return;
+ }
+ mStarted = true;
+ mHandler.sendEmptyMessage(MSG_CHECK_CONFLICT);
+ mScheduleManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ mSessionManager.addOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener);
+ }
+
+ /**
+ * Stops checking the conflict.
+ */
+ public void stop() {
+ if (!mStarted) {
+ return;
+ }
+ mStarted = false;
+ mSessionManager.removeOnTvViewChannelChangeListener(mOnTvViewChannelChangeListener);
+ mScheduleManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ /**
+ * Returns the upcoming conflicts.
+ */
+ public List<ScheduledRecording> getUpcomingConflicts() {
+ return new ArrayList<>(mUpcomingConflicts);
+ }
+
+ /**
+ * Adds a {@link OnUpcomingConflictChangeListener}.
+ */
+ public void addOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) {
+ mOnUpcomingConflictChangeListeners.add(listener);
+ }
+
+ /**
+ * Removes the {@link OnUpcomingConflictChangeListener}.
+ */
+ public void removeOnUpcomingConflictChangeListener(OnUpcomingConflictChangeListener listener) {
+ mOnUpcomingConflictChangeListeners.remove(listener);
+ }
+
+ private void notifyUpcomingConflictChanged() {
+ for (OnUpcomingConflictChangeListener l : mOnUpcomingConflictChangeListeners) {
+ l.onUpcomingConflictChange();
+ }
+ }
+
+ /**
+ * Remembers the user's decision to record while watching the channel.
+ */
+ public void setCheckedConflictsForChannel(long mChannelId, List<ScheduledRecording> conflicts) {
+ mCheckedConflictsMap.put(mChannelId, new ArrayList<>(conflicts));
+ }
+
+ void onCheckConflict() {
+ // Checks the conflicting schedules and setup the next re-check time.
+ // If there are upcoming conflicts soon, it opens the conflict dialog.
+ if (DEBUG) Log.d(TAG, "Handling MSG_CHECK_CONFLICT");
+ mHandler.removeMessages(MSG_CHECK_CONFLICT);
+ mUpcomingConflicts.clear();
+ if (!mScheduleManager.isInitialized()
+ || !mChannelDataManager.isDbLoadFinished()) {
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT, CHECK_RETRY_PERIOD_MS);
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ if (mSessionManager.getCurrentTvViewChannelUri() == null) {
+ // As MainActivity is not using a tuner, no need to check the conflict.
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ Uri channelUri = mSessionManager.getCurrentTvViewChannelUri();
+ if (TvContract.isChannelUriForPassthroughInput(channelUri)) {
+ notifyUpcomingConflictChanged();
+ return;
+ }
+ long channelId = ContentUris.parseId(channelUri);
+ Channel channel = mChannelDataManager.getChannel(channelId);
+ // The conflicts caused by watching the channel.
+ List<ScheduledRecording> conflicts = mScheduleManager
+ .getConflictingSchedulesForWatching(channel.getId());
+ long earliestToCheck = Long.MAX_VALUE;
+ long currentTimeMs = System.currentTimeMillis();
+ for (ScheduledRecording schedule : conflicts) {
+ long startTimeMs = schedule.getStartTimeMs();
+ if (startTimeMs < currentTimeMs + MIN_WATCH_CONFLICT_CHECK_TIME_MS) {
+ // The start time of the upcoming conflict remains less than the minimum
+ // check time.
+ continue;
+ }
+ if (startTimeMs > currentTimeMs + MAX_WATCH_CONFLICT_CHECK_TIME_MS) {
+ // The start time of the upcoming conflict remains greater than the
+ // maximum check time. Setup the next re-check time.
+ long nextCheckTimeMs = startTimeMs - MAX_WATCH_CONFLICT_CHECK_TIME_MS;
+ if (earliestToCheck > nextCheckTimeMs) {
+ earliestToCheck = nextCheckTimeMs;
+ }
+ } else {
+ // Found upcoming conflicts which will start soon.
+ mUpcomingConflicts.add(schedule);
+ // The schedule will be removed from the "upcoming conflict" when the
+ // recording is almost started.
+ long nextCheckTimeMs = startTimeMs - MIN_WATCH_CONFLICT_CHECK_TIME_MS;
+ if (earliestToCheck > nextCheckTimeMs) {
+ earliestToCheck = nextCheckTimeMs;
+ }
+ }
+ }
+ if (earliestToCheck != Long.MAX_VALUE) {
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_CONFLICT,
+ earliestToCheck - currentTimeMs);
+ }
+ if (DEBUG) Log.d(TAG, "upcoming conflicts: " + mUpcomingConflicts);
+ notifyUpcomingConflictChanged();
+ if (!mUpcomingConflicts.isEmpty()
+ && !DvrUiHelper.isChannelWatchConflictDialogShown(mMainActivity)) {
+ // Don't show the conflict dialog if the user already knows.
+ List<ScheduledRecording> checkedConflicts = mCheckedConflictsMap.get(
+ channel.getId());
+ if (checkedConflicts == null
+ || !checkedConflicts.containsAll(mUpcomingConflicts)) {
+ DvrUiHelper.showChannelWatchConflictDialog(mMainActivity, channel);
+ }
+ }
+ }
+
+ private static class ConflictCheckerHandler extends WeakHandler<ConflictChecker> {
+ ConflictCheckerHandler(ConflictChecker conflictChecker) {
+ super(conflictChecker);
+ }
+
+ @Override
+ protected void handleMessage(Message msg, @NonNull ConflictChecker conflictChecker) {
+ switch (msg.what) {
+ case MSG_CHECK_CONFLICT:
+ conflictChecker.onCheckConflict();
+ break;
+ }
+ }
+ }
+
+ /**
+ * A listener for the change of upcoming conflicts.
+ */
+ public interface OnUpcomingConflictChangeListener {
+ void onUpcomingConflictChange();
+ }
+}
diff --git a/src/com/android/tv/dvr/DvrDataManager.java b/src/com/android/tv/dvr/DvrDataManager.java
index c96104e5..06613667 100644
--- a/src/com/android/tv/dvr/DvrDataManager.java
+++ b/src/com/android/tv/dvr/DvrDataManager.java
@@ -17,11 +17,13 @@
package com.android.tv.dvr;
import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Range;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.dvr.ScheduledRecording.RecordingState;
+import java.util.Collection;
import java.util.List;
/**
@@ -34,16 +36,39 @@ public interface DvrDataManager {
boolean isInitialized();
/**
+ * Returns {@code true} if the schedules were loaded, otherwise {@code false}.
+ */
+ boolean isDvrScheduleLoadFinished();
+
+ /**
+ * Returns {@code true} if the recorded programs were loaded, otherwise {@code false}.
+ */
+ boolean isRecordedProgramLoadFinished();
+
+ /**
* Returns past recordings.
*/
List<RecordedProgram> getRecordedPrograms();
/**
+ * Returns past recorded programs in the given series.
+ */
+ List<RecordedProgram> getRecordedPrograms(long seriesRecordingId);
+
+ /**
* Returns all {@link ScheduledRecording} regardless of state.
+ * <p>
+ * The result doesn't contain the deleted schedules.
*/
List<ScheduledRecording> getAllScheduledRecordings();
/**
+ * Returns all available {@link ScheduledRecording}, it contains started and non started
+ * recordings.
+ */
+ List<ScheduledRecording> getAvailableScheduledRecordings();
+
+ /**
* Returns started recordings that expired.
*/
List<ScheduledRecording> getStartedRecordings();
@@ -54,9 +79,14 @@ public interface DvrDataManager {
List<ScheduledRecording> getNonStartedScheduledRecordings();
/**
- * Returns season recordings.
+ * Returns series recordings.
+ */
+ List<SeriesRecording> getSeriesRecordings();
+
+ /**
+ * Returns series recordings from the given input.
*/
- List<SeasonRecording> getSeasonRecordings();
+ List<SeriesRecording> getSeriesRecordings(String inputId);
/**
* Returns the next start time after {@code time} or {@link #NEXT_START_TIME_NOT_FOUND}
@@ -67,15 +97,47 @@ public interface DvrDataManager {
long getNextScheduledStartTimeAfter(long time);
/**
- * Returns a list of all Recordings with a overlap with the given time period inclusive.
+ * Returns a list of the schedules with a overlap with the given time period inclusive and with
+ * the given state.
*
* <p> A recording overlaps with a period when
* {@code recording.getStartTime() <= period.getUpper() &&
* recording.getEndTime() >= period.getLower()}.
*
* @param period a time period in milliseconds.
+ * @param state the state of the schedule.
*/
- List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period);
+ List<ScheduledRecording> getScheduledRecordings(Range<Long> period, @RecordingState int state);
+
+ /**
+ * Returns a list of the schedules in the given series.
+ */
+ List<ScheduledRecording> getScheduledRecordings(long seriesRecordingId);
+
+ /**
+ * Returns a list of the schedules from the given input.
+ */
+ List<ScheduledRecording> getScheduledRecordings(String inputId);
+
+ /**
+ * Add a {@link OnDvrScheduleLoadFinishedListener}.
+ */
+ void addDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener);
+
+ /**
+ * Remove a {@link OnDvrScheduleLoadFinishedListener}.
+ */
+ void removeDvrScheduleLoadFinishedListener(OnDvrScheduleLoadFinishedListener listener);
+
+ /**
+ * Add a {@link OnRecordedProgramLoadFinishedListener}.
+ */
+ void addRecordedProgramLoadFinishedListener(OnRecordedProgramLoadFinishedListener listener);
+
+ /**
+ * Remove a {@link OnRecordedProgramLoadFinishedListener}.
+ */
+ void removeRecordedProgramLoadFinishedListener(OnRecordedProgramLoadFinishedListener listener);
/**
* Add a {@link ScheduledRecordingListener}.
@@ -98,12 +160,21 @@ public interface DvrDataManager {
void removeRecordedProgramListener(RecordedProgramListener listener);
/**
+ * Add a {@link ScheduledRecordingListener}.
+ */
+ void addSeriesRecordingListener(SeriesRecordingListener seriesRecordingListener);
+
+ /**
+ * Remove a {@link ScheduledRecordingListener}.
+ */
+ void removeSeriesRecordingListener(SeriesRecordingListener seriesRecordingListener);
+
+ /**
* Returns the scheduled recording program with the given recordingId or null if is not found.
*/
@Nullable
ScheduledRecording getScheduledRecording(long recordingId);
-
/**
* Returns the scheduled recording program with the given programId or null if is not found.
*/
@@ -116,19 +187,78 @@ public interface DvrDataManager {
@Nullable
RecordedProgram getRecordedProgram(long recordingId);
+ /**
+ * Returns the series recording with the given seriesId or null if is not found.
+ */
+ @Nullable
+ SeriesRecording getSeriesRecording(long seriesRecordingId);
+
+ /**
+ * Returns the series recording with the given series ID or {@code null} if not found.
+ */
+ @Nullable
+ SeriesRecording getSeriesRecording(String seriesId);
+
+ /**
+ * Returns the schedules which are marked deleted.
+ */
+ Collection<ScheduledRecording> getDeletedSchedules();
+
+ /**
+ * Returns the program IDs which is not allowed to make a schedule automatically.
+ */
+ @NonNull
+ Collection<Long> getDisallowedProgramIds();
+
+ /**
+ * Listens for the DVR schedules loading finished.
+ */
+ interface OnDvrScheduleLoadFinishedListener {
+ void onDvrScheduleLoadFinished();
+ }
+
+ /**
+ * Listens for the recorded program loading finished.
+ */
+ interface OnRecordedProgramLoadFinishedListener {
+ void onRecordedProgramLoadFinished();
+ }
+
+ /**
+ * Listens for changes to {@link ScheduledRecording}s.
+ */
interface ScheduledRecordingListener {
- void onScheduledRecordingAdded(ScheduledRecording scheduledRecording);
+ void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings);
- void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording);
+ void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings);
- void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording);
+ /**
+ * Called when the schedules are updated.
+ *
+ * <p>Note that the passed arguments are the new objects with the same ID as the old ones.
+ */
+ void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings);
}
+ /**
+ * Listens for changes to {@link SeriesRecording}s.
+ */
+ interface SeriesRecordingListener {
+ void onSeriesRecordingAdded(SeriesRecording... seriesRecordings);
+
+ void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings);
+
+ void onSeriesRecordingChanged(SeriesRecording... seriesRecordings);
+ }
+
+ /**
+ * Listens for changes to {@link RecordedProgram}s.
+ */
interface RecordedProgramListener {
- void onRecordedProgramAdded(RecordedProgram recordedProgram);
+ void onRecordedProgramsAdded(RecordedProgram... recordedPrograms);
- void onRecordedProgramChanged(RecordedProgram recordedProgram);
+ void onRecordedProgramsChanged(RecordedProgram... recordedPrograms);
- void onRecordedProgramRemoved(RecordedProgram recordedProgram);
+ void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms);
}
}
diff --git a/src/com/android/tv/dvr/DvrDataManagerImpl.java b/src/com/android/tv/dvr/DvrDataManagerImpl.java
index 02c47750..46682a48 100644
--- a/src/com/android/tv/dvr/DvrDataManagerImpl.java
+++ b/src/com/android/tv/dvr/DvrDataManagerImpl.java
@@ -16,13 +16,16 @@
package com.android.tv.dvr;
+import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.ContentObserver;
-import android.database.Cursor;
-import android.media.tv.TvContract;
+import android.database.sqlite.SQLiteException;
+import android.media.tv.TvContract.RecordedPrograms;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager.TvInputCallback;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
@@ -31,23 +34,38 @@ import android.os.Looper;
import android.support.annotation.MainThread;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Log;
import android.util.Range;
+import com.android.tv.TvApplication;
import com.android.tv.common.SoftPreconditions;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.dvr.DvrStorageStatusManager.OnStorageMountChangedListener;
import com.android.tv.dvr.ScheduledRecording.RecordingState;
-import com.android.tv.dvr.provider.AsyncDvrDbTask;
-import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQueryTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddScheduleTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncAddSeriesRecordingTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDeleteScheduleTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDeleteSeriesRecordingTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQueryScheduleTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncDvrQuerySeriesRecordingTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncUpdateScheduleTask;
+import com.android.tv.dvr.provider.AsyncDvrDbTask.AsyncUpdateSeriesRecordingTask;
import com.android.tv.util.AsyncDbTask;
+import com.android.tv.util.AsyncDbTask.AsyncRecordedProgramQueryTask;
import com.android.tv.util.Clock;
+import com.android.tv.util.Filter;
+import com.android.tv.util.TvInputManagerHelper;
+import com.android.tv.util.TvProviderUriMatcher;
+import com.android.tv.util.Utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Map.Entry;
import java.util.Set;
/**
@@ -59,90 +77,221 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
private static final String TAG = "DvrDataManagerImpl";
private static final boolean DEBUG = false;
+ private final TvInputManagerHelper mInputManager;
+
private final HashMap<Long, ScheduledRecording> mScheduledRecordings = new HashMap<>();
+ private final HashMap<Long, RecordedProgram> mRecordedPrograms = new HashMap<>();
+ private final HashMap<Long, SeriesRecording> mSeriesRecordings = new HashMap<>();
private final HashMap<Long, ScheduledRecording> mProgramId2ScheduledRecordings =
new HashMap<>();
- private final HashMap<Long, RecordedProgram> mRecordedPrograms = new HashMap<>();
+ private final HashMap<String, SeriesRecording> mSeriesId2SeriesRecordings = new HashMap<>();
- private final Context mContext;
- private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
- private final ContentObserver mContentObserver = new ContentObserver(mMainThreadHandler) {
+ private final HashMap<Long, ScheduledRecording> mScheduledRecordingsForRemovedInput =
+ new HashMap<>();
+ private final HashMap<Long, RecordedProgram> mRecordedProgramsForRemovedInput = new HashMap<>();
+ private final HashMap<Long, SeriesRecording> mSeriesRecordingsForRemovedInput = new HashMap<>();
+ private final Context mContext;
+ private final ContentObserver mContentObserver = new ContentObserver(new Handler(
+ Looper.getMainLooper())) {
@Override
public void onChange(boolean selfChange) {
onChange(selfChange, null);
}
@Override
- public void onChange(boolean selfChange, @Nullable final Uri uri) {
- if (uri == null) {
- // TODO reload everything.
- }
- AsyncRecordedProgramQueryTask task = new AsyncRecordedProgramQueryTask(
+ public void onChange(boolean selfChange, final @Nullable Uri uri) {
+ RecordedProgramsQueryTask task = new RecordedProgramsQueryTask(
mContext.getContentResolver(), uri);
task.executeOnDbThread();
mPendingTasks.add(task);
}
};
- private void onObservedChange(Uri uri, RecordedProgram recordedProgram) {
- long id = ContentUris.parseId(uri);
- if (DEBUG) {
- Log.d(TAG, "changed recorded program #" + id + " to " + recordedProgram);
- }
- if (recordedProgram == null) {
- RecordedProgram old = mRecordedPrograms.remove(id);
- if (old != null) {
- notifyRecordedProgramRemoved(old);
- } else {
- Log.w(TAG, "Could not find old version of deleted program #" + id);
+ private boolean mDvrLoadFinished;
+ private boolean mRecordedProgramLoadFinished;
+ private final Set<AsyncTask> mPendingTasks = new ArraySet<>();
+ private DvrDbSync mDbSync;
+ private DvrStorageStatusManager mStorageStatusManager;
+
+ private final TvInputCallback mInputCallback = new TvInputCallback() {
+ @Override
+ public void onInputAdded(String inputId) {
+ if (DEBUG) Log.d(TAG, "onInputAdded " + inputId);
+ if (!isInputAvailable(inputId)) {
+ if (DEBUG) Log.d(TAG, "Not available for recording");
+ return;
}
- } else {
- RecordedProgram old = mRecordedPrograms.put(id, recordedProgram);
- if (old == null) {
- notifyRecordedProgramAdded(recordedProgram);
- } else {
- notifyRecordedProgramChanged(recordedProgram);
+ unhideInput(inputId);
+ }
+
+ @Override
+ public void onInputRemoved(String inputId) {
+ if (DEBUG) Log.d(TAG, "onInputRemoved " + inputId);
+ hideInput(inputId);
+ }
+ };
+
+ private final OnStorageMountChangedListener mStorageMountChangedListener =
+ new OnStorageMountChangedListener() {
+ @Override
+ public void onStorageMountChanged(boolean storageMounted) {
+ for (TvInputInfo input : mInputManager.getTvInputInfos(true, true)) {
+ if (Utils.isBundledInput(input.getId())) {
+ if (storageMounted) {
+ unhideInput(input.getId());
+ } else {
+ hideInput(input.getId());
+ }
+ }
+ }
+ }
+ };
+
+ private static <T> List<T> moveElements(HashMap<Long, T> from, HashMap<Long, T> to,
+ Filter<T> filter) {
+ List<T> moved = new ArrayList<>();
+ Iterator<Entry<Long, T>> iter = from.entrySet().iterator();
+ while (iter.hasNext()) {
+ Entry<Long, T> entry = iter.next();
+ if (filter.filter(entry.getValue())) {
+ to.put(entry.getKey(), entry.getValue());
+ iter.remove();
+ moved.add(entry.getValue());
}
}
+ return moved;
}
- private boolean mDvrLoadFinished;
- private boolean mRecordedProgramLoadFinished;
- private final Set<AsyncTask> mPendingTasks = new ArraySet<>();
-
public DvrDataManagerImpl(Context context, Clock clock) {
super(context, clock);
mContext = context;
+ mInputManager = TvApplication.getSingletons(context).getTvInputManagerHelper();
+ mStorageStatusManager = TvApplication.getSingletons(context).getDvrStorageStatusManager();
}
public void start() {
- AsyncDvrQueryTask mDvrQueryTask = new AsyncDvrQueryTask(mContext) {
+ mInputManager.addCallback(mInputCallback);
+ mStorageStatusManager.addListener(mStorageMountChangedListener);
+ AsyncDvrQuerySeriesRecordingTask dvrQuerySeriesRecordingTask
+ = new AsyncDvrQuerySeriesRecordingTask(mContext) {
+ @Override
+ protected void onCancelled(List<SeriesRecording> seriesRecordings) {
+ mPendingTasks.remove(this);
+ }
@Override
+ protected void onPostExecute(List<SeriesRecording> seriesRecordings) {
+ mPendingTasks.remove(this);
+ long maxId = 0;
+ HashSet<String> seriesIds = new HashSet<>();
+ for (SeriesRecording r : seriesRecordings) {
+ if (SoftPreconditions.checkState(!seriesIds.contains(r.getSeriesId()), TAG,
+ "Skip loading series recording with duplicate series ID: " + r)) {
+ seriesIds.add(r.getSeriesId());
+ if (isInputAvailable(r.getInputId())) {
+ mSeriesRecordings.put(r.getId(), r);
+ mSeriesId2SeriesRecordings.put(r.getSeriesId(), r);
+ } else {
+ mSeriesRecordingsForRemovedInput.put(r.getId(), r);
+ }
+ }
+ if (maxId < r.getId()) {
+ maxId = r.getId();
+ }
+ }
+ IdGenerator.SERIES_RECORDING.setMaxId(maxId);
+ }
+ };
+ dvrQuerySeriesRecordingTask.executeOnDbThread();
+ mPendingTasks.add(dvrQuerySeriesRecordingTask);
+ AsyncDvrQueryScheduleTask dvrQueryScheduleTask
+ = new AsyncDvrQueryScheduleTask(mContext) {
+ @Override
protected void onCancelled(List<ScheduledRecording> scheduledRecordings) {
mPendingTasks.remove(this);
}
+ @SuppressLint("SwitchIntDef")
@Override
protected void onPostExecute(List<ScheduledRecording> result) {
mPendingTasks.remove(this);
- mDvrLoadFinished = true;
+ long maxId = 0;
+ List<SeriesRecording> seriesRecordingsToAdd = new ArrayList<>();
+ List<ScheduledRecording> toUpdate = new ArrayList<>();
+ List<ScheduledRecording> toDelete = new ArrayList<>();
for (ScheduledRecording r : result) {
- mScheduledRecordings.put(r.getId(), r);
+ if (!isInputAvailable(r.getInputId())) {
+ mScheduledRecordingsForRemovedInput.put(r.getId(), r);
+ } else if (r.getState() == ScheduledRecording.STATE_RECORDING_DELETED) {
+ getDeletedScheduleMap().put(r.getProgramId(), r);
+ } else {
+ mScheduledRecordings.put(r.getId(), r);
+ if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) {
+ mProgramId2ScheduledRecordings.put(r.getProgramId(), r);
+ }
+ // Adjust the state of the schedules before DB loading is finished.
+ switch (r.getState()) {
+ case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
+ if (r.getEndTimeMs() <= mClock.currentTimeMillis()) {
+ toUpdate.add(ScheduledRecording.buildFrom(r)
+ .setState(ScheduledRecording.STATE_RECORDING_FAILED)
+ .build());
+ } else {
+ toUpdate.add(ScheduledRecording.buildFrom(r)
+ .setState(
+ ScheduledRecording.STATE_RECORDING_NOT_STARTED)
+ .build());
+ }
+ break;
+ case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
+ if (r.getEndTimeMs() <= mClock.currentTimeMillis()) {
+ toUpdate.add(ScheduledRecording.buildFrom(r)
+ .setState(ScheduledRecording.STATE_RECORDING_FAILED)
+ .build());
+ }
+ break;
+ case ScheduledRecording.STATE_RECORDING_CANCELED:
+ toDelete.add(r);
+ break;
+ }
+ }
+ if (maxId < r.getId()) {
+ maxId = r.getId();
+ }
+ }
+ if (!toUpdate.isEmpty()) {
+ updateScheduledRecording(ScheduledRecording.toArray(toUpdate));
+ }
+ if (!toDelete.isEmpty()) {
+ removeScheduledRecording(ScheduledRecording.toArray(toDelete));
+ }
+ IdGenerator.SCHEDULED_RECORDING.setMaxId(maxId);
+ mDvrLoadFinished = true;
+ notifyDvrScheduleLoadFinished();
+ mDbSync = new DvrDbSync(mContext, DvrDataManagerImpl.this);
+ mDbSync.start();
+ if (isInitialized()) {
+ SeriesRecordingScheduler.getInstance(mContext).start();
}
}
};
- mDvrQueryTask.executeOnDbThread();
- mPendingTasks.add(mDvrQueryTask);
- AsyncRecordedProgramsQueryTask mRecordedProgramQueryTask =
- new AsyncRecordedProgramsQueryTask(mContext.getContentResolver());
+ dvrQueryScheduleTask.executeOnDbThread();
+ mPendingTasks.add(dvrQueryScheduleTask);
+ RecordedProgramsQueryTask mRecordedProgramQueryTask =
+ new RecordedProgramsQueryTask(mContext.getContentResolver(), null);
mRecordedProgramQueryTask.executeOnDbThread();
ContentResolver cr = mContext.getContentResolver();
- cr.registerContentObserver(TvContract.RecordedPrograms.CONTENT_URI, true, mContentObserver);
+ cr.registerContentObserver(RecordedPrograms.CONTENT_URI, true, mContentObserver);
}
public void stop() {
+ mInputManager.removeCallback(mInputCallback);
+ mStorageStatusManager.removeListener(mStorageMountChangedListener);
+ SeriesRecordingScheduler.getInstance(mContext).stop();
+ if (mDbSync != null) {
+ mDbSync.stop();
+ }
ContentResolver cr = mContext.getContentResolver();
cr.unregisterContentObserver(mContentObserver);
Iterator<AsyncTask> i = mPendingTasks.iterator();
@@ -153,11 +302,104 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
}
}
+ private void onRecordedProgramsLoadedFinished(Uri uri, List<RecordedProgram> recordedPrograms) {
+ if (uri == null) {
+ uri = RecordedPrograms.CONTENT_URI;
+ }
+ int match = TvProviderUriMatcher.match(uri);
+ if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM) {
+ if (!mRecordedProgramLoadFinished) {
+ for (RecordedProgram recorded : recordedPrograms) {
+ if (isInputAvailable(recorded.getInputId())) {
+ mRecordedPrograms.put(recorded.getId(), recorded);
+ } else {
+ mRecordedProgramsForRemovedInput.put(recorded.getId(), recorded);
+ }
+ }
+ mRecordedProgramLoadFinished = true;
+ notifyRecordedProgramLoadFinished();
+ } else if (recordedPrograms == null || recordedPrograms.isEmpty()) {
+ List<RecordedProgram> oldRecordedPrograms =
+ new ArrayList<>(mRecordedPrograms.values());
+ mRecordedPrograms.clear();
+ mRecordedProgramsForRemovedInput.clear();
+ notifyRecordedProgramsRemoved(RecordedProgram.toArray(oldRecordedPrograms));
+ } else {
+ HashMap<Long, RecordedProgram> oldRecordedPrograms
+ = new HashMap<>(mRecordedPrograms);
+ mRecordedPrograms.clear();
+ mRecordedProgramsForRemovedInput.clear();
+ List<RecordedProgram> addedRecordedPrograms = new ArrayList<>();
+ List<RecordedProgram> changedRecordedPrograms = new ArrayList<>();
+ for (RecordedProgram recorded : recordedPrograms) {
+ if (isInputAvailable(recorded.getInputId())) {
+ mRecordedPrograms.put(recorded.getId(), recorded);
+ if (oldRecordedPrograms.remove(recorded.getId()) == null) {
+ addedRecordedPrograms.add(recorded);
+ } else {
+ changedRecordedPrograms.add(recorded);
+ }
+ } else {
+ mRecordedProgramsForRemovedInput.put(recorded.getId(), recorded);
+ }
+ }
+ if (!addedRecordedPrograms.isEmpty()) {
+ notifyRecordedProgramsAdded(RecordedProgram.toArray(addedRecordedPrograms));
+ }
+ if (!changedRecordedPrograms.isEmpty()) {
+ notifyRecordedProgramsChanged(RecordedProgram.toArray(changedRecordedPrograms));
+ }
+ if (!oldRecordedPrograms.isEmpty()) {
+ notifyRecordedProgramsRemoved(
+ RecordedProgram.toArray(oldRecordedPrograms.values()));
+ }
+ }
+ if (isInitialized()) {
+ SeriesRecordingScheduler.getInstance(mContext).start();
+ }
+ } else if (match == TvProviderUriMatcher.MATCH_RECORDED_PROGRAM_ID) {
+ if (!mRecordedProgramLoadFinished) {
+ return;
+ }
+ long id = ContentUris.parseId(uri);
+ if (DEBUG) Log.d(TAG, "changed recorded program #" + id + " to " + recordedPrograms);
+ if (recordedPrograms == null || recordedPrograms.isEmpty()) {
+ mRecordedProgramsForRemovedInput.remove(id);
+ RecordedProgram old = mRecordedPrograms.remove(id);
+ if (old != null) {
+ notifyRecordedProgramsRemoved(old);
+ }
+ } else {
+ RecordedProgram recordedProgram = recordedPrograms.get(0);
+ if (isInputAvailable(recordedProgram.getInputId())) {
+ RecordedProgram old = mRecordedPrograms.put(id, recordedProgram);
+ if (old == null) {
+ notifyRecordedProgramsAdded(recordedProgram);
+ } else {
+ notifyRecordedProgramsChanged(recordedProgram);
+ }
+ } else {
+ mRecordedProgramsForRemovedInput.put(id, recordedProgram);
+ }
+ }
+ }
+ }
+
@Override
public boolean isInitialized() {
return mDvrLoadFinished && mRecordedProgramLoadFinished;
}
+ @Override
+ public boolean isDvrScheduleLoadFinished() {
+ return mDvrLoadFinished;
+ }
+
+ @Override
+ public boolean isRecordedProgramLoadFinished() {
+ return mRecordedProgramLoadFinished;
+ }
+
private List<ScheduledRecording> getScheduledRecordingsPrograms() {
if (!mDvrLoadFinished) {
return Collections.emptyList();
@@ -177,24 +419,50 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
}
@Override
+ public List<RecordedProgram> getRecordedPrograms(long seriesRecordingId) {
+ SeriesRecording seriesRecording = getSeriesRecording(seriesRecordingId);
+ if (!mRecordedProgramLoadFinished || seriesRecording == null) {
+ return Collections.emptyList();
+ }
+ return super.getRecordedPrograms(seriesRecordingId);
+ }
+
+ @Override
public List<ScheduledRecording> getAllScheduledRecordings() {
return new ArrayList<>(mScheduledRecordings.values());
}
- protected List<ScheduledRecording> getRecordingsWithState(@RecordingState int state) {
+ @Override
+ protected List<ScheduledRecording> getRecordingsWithState(@RecordingState int... states) {
List<ScheduledRecording> result = new ArrayList<>();
for (ScheduledRecording r : mScheduledRecordings.values()) {
- if (r.getState() == state) {
- result.add(r);
+ for (int state : states) {
+ if (r.getState() == state) {
+ result.add(r);
+ break;
+ }
}
}
return result;
}
@Override
- public List<SeasonRecording> getSeasonRecordings() {
- // If we return dummy data here, we can implement UI part independently.
- return Collections.emptyList();
+ public List<SeriesRecording> getSeriesRecordings() {
+ if (!mDvrLoadFinished) {
+ return Collections.emptyList();
+ }
+ return new ArrayList<>(mSeriesRecordings.values());
+ }
+
+ @Override
+ public List<SeriesRecording> getSeriesRecordings(String inputId) {
+ List<SeriesRecording> result = new ArrayList<>();
+ for (SeriesRecording r : mSeriesRecordings.values()) {
+ if (TextUtils.equals(r.getInputId(), inputId)) {
+ result.add(r);
+ }
+ }
+ return result;
}
@Override
@@ -219,10 +487,33 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
}
@Override
- public List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period) {
+ public List<ScheduledRecording> getScheduledRecordings(Range<Long> period,
+ @RecordingState int state) {
+ List<ScheduledRecording> result = new ArrayList<>();
+ for (ScheduledRecording r : mScheduledRecordings.values()) {
+ if (r.isOverLapping(period) && r.getState() == state) {
+ result.add(r);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public List<ScheduledRecording> getScheduledRecordings(long seriesRecordingId) {
List<ScheduledRecording> result = new ArrayList<>();
for (ScheduledRecording r : mScheduledRecordings.values()) {
- if (r.isOverLapping(period)) {
+ if (r.getSeriesRecordingId() == seriesRecordingId) {
+ result.add(r);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public List<ScheduledRecording> getScheduledRecordings(String inputId) {
+ List<ScheduledRecording> result = new ArrayList<>();
+ for (ScheduledRecording r : mScheduledRecordings.values()) {
+ if (TextUtils.equals(r.getInputId(), inputId)) {
result.add(r);
}
}
@@ -232,19 +523,13 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
@Nullable
@Override
public ScheduledRecording getScheduledRecording(long recordingId) {
- if (mDvrLoadFinished) {
- return mScheduledRecordings.get(recordingId);
- }
- return null;
+ return mScheduledRecordings.get(recordingId);
}
@Nullable
@Override
public ScheduledRecording getScheduledRecordingForProgramId(long programId) {
- if (mDvrLoadFinished) {
- return mProgramId2ScheduledRecordings.get(programId);
- }
- return null;
+ return mProgramId2ScheduledRecordings.get(programId);
}
@Nullable
@@ -253,151 +538,386 @@ public class DvrDataManagerImpl extends BaseDvrDataManager {
return mRecordedPrograms.get(recordingId);
}
+ @Nullable
@Override
- public void addScheduledRecording(final ScheduledRecording scheduledRecording) {
- new AsyncDvrDbTask.AsyncAddRecordingTask(mContext) {
- @Override
- protected void onPostExecute(List<ScheduledRecording> scheduledRecordings) {
- super.onPostExecute(scheduledRecordings);
- SoftPreconditions.checkArgument(scheduledRecordings.size() == 1);
- for (ScheduledRecording r : scheduledRecordings) {
- if (r.getId() != -1) {
- mScheduledRecordings.put(r.getId(), r);
- if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) {
- mProgramId2ScheduledRecordings.put(r.getProgramId(), r);
- }
- notifyScheduledRecordingAdded(r);
- } else {
- Log.w(TAG, "Error adding " + r);
- }
- }
+ public SeriesRecording getSeriesRecording(long seriesRecordingId) {
+ return mSeriesRecordings.get(seriesRecordingId);
+ }
+ @Nullable
+ @Override
+ public SeriesRecording getSeriesRecording(String seriesId) {
+ return mSeriesId2SeriesRecordings.get(seriesId);
+ }
+
+ @Override
+ public void addScheduledRecording(ScheduledRecording... schedules) {
+ for (ScheduledRecording r : schedules) {
+ if (r.getId() == ScheduledRecording.ID_NOT_SET) {
+ r.setId(IdGenerator.SCHEDULED_RECORDING.newId());
}
- }.executeOnDbThread(scheduledRecording);
+ mScheduledRecordings.put(r.getId(), r);
+ if (r.getProgramId() != ScheduledRecording.ID_NOT_SET) {
+ mProgramId2ScheduledRecordings.put(r.getProgramId(), r);
+ }
+ }
+ if (mDvrLoadFinished) {
+ notifyScheduledRecordingAdded(schedules);
+ }
+ new AsyncAddScheduleTask(mContext).executeOnDbThread(schedules);
+ removeDeletedSchedules(schedules);
}
@Override
- public void addSeasonRecording(SeasonRecording seasonRecording) { }
+ public void addSeriesRecording(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording r : seriesRecordings) {
+ r.setId(IdGenerator.SERIES_RECORDING.newId());
+ mSeriesRecordings.put(r.getId(), r);
+ SeriesRecording previousSeries = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r);
+ SoftPreconditions.checkArgument(previousSeries == null, TAG, "Attempt to add series"
+ + " recording with the duplicate series ID: " + r.getSeriesId());
+ }
+ if (mDvrLoadFinished) {
+ notifySeriesRecordingAdded(seriesRecordings);
+ }
+ new AsyncAddSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings);
+ }
@Override
- public void removeScheduledRecording(final ScheduledRecording scheduledRecording) {
- new AsyncDvrDbTask.AsyncDeleteRecordingTask(mContext) {
- @Override
- protected void onPostExecute(List<Integer> counts) {
- super.onPostExecute(counts);
- SoftPreconditions.checkArgument(counts.size() == 1);
- for (Integer c : counts) {
- if (c == 1) {
- mScheduledRecordings.remove(scheduledRecording.getId());
- if (scheduledRecording.getProgramId() != ScheduledRecording.ID_NOT_SET) {
- mProgramId2ScheduledRecordings
- .remove(scheduledRecording.getProgramId());
- }
- //TODO change to notifyRecordingUpdated
- notifyScheduledRecordingRemoved(scheduledRecording);
- } else {
- Log.w(TAG, "Error removing " + scheduledRecording);
- }
- }
+ public void removeScheduledRecording(ScheduledRecording... schedules) {
+ removeScheduledRecording(false, schedules);
+ }
+ @Override
+ public void removeScheduledRecording(boolean forceRemove, ScheduledRecording... schedules) {
+ List<ScheduledRecording> schedulesToDelete = new ArrayList<>();
+ List<ScheduledRecording> schedulesNotToDelete = new ArrayList<>();
+ for (ScheduledRecording r : schedules) {
+ mScheduledRecordings.remove(r.getId());
+ getDeletedScheduleMap().remove(r.getId());
+ mProgramId2ScheduledRecordings.remove(r.getProgramId());
+ boolean isScheduleForRemovedInput =
+ mScheduledRecordingsForRemovedInput.remove(r.getProgramId()) != null;
+ // If it belongs to the series recording and it's not started yet, just mark delete
+ // instead of deleting it.
+ if (!isScheduleForRemovedInput && !forceRemove
+ && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET
+ && (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
+ || r.getState() == ScheduledRecording.STATE_RECORDING_CANCELED)) {
+ SoftPreconditions.checkState(r.getProgramId() != ScheduledRecording.ID_NOT_SET);
+ ScheduledRecording deleted = ScheduledRecording.buildFrom(r)
+ .setState(ScheduledRecording.STATE_RECORDING_DELETED).build();
+ getDeletedScheduleMap().put(deleted.getProgramId(), deleted);
+ schedulesNotToDelete.add(deleted);
+ } else {
+ schedulesToDelete.add(r);
}
- }.executeOnDbThread(scheduledRecording);
+ }
+ if (mDvrLoadFinished) {
+ notifyScheduledRecordingRemoved(schedules);
+ }
+ if (!schedulesToDelete.isEmpty()) {
+ new AsyncDeleteScheduleTask(mContext).executeOnDbThread(
+ ScheduledRecording.toArray(schedulesToDelete));
+ }
+ if (!schedulesNotToDelete.isEmpty()) {
+ new AsyncUpdateScheduleTask(mContext).executeOnDbThread(
+ ScheduledRecording.toArray(schedulesNotToDelete));
+ }
}
@Override
- public void removeSeasonSchedule(SeasonRecording seasonSchedule) { }
+ public void removeSeriesRecording(final SeriesRecording... seriesRecordings) {
+ HashSet<Long> ids = new HashSet<>();
+ for (SeriesRecording r : seriesRecordings) {
+ mSeriesRecordings.remove(r.getId());
+ mSeriesId2SeriesRecordings.remove(r.getSeriesId());
+ ids.add(r.getId());
+ }
+ // Reset series recording ID of the scheduled recording.
+ List<ScheduledRecording> toUpdate = new ArrayList<>();
+ List<ScheduledRecording> toDelete = new ArrayList<>();
+ for (ScheduledRecording r : mScheduledRecordings.values()) {
+ if (ids.contains(r.getSeriesRecordingId())) {
+ if (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ toDelete.add(r);
+ } else {
+ toUpdate.add(ScheduledRecording.buildFrom(r)
+ .setSeriesRecordingId(SeriesRecording.ID_NOT_SET).build());
+ }
+ }
+ }
+ if (!toUpdate.isEmpty()) {
+ // No need to update DB. It's handled in database automatically when the series
+ // recording is deleted.
+ updateScheduledRecording(false, ScheduledRecording.toArray(toUpdate));
+ }
+ if (!toDelete.isEmpty()) {
+ removeScheduledRecording(true, ScheduledRecording.toArray(toDelete));
+ }
+ if (mDvrLoadFinished) {
+ notifySeriesRecordingRemoved(seriesRecordings);
+ }
+ new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings);
+ removeDeletedSchedules(seriesRecordings);
+ }
@Override
- public void updateScheduledRecording(final ScheduledRecording scheduledRecording) {
- new AsyncDvrDbTask.AsyncUpdateRecordingTask(mContext) {
- @Override
- protected void onPostExecute(List<Integer> counts) {
- super.onPostExecute(counts);
- SoftPreconditions.checkArgument(counts.size() == 1);
- for (Integer c : counts) {
- if (c == 1) {
- ScheduledRecording oldScheduledRecording = mScheduledRecordings
- .put(scheduledRecording.getId(), scheduledRecording);
- long programId = scheduledRecording.getProgramId();
- if (oldScheduledRecording != null
- && oldScheduledRecording.getProgramId() != programId
- && oldScheduledRecording.getProgramId()
- != ScheduledRecording.ID_NOT_SET) {
- ScheduledRecording oldValueForProgramId = mProgramId2ScheduledRecordings
- .get(oldScheduledRecording.getProgramId());
- if (oldValueForProgramId.getId() == scheduledRecording.getId()) {
- //Only remove the old ScheduledRecording if it has the same ID as
- // the new one.
- mProgramId2ScheduledRecordings
- .remove(oldScheduledRecording.getProgramId());
- }
- }
- if (programId != ScheduledRecording.ID_NOT_SET) {
- mProgramId2ScheduledRecordings.put(programId, scheduledRecording);
- }
- //TODO change to notifyRecordingUpdated
- notifyScheduledRecordingStatusChanged(scheduledRecording);
- } else {
- Log.w(TAG, "Error updating " + scheduledRecording);
- }
+ public void updateScheduledRecording(final ScheduledRecording... schedules) {
+ updateScheduledRecording(true, schedules);
+ }
+
+ private void updateScheduledRecording(boolean updateDb, final ScheduledRecording... schedules) {
+ List<ScheduledRecording> toUpdate = new ArrayList<>();
+ for (ScheduledRecording r : schedules) {
+ if (!SoftPreconditions.checkState(mScheduledRecordings.containsKey(r.getId()), TAG,
+ "Recording not found for: " + r)) {
+ continue;
+ }
+ toUpdate.add(r);
+ ScheduledRecording oldScheduledRecording = mScheduledRecordings.put(r.getId(), r);
+ // The channel ID should not be changed.
+ SoftPreconditions.checkState(r.getChannelId() == oldScheduledRecording.getChannelId());
+ long programId = r.getProgramId();
+ if (oldScheduledRecording.getProgramId() != programId
+ && oldScheduledRecording.getProgramId() != ScheduledRecording.ID_NOT_SET) {
+ ScheduledRecording oldValueForProgramId = mProgramId2ScheduledRecordings
+ .get(oldScheduledRecording.getProgramId());
+ if (oldValueForProgramId.getId() == r.getId()) {
+ // Only remove the old ScheduledRecording if it has the same ID as the new one.
+ mProgramId2ScheduledRecordings.remove(oldScheduledRecording.getProgramId());
}
}
- }.executeOnDbThread(scheduledRecording);
+ if (programId != ScheduledRecording.ID_NOT_SET) {
+ mProgramId2ScheduledRecordings.put(programId, r);
+ }
+ }
+ if (toUpdate.isEmpty()) {
+ return;
+ }
+ ScheduledRecording[] scheduleArray = ScheduledRecording.toArray(toUpdate);
+ if (mDvrLoadFinished) {
+ notifyScheduledRecordingStatusChanged(scheduleArray);
+ }
+ if (updateDb) {
+ new AsyncUpdateScheduleTask(mContext).executeOnDbThread(scheduleArray);
+ }
+ removeDeletedSchedules(schedules);
}
- private final class AsyncRecordedProgramsQueryTask
- extends AsyncDbTask.AsyncQueryListTask<RecordedProgram> {
- public AsyncRecordedProgramsQueryTask(ContentResolver contentResolver) {
- super(contentResolver, TvContract.RecordedPrograms.CONTENT_URI,
- RecordedProgram.PROJECTION, null, null, null);
+ @Override
+ public void updateSeriesRecording(final SeriesRecording... seriesRecordings) {
+ for (SeriesRecording r : seriesRecordings) {
+ SeriesRecording old1 = mSeriesRecordings.put(r.getId(), r);
+ SeriesRecording old2 = mSeriesId2SeriesRecordings.put(r.getSeriesId(), r);
+ SoftPreconditions.checkArgument(old1.equals(old2), TAG, "Series ID cannot be"
+ + " updated: " + r);
}
+ if (mDvrLoadFinished) {
+ notifySeriesRecordingChanged(seriesRecordings);
+ }
+ new AsyncUpdateSeriesRecordingTask(mContext).executeOnDbThread(seriesRecordings);
+ }
- @Override
- protected RecordedProgram fromCursor(Cursor c) {
- return RecordedProgram.fromCursor(c);
+ private boolean isInputAvailable(String inputId) {
+ return mInputManager.hasTvInputInfo(inputId)
+ && (!Utils.isBundledInput(inputId) || mStorageStatusManager.isStorageMounted());
+ }
+
+ private void removeDeletedSchedules(ScheduledRecording... addedSchedules) {
+ List<ScheduledRecording> schedulesToDelete = new ArrayList<>();
+ for (ScheduledRecording r : addedSchedules) {
+ ScheduledRecording deleted = getDeletedScheduleMap().remove(r.getProgramId());
+ if (deleted != null) {
+ schedulesToDelete.add(deleted);
+ }
+ }
+ if (!schedulesToDelete.isEmpty()) {
+ new AsyncDeleteScheduleTask(mContext).executeOnDbThread(
+ ScheduledRecording.toArray(schedulesToDelete));
}
+ }
- @Override
- protected void onCancelled(List<RecordedProgram> scheduledRecordings) {
- mPendingTasks.remove(this);
+ private void removeDeletedSchedules(SeriesRecording... removedSeriesRecordings) {
+ Set<Long> seriesRecordingIds = new HashSet<>();
+ for (SeriesRecording r : removedSeriesRecordings) {
+ seriesRecordingIds.add(r.getId());
+ }
+ List<ScheduledRecording> schedulesToDelete = new ArrayList<>();
+ Iterator<Entry<Long, ScheduledRecording>> iter =
+ getDeletedScheduleMap().entrySet().iterator();
+ while (iter.hasNext()) {
+ Entry<Long, ScheduledRecording> entry = iter.next();
+ if (seriesRecordingIds.contains(entry.getValue().getSeriesRecordingId())) {
+ schedulesToDelete.add(entry.getValue());
+ iter.remove();
+ }
+ }
+ if (!schedulesToDelete.isEmpty()) {
+ new AsyncDeleteScheduleTask(mContext).executeOnDbThread(
+ ScheduledRecording.toArray(schedulesToDelete));
}
+ }
- @Override
- protected void onPostExecute(List<RecordedProgram> result) {
- mPendingTasks.remove(this);
- mRecordedProgramLoadFinished = true;
- if (result != null) {
- for (RecordedProgram r : result) {
- mRecordedPrograms.put(r.getId(), r);
- }
+ private void unhideInput(String inputId) {
+ if (DEBUG) Log.d(TAG, "unhideInput " + inputId);
+ List<ScheduledRecording> movedSchedules =
+ moveElements(mScheduledRecordingsForRemovedInput, mScheduledRecordings,
+ new Filter<ScheduledRecording>() {
+ @Override
+ public boolean filter(ScheduledRecording r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ List<SeriesRecording> movedSeriesRecordings =
+ moveElements(mSeriesRecordingsForRemovedInput, mSeriesRecordings,
+ new Filter<SeriesRecording>() {
+ @Override
+ public boolean filter(SeriesRecording r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ List<RecordedProgram> movedRecordedPrograms =
+ moveElements(mRecordedProgramsForRemovedInput, mRecordedPrograms,
+ new Filter<RecordedProgram>() {
+ @Override
+ public boolean filter(RecordedProgram r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ if (!movedSchedules.isEmpty()) {
+ for (ScheduledRecording schedule : movedSchedules) {
+ mProgramId2ScheduledRecordings.put(schedule.getProgramId(), schedule);
+ }
+ }
+ if (!movedSeriesRecordings.isEmpty()) {
+ for (SeriesRecording seriesRecording : movedSeriesRecordings) {
+ mSeriesId2SeriesRecordings.put(seriesRecording.getSeriesId(), seriesRecording);
+ }
+ }
+ // Notify after all the data are moved.
+ if (!movedSchedules.isEmpty()) {
+ notifyScheduledRecordingAdded(ScheduledRecording.toArray(movedSchedules));
+ }
+ if (!movedSeriesRecordings.isEmpty()) {
+ notifySeriesRecordingAdded(SeriesRecording.toArray(movedSeriesRecordings));
+ }
+ if (!movedRecordedPrograms.isEmpty()) {
+ notifyRecordedProgramsAdded(RecordedProgram.toArray(movedRecordedPrograms));
+ }
+ }
+
+ private void hideInput(String inputId) {
+ if (DEBUG) Log.d(TAG, "hideInput " + inputId);
+ List<ScheduledRecording> movedSchedules =
+ moveElements(mScheduledRecordings, mScheduledRecordingsForRemovedInput,
+ new Filter<ScheduledRecording>() {
+ @Override
+ public boolean filter(ScheduledRecording r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ List<SeriesRecording> movedSeriesRecordings =
+ moveElements(mSeriesRecordings, mSeriesRecordingsForRemovedInput,
+ new Filter<SeriesRecording>() {
+ @Override
+ public boolean filter(SeriesRecording r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ List<RecordedProgram> movedRecordedPrograms =
+ moveElements(mRecordedPrograms, mRecordedProgramsForRemovedInput,
+ new Filter<RecordedProgram>() {
+ @Override
+ public boolean filter(RecordedProgram r) {
+ return r.getInputId().equals(inputId);
+ }
+ });
+ if (!movedSchedules.isEmpty()) {
+ for (ScheduledRecording schedule : movedSchedules) {
+ mProgramId2ScheduledRecordings.remove(schedule.getProgramId());
+ }
+ }
+ if (!movedSeriesRecordings.isEmpty()) {
+ for (SeriesRecording seriesRecording : movedSeriesRecordings) {
+ mSeriesId2SeriesRecordings.remove(seriesRecording.getSeriesId());
}
}
+ // Notify after all the data are moved.
+ if (!movedSchedules.isEmpty()) {
+ notifyScheduledRecordingRemoved(ScheduledRecording.toArray(movedSchedules));
+ }
+ if (!movedSeriesRecordings.isEmpty()) {
+ notifySeriesRecordingRemoved(SeriesRecording.toArray(movedSeriesRecordings));
+ }
+ if (!movedRecordedPrograms.isEmpty()) {
+ notifyRecordedProgramsRemoved(RecordedProgram.toArray(movedRecordedPrograms));
+ }
}
- private final class AsyncRecordedProgramQueryTask
- extends AsyncDbTask.AsyncQueryItemTask<RecordedProgram> {
+ @Override
+ public void forgetStorage(String inputId) {
+ List<ScheduledRecording> schedulesToDelete = new ArrayList<>();
+ for (Iterator<ScheduledRecording> i =
+ mScheduledRecordingsForRemovedInput.values().iterator(); i.hasNext(); ) {
+ ScheduledRecording r = i.next();
+ if (inputId.equals(r.getInputId())) {
+ schedulesToDelete.add(r);
+ i.remove();
+ }
+ }
+ List<SeriesRecording> seriesRecordingsToDelete = new ArrayList<>();
+ for (Iterator<SeriesRecording> i =
+ mSeriesRecordingsForRemovedInput.values().iterator(); i.hasNext(); ) {
+ SeriesRecording r = i.next();
+ if (inputId.equals(r.getInputId())) {
+ seriesRecordingsToDelete.add(r);
+ i.remove();
+ }
+ }
+ for (Iterator<RecordedProgram> i =
+ mRecordedProgramsForRemovedInput.values().iterator(); i.hasNext(); ) {
+ if (inputId.equals(i.next().getInputId())) {
+ i.remove();
+ }
+ }
+ new AsyncDeleteScheduleTask(mContext).executeOnDbThread(
+ ScheduledRecording.toArray(schedulesToDelete));
+ new AsyncDeleteSeriesRecordingTask(mContext).executeOnDbThread(
+ SeriesRecording.toArray(seriesRecordingsToDelete));
+ new AsyncDbTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ ContentResolver resolver = mContext.getContentResolver();
+ String args[] = { inputId };
+ try {
+ resolver.delete(RecordedPrograms.CONTENT_URI,
+ RecordedPrograms.COLUMN_INPUT_ID + " = ?", args);
+ } catch (SQLiteException e) {
+ Log.e(TAG, "Failed to delete recorded programs for inputId: " + inputId, e);
+ }
+ return null;
+ }
+ }.executeOnDbThread();
+ }
+ private final class RecordedProgramsQueryTask extends AsyncRecordedProgramQueryTask {
private final Uri mUri;
- public AsyncRecordedProgramQueryTask(ContentResolver contentResolver, Uri uri) {
- super(contentResolver, uri, RecordedProgram.PROJECTION, null, null, null);
+ public RecordedProgramsQueryTask(ContentResolver contentResolver, Uri uri) {
+ super(contentResolver, uri == null ? RecordedPrograms.CONTENT_URI : uri);
mUri = uri;
}
@Override
- protected RecordedProgram fromCursor(Cursor c) {
- return RecordedProgram.fromCursor(c);
- }
-
- @Override
- protected void onCancelled(RecordedProgram recordedProgram) {
+ protected void onCancelled(List<RecordedProgram> scheduledRecordings) {
mPendingTasks.remove(this);
}
@Override
- protected void onPostExecute(RecordedProgram recordedProgram) {
+ protected void onPostExecute(List<RecordedProgram> result) {
mPendingTasks.remove(this);
- onObservedChange(mUri, recordedProgram);
+ onRecordedProgramsLoadedFinished(mUri, result);
}
}
}
diff --git a/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java b/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java
deleted file mode 100644
index 95b342bb..00000000
--- a/src/com/android/tv/dvr/DvrDataManagerInMemoryImpl.java
+++ /dev/null
@@ -1,215 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License
- */
-
-package com.android.tv.dvr;
-
-import android.content.Context;
-import android.support.annotation.MainThread;
-import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
-import android.support.annotation.VisibleForTesting;
-import android.util.Range;
-
-import com.android.tv.common.SoftPreconditions;
-import com.android.tv.common.recording.RecordedProgram;
-import com.android.tv.util.Clock;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.atomic.AtomicLong;
-
-/**
- * A DVR Data manager that stores values in memory suitable for testing.
- */
-@VisibleForTesting // TODO(DVR): move to testing dir.
-@MainThread
-public final class DvrDataManagerInMemoryImpl extends BaseDvrDataManager {
- private final static String TAG = "DvrDataManagerInMemory";
- private final AtomicLong mNextId = new AtomicLong(1);
- private final Map<Long, ScheduledRecording> mScheduledRecordings = new HashMap<>();
- private final Map<Long, RecordedProgram> mRecordedPrograms = new HashMap<>();
- private final List<SeasonRecording> mSeasonSchedule = new ArrayList<>();
-
- public DvrDataManagerInMemoryImpl(Context context, Clock clock) {
- super(context, clock);
- }
-
- @Override
- public boolean isInitialized() {
- return true;
- }
-
- private List<ScheduledRecording> getScheduledRecordingsPrograms() {
- return new ArrayList(mScheduledRecordings.values());
- }
-
- @Override
- public List<RecordedProgram> getRecordedPrograms() {
- return new ArrayList<>(mRecordedPrograms.values());
- }
-
- @Override
- public List<ScheduledRecording> getAllScheduledRecordings() {
- return new ArrayList<>(mScheduledRecordings.values());
- }
-
- public List<SeasonRecording> getSeasonRecordings() {
- return mSeasonSchedule;
- }
-
- @Override
- public long getNextScheduledStartTimeAfter(long startTime) {
-
- List<ScheduledRecording> temp = getNonStartedScheduledRecordings();
- Collections.sort(temp, ScheduledRecording.START_TIME_COMPARATOR);
- for (ScheduledRecording r : temp) {
- if (r.getStartTimeMs() > startTime) {
- return r.getStartTimeMs();
- }
- }
- return DvrDataManager.NEXT_START_TIME_NOT_FOUND;
- }
-
- @Override
- public List<ScheduledRecording> getRecordingsThatOverlapWith(Range<Long> period) {
- List<ScheduledRecording> temp = getScheduledRecordingsPrograms();
- List<ScheduledRecording> result = new ArrayList<>();
- for (ScheduledRecording r : temp) {
- if (r.isOverLapping(period)) {
- result.add(r);
- }
- }
- return result;
- }
-
- /**
- * Add a new scheduled recording.
- */
- @Override
- public void addScheduledRecording(ScheduledRecording scheduledRecording) {
- addScheduledRecordingInternal(scheduledRecording);
- }
-
-
- public void addRecordedProgram(RecordedProgram recordedProgram) {
- addRecordedProgramInternal(recordedProgram);
- }
-
- public void updateRecordedProgram(RecordedProgram r) {
- long id = r.getId();
- if (mRecordedPrograms.containsKey(id)) {
- mRecordedPrograms.put(id, r);
- notifyRecordedProgramChanged(r);
- } else {
- throw new IllegalArgumentException("Recording not found:" + r);
- }
- }
-
- public void removeRecordedProgram(RecordedProgram scheduledRecording) {
- mRecordedPrograms.remove(scheduledRecording.getId());
- notifyRecordedProgramRemoved(scheduledRecording);
- }
-
-
- public ScheduledRecording addScheduledRecordingInternal(ScheduledRecording scheduledRecording) {
- SoftPreconditions
- .checkState(scheduledRecording.getId() == ScheduledRecording.ID_NOT_SET, TAG,
- "expected id of " + ScheduledRecording.ID_NOT_SET + " but was "
- + scheduledRecording);
- scheduledRecording = ScheduledRecording.buildFrom(scheduledRecording)
- .setId(mNextId.incrementAndGet())
- .build();
- mScheduledRecordings.put(scheduledRecording.getId(), scheduledRecording);
- notifyScheduledRecordingAdded(scheduledRecording);
- return scheduledRecording;
- }
-
- public RecordedProgram addRecordedProgramInternal(RecordedProgram recordedProgram) {
- SoftPreconditions.checkState(recordedProgram.getId() == RecordedProgram.ID_NOT_SET, TAG,
- "expected id of " + RecordedProgram.ID_NOT_SET + " but was " + recordedProgram);
- recordedProgram = RecordedProgram.buildFrom(recordedProgram)
- .setId(mNextId.incrementAndGet())
- .build();
- mRecordedPrograms.put(recordedProgram.getId(), recordedProgram);
- notifyRecordedProgramAdded(recordedProgram);
- return recordedProgram;
- }
-
- @Override
- public void addSeasonRecording(SeasonRecording seasonRecording) {
- mSeasonSchedule.add(seasonRecording);
- }
-
- @Override
- public void removeScheduledRecording(ScheduledRecording scheduledRecording) {
- mScheduledRecordings.remove(scheduledRecording.getId());
- notifyScheduledRecordingRemoved(scheduledRecording);
- }
-
- @Override
- public void removeSeasonSchedule(SeasonRecording seasonSchedule) {
- mSeasonSchedule.remove(seasonSchedule);
- }
-
- @Override
- public void updateScheduledRecording(ScheduledRecording r) {
- long id = r.getId();
- if (mScheduledRecordings.containsKey(id)) {
- mScheduledRecordings.put(id, r);
- notifyScheduledRecordingStatusChanged(r);
- } else {
- throw new IllegalArgumentException("Recording not found:" + r);
- }
- }
-
- @Nullable
- @Override
- public ScheduledRecording getScheduledRecording(long id) {
- return mScheduledRecordings.get(id);
- }
-
- @Nullable
- @Override
- public ScheduledRecording getScheduledRecordingForProgramId(long programId) {
- for (ScheduledRecording r : mScheduledRecordings.values()) {
- if (r.getProgramId() == programId) {
- return r;
- }
- }
- return null;
- }
-
- @Nullable
- @Override
- public RecordedProgram getRecordedProgram(long recordingId) {
- return mRecordedPrograms.get(recordingId);
- }
-
- @Override
- @NonNull
- protected List<ScheduledRecording> getRecordingsWithState(int state) {
- ArrayList<ScheduledRecording> result = new ArrayList<>();
- for (ScheduledRecording r : mScheduledRecordings.values()) {
- if(r.getState() == state){
- result.add(r);
- }
- }
- return result;
- }
-}
diff --git a/src/com/android/tv/dvr/DvrDbSync.java b/src/com/android/tv/dvr/DvrDbSync.java
new file mode 100644
index 00000000..df181455
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrDbSync.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.media.tv.TvContract.Programs;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.MainThread;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import com.android.tv.TvApplication;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.util.AsyncDbTask.AsyncQueryProgramTask;
+import com.android.tv.util.TvProviderUriMatcher;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.Set;
+
+/**
+ * A class to synchronizes DVR DB with TvProvider.
+ *
+ * <p>The current implementation of AsyncDbTask allows only one task to run at a time, and all the
+ * other tasks are blocked until the current one finishes. As this class performs the low priority
+ * jobs which take long time, it should not block others if possible. For this reason, only one
+ * program is queried at a time and others are queued and will be executed on the other
+ * AsyncDbTask's after the current one finishes to minimize the execution time of one AsyncDbTask.
+ */
+@MainThread
+@TargetApi(Build.VERSION_CODES.N)
+class DvrDbSync {
+ private static final String TAG = "DvrDbSync";
+ private static final boolean DEBUG = false;
+
+ private final Context mContext;
+ private final DvrDataManagerImpl mDataManager;
+ private final ChannelDataManager mChannelDataManager;
+ private final Queue<Long> mProgramIdQueue = new LinkedList<>();
+ private QueryProgramTask mQueryProgramTask;
+ private final SeriesRecordingScheduler mSeriesRecordingScheduler;
+ private final ContentObserver mContentObserver = new ContentObserver(new Handler(
+ Looper.getMainLooper())) {
+ @SuppressLint("SwitchIntDef")
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ switch (TvProviderUriMatcher.match(uri)) {
+ case TvProviderUriMatcher.MATCH_PROGRAM:
+ if (DEBUG) Log.d(TAG, "onProgramsUpdated");
+ onProgramsUpdated();
+ break;
+ case TvProviderUriMatcher.MATCH_PROGRAM_ID:
+ if (DEBUG) {
+ Log.d(TAG, "onProgramUpdated: programId=" + ContentUris.parseId(uri));
+ }
+ onProgramUpdated(ContentUris.parseId(uri));
+ break;
+ }
+ }
+ };
+
+ private final ChannelDataManager.Listener mChannelDataManagerListener =
+ new ChannelDataManager.Listener() {
+ @Override
+ public void onLoadFinished() {
+ start();
+ }
+
+ @Override
+ public void onChannelListUpdated() {
+ onChannelsUpdated();
+ }
+
+ @Override
+ public void onChannelBrowsableChanged() { }
+ };
+
+ private final ScheduledRecordingListener mScheduleListener = new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ addProgramIdToCheckIfNeeded(schedule);
+ }
+ startNextUpdateIfNeeded();
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ mProgramIdQueue.remove(schedule.getProgramId());
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ mProgramIdQueue.remove(schedule.getProgramId());
+ addProgramIdToCheckIfNeeded(schedule);
+ }
+ startNextUpdateIfNeeded();
+ }
+ };
+
+ DvrDbSync(Context context, DvrDataManagerImpl dataManager) {
+ this(context, dataManager, TvApplication.getSingletons(context).getChannelDataManager());
+ }
+
+ @VisibleForTesting
+ DvrDbSync(Context context, DvrDataManagerImpl dataManager,
+ ChannelDataManager channelDataManager) {
+ mContext = context;
+ mDataManager = dataManager;
+ mChannelDataManager = channelDataManager;
+ mSeriesRecordingScheduler = SeriesRecordingScheduler.getInstance(context);
+ }
+
+ /**
+ * Starts the DB sync.
+ */
+ public void start() {
+ if (!mChannelDataManager.isDbLoadFinished()) {
+ mChannelDataManager.addListener(mChannelDataManagerListener);
+ return;
+ }
+ mContext.getContentResolver().registerContentObserver(Programs.CONTENT_URI, true,
+ mContentObserver);
+ mDataManager.addScheduledRecordingListener(mScheduleListener);
+ onChannelsUpdated();
+ onProgramsUpdated();
+ }
+
+ /**
+ * Stops the DB sync.
+ */
+ public void stop() {
+ mProgramIdQueue.clear();
+ if (mQueryProgramTask != null) {
+ mQueryProgramTask.cancel(true);
+ }
+ mChannelDataManager.removeListener(mChannelDataManagerListener);
+ mDataManager.removeScheduledRecordingListener(mScheduleListener);
+ mContext.getContentResolver().unregisterContentObserver(mContentObserver);
+ }
+
+ private void onChannelsUpdated() {
+ List<SeriesRecording> seriesRecordingsToUpdate = new ArrayList<>();
+ for (SeriesRecording r : mDataManager.getSeriesRecordings()) {
+ if (r.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE
+ && !mChannelDataManager.doesChannelExistInDb(r.getChannelId())) {
+ seriesRecordingsToUpdate.add(SeriesRecording.buildFrom(r)
+ .setChannelOption(SeriesRecording.OPTION_CHANNEL_ALL)
+ .setState(SeriesRecording.STATE_SERIES_STOPPED).build());
+ }
+ }
+ if (!seriesRecordingsToUpdate.isEmpty()) {
+ mDataManager.updateSeriesRecording(
+ SeriesRecording.toArray(seriesRecordingsToUpdate));
+ }
+ List<ScheduledRecording> schedulesToRemove = new ArrayList<>();
+ for (ScheduledRecording r : mDataManager.getAvailableScheduledRecordings()) {
+ if (!mChannelDataManager.doesChannelExistInDb(r.getChannelId())) {
+ schedulesToRemove.add(r);
+ mProgramIdQueue.remove(r.getProgramId());
+ }
+ }
+ if (!schedulesToRemove.isEmpty()) {
+ mDataManager.removeScheduledRecording(
+ ScheduledRecording.toArray(schedulesToRemove));
+ }
+ }
+
+ private void onProgramsUpdated() {
+ for (ScheduledRecording schedule : mDataManager.getAvailableScheduledRecordings()) {
+ addProgramIdToCheckIfNeeded(schedule);
+ }
+ startNextUpdateIfNeeded();
+ }
+
+ private void onProgramUpdated(long programId) {
+ addProgramIdToCheckIfNeeded(mDataManager.getScheduledRecordingForProgramId(programId));
+ startNextUpdateIfNeeded();
+ }
+
+ private void addProgramIdToCheckIfNeeded(ScheduledRecording schedule) {
+ if (schedule == null) {
+ return;
+ }
+ long programId = schedule.getProgramId();
+ if (programId != ScheduledRecording.ID_NOT_SET
+ && !mProgramIdQueue.contains(programId)
+ && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) {
+ if (DEBUG) Log.d(TAG, "Program ID enqueued: " + programId);
+ mProgramIdQueue.offer(programId);
+ // There are schedules to be updated. Pause the SeriesRecordingScheduler until all the
+ // schedule updates finish.
+ // Note that the SeriesRecordingScheduler should be paused even though the program to
+ // check is not episodic because it can be changed to the episodic program after the
+ // update, which affect the SeriesRecordingScheduler.
+ mSeriesRecordingScheduler.pauseUpdate();
+ }
+ }
+
+ private void startNextUpdateIfNeeded() {
+ if (mQueryProgramTask != null && !mQueryProgramTask.isCancelled()) {
+ return;
+ }
+ if (!mProgramIdQueue.isEmpty()) {
+ if (DEBUG) Log.d(TAG, "Program ID dequeued: " + mProgramIdQueue.peek());
+ mQueryProgramTask = new QueryProgramTask(mProgramIdQueue.poll());
+ mQueryProgramTask.executeOnDbThread();
+ } else {
+ mSeriesRecordingScheduler.resumeUpdate();
+ }
+ }
+
+ @VisibleForTesting
+ void handleUpdateProgram(Program program, long programId) {
+ Set<SeriesRecording> seriesRecordingsToUpdate = new HashSet<>();
+ ScheduledRecording schedule = mDataManager.getScheduledRecordingForProgramId(programId);
+ if (schedule != null
+ && (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) {
+ if (program == null) {
+ mDataManager.removeScheduledRecording(schedule);
+ if (schedule.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
+ SeriesRecording seriesRecording =
+ mDataManager.getSeriesRecording(schedule.getSeriesRecordingId());
+ if (seriesRecording != null) {
+ seriesRecordingsToUpdate.add(seriesRecording);
+ }
+ }
+ } else {
+ long currentTimeMs = System.currentTimeMillis();
+ ScheduledRecording.Builder builder = ScheduledRecording.buildFrom(schedule)
+ .setEndTimeMs(program.getEndTimeUtcMillis())
+ .setSeasonNumber(program.getSeasonNumber())
+ .setEpisodeNumber(program.getEpisodeNumber())
+ .setEpisodeTitle(program.getEpisodeTitle())
+ .setProgramDescription(program.getDescription())
+ .setProgramLongDescription(program.getLongDescription())
+ .setProgramPosterArtUri(program.getPosterArtUri())
+ .setProgramThumbnailUri(program.getThumbnailUri());
+ boolean needUpdate = false;
+ // Check the series recording.
+ SeriesRecording seriesRecordingForOldSchedule =
+ mDataManager.getSeriesRecording(schedule.getSeriesRecordingId());
+ if (program.getSeriesId() != null) {
+ // New program belongs to a series.
+ SeriesRecording seriesRecording =
+ mDataManager.getSeriesRecording(program.getSeriesId());
+ if (seriesRecording == null) {
+ // The new program is episodic while the previous one isn't.
+ SeriesRecording newSeriesRecording = TvApplication.getSingletons(mContext)
+ .getDvrManager().addSeriesRecording(program,
+ Collections.singletonList(program),
+ SeriesRecording.STATE_SERIES_STOPPED);
+ builder.setSeriesRecordingId(newSeriesRecording.getId());
+ needUpdate = true;
+ } else if (seriesRecording.getId() != schedule.getSeriesRecordingId()) {
+ // The new program belongs to the other series.
+ builder.setSeriesRecordingId(seriesRecording.getId());
+ needUpdate = true;
+ seriesRecordingsToUpdate.add(seriesRecording);
+ if (seriesRecordingForOldSchedule != null) {
+ seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
+ }
+ } else if (!Objects.equals(schedule.getSeasonNumber(),
+ program.getSeasonNumber())
+ || !Objects.equals(schedule.getEpisodeNumber(),
+ program.getEpisodeNumber())) {
+ // The episode number has been changed.
+ if (seriesRecordingForOldSchedule != null) {
+ seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
+ }
+ }
+ } else if (seriesRecordingForOldSchedule != null) {
+ // Old program belongs to a series but the new one doesn't.
+ seriesRecordingsToUpdate.add(seriesRecordingForOldSchedule);
+ }
+ // Change start time only when the recording start time has not passed.
+ boolean needToChangeStartTime = schedule.getStartTimeMs() > currentTimeMs
+ && program.getStartTimeUtcMillis() != schedule.getStartTimeMs();
+ if (needToChangeStartTime) {
+ builder.setStartTimeMs(program.getStartTimeUtcMillis());
+ needUpdate = true;
+ }
+ if (needUpdate || schedule.getEndTimeMs() != program.getEndTimeUtcMillis()
+ || !Objects.equals(schedule.getSeasonNumber(), program.getSeasonNumber())
+ || !Objects.equals(schedule.getEpisodeNumber(), program.getEpisodeNumber())
+ || !Objects.equals(schedule.getEpisodeTitle(), program.getEpisodeTitle())
+ || !Objects.equals(schedule.getProgramDescription(),
+ program.getDescription())
+ || !Objects.equals(schedule.getProgramLongDescription(),
+ program.getLongDescription())
+ || !Objects.equals(schedule.getProgramPosterArtUri(),
+ program.getPosterArtUri())
+ || !Objects.equals(schedule.getProgramThumbnailUri(),
+ program.getThumbnailUri())) {
+ mDataManager.updateScheduledRecording(builder.build());
+ }
+ if (!seriesRecordingsToUpdate.isEmpty()) {
+ // The series recordings will be updated after it's resumed.
+ mSeriesRecordingScheduler.updateSchedules(seriesRecordingsToUpdate);
+ }
+ }
+ }
+ }
+
+ private class QueryProgramTask extends AsyncQueryProgramTask {
+ private final long mProgramId;
+
+ QueryProgramTask(long programId) {
+ super(mContext.getContentResolver(), programId);
+ mProgramId = programId;
+ }
+
+ @Override
+ protected void onCancelled(Program program) {
+ if (mQueryProgramTask == this) {
+ mQueryProgramTask = null;
+ }
+ startNextUpdateIfNeeded();
+ }
+
+ @Override
+ protected void onPostExecute(Program program) {
+ if (mQueryProgramTask == this) {
+ mQueryProgramTask = null;
+ }
+ handleUpdateProgram(program, mProgramId);
+ startNextUpdateIfNeeded();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/DvrManager.java b/src/com/android/tv/dvr/DvrManager.java
index e3dc622e..5fa6f90f 100644
--- a/src/com/android/tv/dvr/DvrManager.java
+++ b/src/com/android/tv/dvr/DvrManager.java
@@ -16,28 +16,43 @@
package com.android.tv.dvr;
+import android.annotation.TargetApi;
+import android.content.ContentProviderOperation;
import android.content.ContentResolver;
+import android.content.ContentUris;
import android.content.Context;
+import android.content.OperationApplicationException;
+import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
import android.os.Handler;
+import android.os.RemoteException;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.util.Log;
import android.util.Range;
-import android.widget.Toast;
import com.android.tv.ApplicationSingletons;
import com.android.tv.TvApplication;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.feature.CommonFeatures;
-import com.android.tv.common.recording.RecordedProgram;
import com.android.tv.data.Channel;
-import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.RecordedProgramListener;
+import com.android.tv.dvr.DvrScheduleManager.OnInitializeListener;
+import com.android.tv.dvr.SeriesRecording.SeriesState;
import com.android.tv.util.AsyncDbTask;
import com.android.tv.util.Utils;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -49,68 +64,375 @@ import java.util.Map.Entry;
* instead of modifying them directly through {@link DvrDataManager}.
*/
@MainThread
+@TargetApi(Build.VERSION_CODES.N)
public class DvrManager {
- private final static String TAG = "DvrManager";
+ private static final String TAG = "DvrManager";
+ private static final boolean DEBUG = false;
+
private final WritableDvrDataManager mDataManager;
- private final ChannelDataManager mChannelDataManager;
- private final DvrSessionManager mDvrSessionManager;
+ private final DvrScheduleManager mScheduleManager;
// @GuardedBy("mListener")
private final Map<Listener, Handler> mListener = new HashMap<>();
private final Context mAppContext;
public DvrManager(Context context) {
SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG);
+ mAppContext = context.getApplicationContext();
ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager();
- mAppContext = context.getApplicationContext();
- mChannelDataManager = appSingletons.getChannelDataManager();
- mDvrSessionManager = appSingletons.getDvrSessionManger();
+ mScheduleManager = appSingletons.getDvrScheduleManager();
+ if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) {
+ createSeriesRecordingsForRecordedProgramsIfNeeded(mDataManager.getRecordedPrograms());
+ } else {
+ // No need to handle DVR schedule load finished because schedule manager is initialized
+ // after the all the schedules are loaded.
+ if (!mDataManager.isRecordedProgramLoadFinished()) {
+ mDataManager.addRecordedProgramLoadFinishedListener(
+ new OnRecordedProgramLoadFinishedListener() {
+ @Override
+ public void onRecordedProgramLoadFinished() {
+ mDataManager.removeRecordedProgramLoadFinishedListener(this);
+ if (mDataManager.isInitialized()
+ && mScheduleManager.isInitialized()) {
+ createSeriesRecordingsForRecordedProgramsIfNeeded(
+ mDataManager.getRecordedPrograms());
+ }
+ }
+ });
+ }
+ if (!mScheduleManager.isInitialized()) {
+ mScheduleManager.addOnInitializeListener(new OnInitializeListener() {
+ @Override
+ public void onInitialize() {
+ mScheduleManager.removeOnInitializeListener(this);
+ if (mDataManager.isInitialized() && mScheduleManager.isInitialized()) {
+ createSeriesRecordingsForRecordedProgramsIfNeeded(
+ mDataManager.getRecordedPrograms());
+ }
+ }
+ });
+ }
+ }
+ mDataManager.addRecordedProgramListener(new RecordedProgramListener() {
+ @Override
+ public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
+ if (!mDataManager.isInitialized() || !mScheduleManager.isInitialized()) {
+ return;
+ }
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram);
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { }
+
+ @Override
+ public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
+ // Removing series recording is handled in the SeriesRecordingDetailsFragment.
+ }
+ });
+ }
+
+ private void createSeriesRecordingsForRecordedProgramsIfNeeded(
+ List<RecordedProgram> recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ createSeriesRecordingForRecordedProgramIfNeeded(recordedProgram);
+ }
+ }
+
+ private void createSeriesRecordingForRecordedProgramIfNeeded(RecordedProgram recordedProgram) {
+ if (recordedProgram.getSeriesId() != null) {
+ SeriesRecording seriesRecording =
+ mDataManager.getSeriesRecording(recordedProgram.getSeriesId());
+ if (seriesRecording == null) {
+ addSeriesRecording(recordedProgram);
+ }
+ }
}
/**
- * Schedules a recording for {@code program} instead of the list of recording that conflict.
- * @param program the program to record
- * @param recordingsToOverride the possible empty list of recordings that will not be recorded
+ * Schedules a recording for {@code program}.
*/
- public void addSchedule(Program program, List<ScheduledRecording> recordingsToOverride) {
- Log.i(TAG,
- "Adding scheduled recording of " + program + " instead of " + recordingsToOverride);
- Collections.sort(recordingsToOverride, ScheduledRecording.PRIORITY_COMPARATOR);
- Channel c = mChannelDataManager.getChannel(program.getChannelId());
- long priority = recordingsToOverride.isEmpty() ? Long.MAX_VALUE
- : recordingsToOverride.get(0).getPriority() - 1;
- ScheduledRecording r = ScheduledRecording.builder(program)
+ public ScheduledRecording addSchedule(Program program) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return null;
+ }
+ SeriesRecording seriesRecording = getSeriesRecording(program);
+ return addSchedule(program, seriesRecording == null
+ ? mScheduleManager.suggestNewPriority()
+ : seriesRecording.getPriority());
+ }
+
+ /**
+ * Schedules a recording for {@code program} with the highest priority so that the schedule
+ * can be recorded.
+ */
+ public ScheduledRecording addScheduleWithHighestPriority(Program program) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return null;
+ }
+ SeriesRecording seriesRecording = getSeriesRecording(program);
+ return addSchedule(program, seriesRecording == null
+ ? mScheduleManager.suggestNewPriority()
+ : mScheduleManager.suggestHighestPriority(seriesRecording.getInputId(),
+ new Range(program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis()),
+ seriesRecording.getPriority()));
+ }
+
+ private ScheduledRecording addSchedule(Program program, long priority) {
+ TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, program);
+ if (input == null) {
+ Log.e(TAG, "Can't find input for program: " + program);
+ return null;
+ }
+ ScheduledRecording schedule;
+ SeriesRecording seriesRecording = getSeriesRecording(program);
+ schedule = createScheduledRecordingBuilder(input.getId(), program)
.setPriority(priority)
- .setChannelId(c.getId())
+ .setSeriesRecordingId(seriesRecording == null ? SeriesRecording.ID_NOT_SET
+ : seriesRecording.getId())
.build();
- mDataManager.addScheduledRecording(r);
+ mDataManager.addScheduledRecording(schedule);
+ return schedule;
}
/**
* Adds a recording schedule with a time range.
*/
public void addSchedule(Channel channel, long startTime, long endTime) {
- Log.i(TAG, "Adding scheduled recording of channel" + channel + " starting at " +
+ Log.i(TAG, "Adding scheduled recording of channel " + channel + " starting at " +
Utils.toTimeString(startTime) + " and ending at " + Utils.toTimeString(endTime));
- //TODO: handle error cases
- ScheduledRecording r = ScheduledRecording.builder(startTime, endTime)
- .setChannelId(channel.getId())
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
+ TvInputInfo input = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId());
+ if (input == null) {
+ Log.e(TAG, "Can't find input for channel: " + channel);
+ return;
+ }
+ addScheduleInternal(input.getId(), channel.getId(), startTime, endTime);
+ }
+
+ /**
+ * Adds the schedule.
+ */
+ public void addSchedule(ScheduledRecording schedule) {
+ if (mDataManager.isDvrScheduleLoadFinished()) {
+ mDataManager.addScheduledRecording(schedule);
+ }
+ }
+
+ private void addScheduleInternal(String inputId, long channelId, long startTime, long endTime) {
+ mDataManager.addScheduledRecording(ScheduledRecording
+ .builder(inputId, channelId, startTime, endTime)
+ .setPriority(mScheduleManager.suggestNewPriority())
+ .build());
+ }
+
+ /**
+ * Adds a new series recording and schedules for the programs with the initial state.
+ */
+ public SeriesRecording addSeriesRecording(Program selectedProgram,
+ List<Program> programsToSchedule, @SeriesState int initialState) {
+ Log.i(TAG, "Adding series recording for program " + selectedProgram + ", and schedules: "
+ + programsToSchedule);
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
+ return null;
+ }
+ TvInputInfo input = Utils.getTvInputInfoForProgram(mAppContext, selectedProgram);
+ if (input == null) {
+ Log.e(TAG, "Can't find input for program: " + selectedProgram);
+ return null;
+ }
+ SeriesRecording seriesRecording = SeriesRecording.builder(input.getId(), selectedProgram)
+ .setPriority(mScheduleManager.suggestNewSeriesPriority())
+ .setState(initialState)
.build();
- mDataManager.addScheduledRecording(r);
+ mDataManager.addSeriesRecording(seriesRecording);
+ // The schedules for the recorded programs should be added not to create the schedule the
+ // duplicate episodes.
+ addRecordedProgramToSeriesRecording(seriesRecording);
+ addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
+ return seriesRecording;
+ }
+
+ private void addSeriesRecording(RecordedProgram recordedProgram) {
+ SeriesRecording seriesRecording =
+ SeriesRecording.builder(recordedProgram.getInputId(), recordedProgram)
+ .setPriority(mScheduleManager.suggestNewSeriesPriority())
+ .setState(SeriesRecording.STATE_SERIES_STOPPED)
+ .build();
+ mDataManager.addSeriesRecording(seriesRecording);
+ // The schedules for the recorded programs should be added not to create the schedule the
+ // duplicate episodes.
+ addRecordedProgramToSeriesRecording(seriesRecording);
+ }
+
+ private void addRecordedProgramToSeriesRecording(SeriesRecording series) {
+ List<ScheduledRecording> toAdd = new ArrayList<>();
+ for (RecordedProgram recordedProgram : mDataManager.getRecordedPrograms()) {
+ if (series.getSeriesId().equals(recordedProgram.getSeriesId())
+ && !recordedProgram.isClipped()) {
+ // Duplicate schedules can exist, but they will be deleted in a few days. And it's
+ // also guaranteed that the schedules don't belong to any series recordings because
+ // there are no more than one series recordings which have the same program title.
+ toAdd.add(ScheduledRecording.builder(recordedProgram)
+ .setPriority(series.getPriority())
+ .setSeriesRecordingId(series.getId()).build());
+ }
+ }
+ if (!toAdd.isEmpty()) {
+ mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd));
+ }
}
/**
- * Adds a season recording schedule based on {@code program}.
+ * Adds {@link ScheduledRecording}s for the series recording.
+ * <p>
+ * This method doesn't add the series recording.
*/
- public void addSeasonSchedule(Program program) {
- Log.i(TAG, "Adding season recording of " + program);
- // TODO: implement
+ public void addScheduleToSeriesRecording(SeriesRecording series,
+ List<Program> programsToSchedule) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mAppContext, series.getInputId());
+ if (input == null) {
+ Log.e(TAG, "Can't find input with ID: " + series.getInputId());
+ return;
+ }
+ List<ScheduledRecording> toAdd = new ArrayList<>();
+ List<ScheduledRecording> toUpdate = new ArrayList<>();
+ for (Program program : programsToSchedule) {
+ ScheduledRecording scheduleWithSameProgram =
+ mDataManager.getScheduledRecordingForProgramId(program.getId());
+ if (scheduleWithSameProgram != null) {
+ if (scheduleWithSameProgram.getState()
+ == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ ScheduledRecording r = ScheduledRecording.buildFrom(scheduleWithSameProgram)
+ .setSeriesRecordingId(series.getId())
+ .build();
+ if (!r.equals(scheduleWithSameProgram)) {
+ toUpdate.add(r);
+ }
+ }
+ } else {
+ toAdd.add(createScheduledRecordingBuilder(input.getId(), program)
+ .setPriority(series.getPriority())
+ .setSeriesRecordingId(series.getId())
+ .build());
+ }
+ }
+ if (!toAdd.isEmpty()) {
+ mDataManager.addScheduledRecording(ScheduledRecording.toArray(toAdd));
+ }
+ if (!toUpdate.isEmpty()) {
+ mDataManager.updateScheduledRecording(ScheduledRecording.toArray(toUpdate));
+ }
+ }
+
+ /**
+ * Updates the series recording.
+ */
+ public void updateSeriesRecording(SeriesRecording series) {
+ if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ SeriesRecordingScheduler scheduler = SeriesRecordingScheduler.getInstance(mAppContext);
+ scheduler.pauseUpdate();
+ SeriesRecording previousSeries = mDataManager.getSeriesRecording(series.getId());
+ if (previousSeries != null) {
+ if (previousSeries.getChannelOption() != series.getChannelOption()
+ || (previousSeries.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ONE
+ && previousSeries.getChannelId() != series.getChannelId())) {
+ List<ScheduledRecording> schedules =
+ mDataManager.getScheduledRecordings(series.getId());
+ List<ScheduledRecording> schedulesToRemove = new ArrayList<>();
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.isNotStarted()) {
+ schedulesToRemove.add(schedule);
+ }
+ }
+ mDataManager.removeScheduledRecording(true,
+ ScheduledRecording.toArray(schedulesToRemove));
+ }
+ }
+ mDataManager.updateSeriesRecording(series);
+ if (previousSeries == null
+ || previousSeries.getPriority() != series.getPriority()) {
+ long priority = series.getPriority();
+ List<ScheduledRecording> schedulesToUpdate = new ArrayList<>();
+ for (ScheduledRecording schedule
+ : mDataManager.getScheduledRecordings(series.getId())) {
+ if (schedule.isNotStarted()) {
+ schedulesToUpdate.add(ScheduledRecording.buildFrom(schedule)
+ .setPriority(priority).build());
+ }
+ }
+ if (!schedulesToUpdate.isEmpty()) {
+ mDataManager.updateScheduledRecording(
+ ScheduledRecording.toArray(schedulesToUpdate));
+ }
+ }
+ scheduler.resumeUpdate();
+ }
+ }
+
+ /**
+ * Removes the series recording and all the corresponding schedules which are not started yet.
+ */
+ public void removeSeriesRecording(long seriesRecordingId) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
+ SeriesRecording series = mDataManager.getSeriesRecording(seriesRecordingId);
+ if (series == null) {
+ return;
+ }
+ for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
+ if (schedule.getSeriesRecordingId() == seriesRecordingId) {
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ stopRecording(schedule);
+ break;
+ }
+ }
+ }
+ mDataManager.removeSeriesRecording(series);
+ }
+
+ /**
+ * Returns true, if the series recording can be removed. If a series recording is NORMAL state
+ * or has recordings or schedules, it cannot be removed.
+ */
+ public boolean canRemoveSeriesRecording(long seriesRecordingId) {
+ SeriesRecording seriesRecording = mDataManager.getSeriesRecording(seriesRecordingId);
+ if (seriesRecording == null) {
+ return false;
+ }
+ if (!seriesRecording.isStopped()) {
+ return false;
+ }
+ for (ScheduledRecording r : mDataManager.getAvailableScheduledRecordings()) {
+ if (r.getSeriesRecordingId() == seriesRecordingId) {
+ return false;
+ }
+ }
+ String seriesId = seriesRecording.getSeriesId();
+ SoftPreconditions.checkNotNull(seriesId);
+ for (RecordedProgram r : mDataManager.getRecordedPrograms()) {
+ if (seriesId.equals(r.getSeriesId())) {
+ return false;
+ }
+ }
+ return true;
}
/**
* Stops the currently recorded program
*/
public void stopRecording(final ScheduledRecording recording) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
synchronized (mListener) {
for (final Entry<Listener, Handler> entry : mListener.entrySet()) {
entry.getValue().post(new Runnable() {
@@ -124,86 +446,297 @@ public class DvrManager {
}
/**
- * Removes a scheduled recording or an existing recording.
+ * Removes scheduled recordings or an existing recordings.
+ */
+ public void removeScheduledRecording(ScheduledRecording... schedules) {
+ Log.i(TAG, "Removing " + Arrays.asList(schedules));
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
+ for (ScheduledRecording r : schedules) {
+ if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ stopRecording(r);
+ } else {
+ mDataManager.removeScheduledRecording(r);
+ }
+ }
+ }
+
+ /**
+ * Removes scheduled recordings without changing to the DELETED state.
+ */
+ public void forceRemoveScheduledRecording(ScheduledRecording... schedules) {
+ Log.i(TAG, "Force removing " + Arrays.asList(schedules));
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return;
+ }
+ for (ScheduledRecording r : schedules) {
+ if (r.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ stopRecording(r);
+ } else {
+ mDataManager.removeScheduledRecording(true, r);
+ }
+ }
+ }
+
+ /**
+ * Removes the recorded program. It deletes the file if possible.
*/
- public void removeScheduledRecording(ScheduledRecording scheduledRecording) {
- Log.i(TAG, "Removing " + scheduledRecording);
- mDataManager.removeScheduledRecording(scheduledRecording);
+ public void removeRecordedProgram(Uri recordedProgramUri) {
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
+ return;
+ }
+ removeRecordedProgram(ContentUris.parseId(recordedProgramUri));
}
+ /**
+ * Removes the recorded program. It deletes the file if possible.
+ */
+ public void removeRecordedProgram(long recordedProgramId) {
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
+ return;
+ }
+ RecordedProgram recordedProgram = mDataManager.getRecordedProgram(recordedProgramId);
+ if (recordedProgram != null) {
+ removeRecordedProgram(recordedProgram);
+ }
+ }
+
+ /**
+ * Removes the recorded program. It deletes the file if possible.
+ */
public void removeRecordedProgram(final RecordedProgram recordedProgram) {
- // TODO(dvr): implement
- Log.i(TAG, "To delete " + recordedProgram
- + "\nyou should manually delete video data at"
- + "\nadb shell rm -rf " + recordedProgram.getDataUri()
- );
- Toast.makeText(mAppContext, "Deleting recorded programs is not fully implemented yet",
- Toast.LENGTH_SHORT).show();
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized())) {
+ return;
+ }
+ new AsyncDbTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ ContentResolver resolver = mAppContext.getContentResolver();
+ int deletedCounts = resolver.delete(recordedProgram.getUri(), null, null);
+ if (deletedCounts > 0) {
+ // TODO: executeOnExecutor should be called on the main thread.
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ removeRecordedData(recordedProgram.getDataUri());
+ return null;
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ return null;
+ }
+ }.executeOnDbThread();
+ }
+
+ public void removeRecordedPrograms(List<Long> recordedProgramIds) {
+ final ArrayList<ContentProviderOperation> dbOperations = new ArrayList<>();
+ final List<Uri> dataUris = new ArrayList<>();
+ for (Long rId : recordedProgramIds) {
+ RecordedProgram r = mDataManager.getRecordedProgram(rId);
+ if (r != null) {
+ dataUris.add(r.getDataUri());
+ dbOperations.add(ContentProviderOperation.newDelete(r.getUri()).build());
+ }
+ }
new AsyncDbTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ContentResolver resolver = mAppContext.getContentResolver();
- resolver.delete(recordedProgram.getUri(), null, null);
+ try {
+ resolver.applyBatch(TvContract.AUTHORITY, dbOperations);
+ // TODO: executeOnExecutor should be called on the main thread.
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ for (Uri dataUri : dataUris) {
+ removeRecordedData(dataUri);
+ }
+ return null;
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.w(TAG, "Remove reocrded programs from DB failed.", e);
+ }
return null;
}
- }.execute();
+ }.executeOnDbThread();
}
/**
- * Returns priority ordered list of all scheduled recording that will not be recorded if
+ * Updates the scheduled recording.
+ */
+ public void updateScheduledRecording(ScheduledRecording recording) {
+ if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ mDataManager.updateScheduledRecording(recording);
+ }
+ }
+
+ /**
+ * Returns priority ordered list of all scheduled recordings that will not be recorded if
* this program is.
*
- * <p>Any empty list means there is no conflicts. If there is conflict the program must be
- * scheduled to record with a Priority lower than the first Recording in the list returned.
- */
- public List<ScheduledRecording> getScheduledRecordingsThatConflict(Program program) {
- //TODO(DVR): move to scheduler.
- //TODO(DVR): deal with more than one DvrInputService
- List<ScheduledRecording> overLap = mDataManager.getRecordingsThatOverlapWith(getPeriod(program));
- if (!overLap.isEmpty()) {
- // TODO(DVR): ignore shows that already won't record.
- Channel channel = mChannelDataManager.getChannel(program.getChannelId());
- if (channel != null) {
- TvInputInfo info = mDvrSessionManager.getTvInputInfo(channel.getInputId());
- if (info == null) {
- Log.w(TAG,
- "Could not find a recording TvInputInfo for " + channel.getInputId());
- return overLap;
- }
- int remove = Math.max(0, info.getTunerCount() - 1);
- if (remove >= overLap.size()) {
- return Collections.EMPTY_LIST;
- }
- overLap = overLap.subList(remove, overLap.size() - 1);
+ * @see DvrScheduleManager#getConflictingSchedules(Program)
+ */
+ public List<ScheduledRecording> getConflictingSchedules(Program program) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return Collections.emptyList();
+ }
+ return mScheduleManager.getConflictingSchedules(program);
+ }
+
+ /**
+ * Returns priority ordered list of all scheduled recordings that will not be recorded if
+ * this channel is.
+ *
+ * @see DvrScheduleManager#getConflictingSchedules(long, long, long)
+ */
+ public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs,
+ long endTimeMs) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return Collections.emptyList();
+ }
+ return mScheduleManager.getConflictingSchedules(channelId, startTimeMs, endTimeMs);
+ }
+
+ /**
+ * Checks if the schedule is conflicting.
+ *
+ * <p>Note that the {@code schedule} should be the existing one. If not, this returns
+ * {@code false}.
+ */
+ public boolean isConflicting(ScheduledRecording schedule) {
+ return schedule != null
+ && SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())
+ && mScheduleManager.isConflicting(schedule);
+ }
+
+ /**
+ * Returns priority ordered list of all scheduled recording that will not be recorded if
+ * this channel is tuned to.
+ *
+ * @see DvrScheduleManager#getConflictingSchedulesForTune
+ */
+ public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return Collections.emptyList();
+ }
+ return mScheduleManager.getConflictingSchedulesForTune(channelId);
+ }
+
+ /**
+ * Sets the highest priority to the schedule.
+ */
+ public void setHighestPriority(ScheduledRecording schedule) {
+ if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ long newPriority = mScheduleManager.suggestHighestPriority(schedule);
+ if (newPriority != schedule.getPriority()) {
+ mDataManager.updateScheduledRecording(ScheduledRecording.buildFrom(schedule)
+ .setPriority(newPriority).build());
}
}
- return overLap;
}
- @NonNull
- private static Range getPeriod(Program program) {
- return new Range(program.getStartTimeUtcMillis(), program.getEndTimeUtcMillis());
+ /**
+ * Suggests the higher priority than the schedules which overlap with {@code schedule}.
+ */
+ public long suggestHighestPriority(ScheduledRecording schedule) {
+ if (SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return mScheduleManager.suggestHighestPriority(schedule);
+ }
+ return DvrScheduleManager.DEFAULT_PRIORITY;
}
/**
- * Checks whether {@code channel} can be tuned without any conflict with existing recordings
- * in progress. If there is any conflict, {@code outConflictRecordings} will be filled.
+ * Returns {@code true} if the channel can be recorded.
+ * <p>
+ * Note that this method doesn't check the conflict of the schedule or available tuners.
+ * This can be called from the UI before the schedules are loaded.
*/
- public boolean canTuneTo(Channel channel, List<ScheduledRecording> outConflictScheduledRecordings) {
- // TODO: implement
- return true;
+ public boolean isChannelRecordable(Channel channel) {
+ if (!mDataManager.isDvrScheduleLoadFinished() || channel == null) {
+ return false;
+ }
+ TvInputInfo info = Utils.getTvInputInfoForChannelId(mAppContext, channel.getId());
+ if (info == null) {
+ Log.w(TAG, "Could not find TvInputInfo for " + channel);
+ return false;
+ }
+ if (!info.canRecord()) {
+ return false;
+ }
+ Program program = TvApplication.getSingletons(mAppContext).getProgramDataManager()
+ .getCurrentProgram(channel.getId());
+ return program == null || !program.isRecordingProhibited();
+ }
+
+ /**
+ * Returns {@code true} if the program can be recorded.
+ * <p>
+ * Note that this method doesn't check the conflict of the schedule or available tuners.
+ * This can be called from the UI before the schedules are loaded.
+ */
+ public boolean isProgramRecordable(Program program) {
+ if (!mDataManager.isInitialized()) {
+ return false;
+ }
+ TvInputInfo info = Utils.getTvInputInfoForProgram(mAppContext, program);
+ if (info == null) {
+ Log.w(TAG, "Could not find TvInputInfo for " + program);
+ return false;
+ }
+ return info.canRecord() && !program.isRecordingProhibited();
+ }
+
+ /**
+ * Returns the current recording for the channel.
+ * <p>
+ * This can be called from the UI before the schedules are loaded.
+ */
+ public ScheduledRecording getCurrentRecording(long channelId) {
+ if (!mDataManager.isDvrScheduleLoadFinished()) {
+ return null;
+ }
+ for (ScheduledRecording recording : mDataManager.getStartedRecordings()) {
+ if (recording.getChannelId() == channelId) {
+ return recording;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns schedules which is available (i.e., isNotStarted or isInProgress) and belongs to
+ * the series recording {@code seriesRecordingId}.
+ */
+ public List<ScheduledRecording> getAvailableScheduledRecording(long seriesRecordingId) {
+ if (!mDataManager.isDvrScheduleLoadFinished()) {
+ return Collections.emptyList();
+ }
+ List<ScheduledRecording> schedules = new ArrayList<>();
+ for (ScheduledRecording schedule : mDataManager.getScheduledRecordings(seriesRecordingId)) {
+ if (schedule.isInProgress() || schedule.isNotStarted()) {
+ schedules.add(schedule);
+ }
+ }
+ return schedules;
}
/**
- * Returns true is the inputId supports recording.
+ * Returns the series recording related to the program.
*/
- public boolean canRecord(String inputId) {
- TvInputInfo info = mDvrSessionManager.getTvInputInfo(inputId);
- return info != null && info.getTunerCount() > 0;
+ @Nullable
+ public SeriesRecording getSeriesRecording(Program program) {
+ if (!SoftPreconditions.checkState(mDataManager.isDvrScheduleLoadFinished())) {
+ return null;
+ }
+ return mDataManager.getSeriesRecording(program.getSeriesId());
}
@WorkerThread
- void addListener(Listener listener, @NonNull Handler handler) {
+ @VisibleForTesting
+ // Should be public to use mock DvrManager object.
+ public void addListener(Listener listener, @NonNull Handler handler) {
SoftPreconditions.checkNotNull(handler);
synchronized (mListener) {
mListener.put(listener, handler);
@@ -211,13 +744,102 @@ public class DvrManager {
}
@WorkerThread
- void removeListener(Listener listener) {
+ @VisibleForTesting
+ // Should be public to use mock DvrManager object.
+ public void removeListener(Listener listener) {
synchronized (mListener) {
mListener.remove(listener);
}
}
/**
+ * Returns ScheduledRecording.builder based on {@code program}. If program is already started,
+ * recording started time is clipped to the current time.
+ */
+ private ScheduledRecording.Builder createScheduledRecordingBuilder(String inputId,
+ Program program) {
+ ScheduledRecording.Builder builder = ScheduledRecording.builder(inputId, program);
+ long time = System.currentTimeMillis();
+ if (program.getStartTimeUtcMillis() < time && time < program.getEndTimeUtcMillis()) {
+ builder.setStartTimeMs(time);
+ }
+ return builder;
+ }
+
+ /**
+ * Returns a schedule which matches to the given episode.
+ */
+ public ScheduledRecording getScheduledRecording(String title, String seasonNumber,
+ String episodeNumber) {
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized()) || title == null
+ || seasonNumber == null || episodeNumber == null) {
+ return null;
+ }
+ for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) {
+ if (title.equals(r.getProgramTitle())
+ && seasonNumber.equals(r.getSeasonNumber())
+ && episodeNumber.equals(r.getEpisodeNumber())) {
+ return r;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns a recorded program which is the same episode as the given {@code program}.
+ */
+ public RecordedProgram getRecordedProgram(String title, String seasonNumber,
+ String episodeNumber) {
+ if (!SoftPreconditions.checkState(mDataManager.isInitialized()) || title == null
+ || seasonNumber == null || episodeNumber == null) {
+ return null;
+ }
+ for (RecordedProgram r : mDataManager.getRecordedPrograms()) {
+ if (title.equals(r.getTitle())
+ && seasonNumber.equals(r.getSeasonNumber())
+ && episodeNumber.equals(r.getEpisodeNumber())
+ && !r.isClipped()) {
+ return r;
+ }
+ }
+ return null;
+ }
+
+ @WorkerThread
+ private void removeRecordedData(Uri dataUri) {
+ try {
+ if (dataUri != null && ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())
+ && dataUri.getPath() != null) {
+ File recordedProgramPath = new File(dataUri.getPath());
+ if (!recordedProgramPath.exists()) {
+ if (DEBUG) Log.d(TAG, "File to delete not exist: " + recordedProgramPath);
+ } else {
+ Utils.deleteDirOrFile(recordedProgramPath);
+ if (DEBUG) {
+ Log.d(TAG, "Sucessfully deleted files of the recorded program: " + dataUri);
+ }
+ }
+ }
+ } catch (SecurityException e) {
+ if (DEBUG) {
+ Log.d(TAG, "To delete this recorded program, please manually delete video data at"
+ + "\nadb shell rm -rf " + dataUri);
+ }
+ }
+ }
+
+ /**
+ * Remove all the records related to the input.
+ * <p>
+ * Note that this should be called after the input was removed.
+ */
+ public void forgetStorage(String inputId) {
+ if (mDataManager.isInitialized()) {
+ mDataManager.forgetStorage(inputId);
+ }
+ }
+
+ /**
* Listener internally used inside dvr package.
*/
interface Listener {
diff --git a/src/com/android/tv/dvr/DvrPlayActivity.java b/src/com/android/tv/dvr/DvrPlayActivity.java
deleted file mode 100644
index b117a7cf..00000000
--- a/src/com/android/tv/dvr/DvrPlayActivity.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License
- */
-
-package com.android.tv.dvr;
-
-import android.app.Activity;
-import android.os.Bundle;
-import android.widget.TextView;
-
-import com.android.tv.R;
-import com.android.tv.TvApplication;
-
-/**
- * Simple Activity to play a {@link ScheduledRecording}.
- */
-public class DvrPlayActivity extends Activity {
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.dvr_play);
-
- DvrDataManager dvrDataManager = TvApplication.getSingletons(this).getDvrDataManager();
- // TODO(DVR) handle errors.
- long recordingId = getIntent().getLongExtra(ScheduledRecording.RECORDING_ID_EXTRA, 0);
- ScheduledRecording scheduledRecording = dvrDataManager.getScheduledRecording(recordingId);
- TextView textView = (TextView) findViewById(R.id.placeHolderText);
- if (scheduledRecording != null) {
- textView.setText(scheduledRecording.toString());
- } else {
- textView.setText(R.string.ut_result_not_found_title); // TODO(DVR) update error text
- }
- }
-} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/DvrPlaybackActivity.java b/src/com/android/tv/dvr/DvrPlaybackActivity.java
new file mode 100644
index 00000000..5deda44a
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrPlaybackActivity.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.ui.DvrPlaybackOverlayFragment;
+
+/**
+ * Activity to play a {@link RecordedProgram}.
+ */
+public class DvrPlaybackActivity extends Activity {
+ private static final String TAG = "DvrPlaybackActivity";
+ private static final boolean DEBUG = false;
+
+ private DvrPlaybackOverlayFragment mOverlayFragment;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
+ if (DEBUG) Log.d(TAG, "onCreate");
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_dvr_playback);
+ mOverlayFragment = (DvrPlaybackOverlayFragment) getFragmentManager()
+ .findFragmentById(R.id.dvr_playback_controls_fragment);
+ }
+
+ @Override
+ public void onVisibleBehindCanceled() {
+ if (DEBUG) Log.d(TAG, "onVisibleBehindCanceled");
+ super.onVisibleBehindCanceled();
+ finish();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ mOverlayFragment.onNewIntent(intent);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ float density = getResources().getDisplayMetrics().density;
+ mOverlayFragment.onWindowSizeChanged((int) (newConfig.screenWidthDp * density),
+ (int) (newConfig.screenHeightDp * density));
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java b/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java
new file mode 100644
index 00000000..9759a856
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrPlaybackMediaSessionHelper.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.MediaMetadata;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.media.tv.TvContract;
+import android.os.AsyncTask;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.ui.DvrPlaybackOverlayFragment;
+import com.android.tv.util.ImageLoader;
+import com.android.tv.util.TimeShiftUtils;
+import com.android.tv.util.Utils;
+
+public class DvrPlaybackMediaSessionHelper {
+ private static final String TAG = "DvrPlaybackMediaSessionHelper";
+ private static final boolean DEBUG = false;
+
+ private int mNowPlayingCardWidth;
+ private int mNowPlayingCardHeight;
+ private int mSpeedLevel;
+ private long mProgramDurationMs;
+
+ private Activity mActivity;
+ private DvrPlayer mDvrPlayer;
+ private MediaSession mMediaSession;
+ private final DvrWatchedPositionManager mDvrWatchedPositionManager;
+ private final ChannelDataManager mChannelDataManager;
+
+ public DvrPlaybackMediaSessionHelper(Activity activity, String mediaSessionTag,
+ DvrPlayer dvrPlayer, DvrPlaybackOverlayFragment overlayFragment) {
+ mActivity = activity;
+ mDvrPlayer = dvrPlayer;
+ mDvrWatchedPositionManager =
+ TvApplication.getSingletons(activity).getDvrWatchedPositionManager();
+ mChannelDataManager = TvApplication.getSingletons(activity).getChannelDataManager();
+ mDvrPlayer.setCallback(new DvrPlayer.DvrPlayerCallback() {
+ @Override
+ public void onPlaybackStateChanged(int playbackState, int playbackSpeed) {
+ updateMediaSessionPlaybackState();
+ }
+
+ @Override
+ public void onPlaybackPositionChanged(long positionMs) {
+ updateMediaSessionPlaybackState();
+ if (mDvrPlayer.isPlaybackPrepared()) {
+ mDvrWatchedPositionManager
+ .setWatchedPosition(mDvrPlayer.getProgram().getId(), positionMs);
+ }
+ }
+
+ @Override
+ public void onPlaybackEnded() {
+ // TODO: Deal with watched over recordings in DVR library
+ RecordedProgram nextEpisode =
+ overlayFragment.getNextEpisode(mDvrPlayer.getProgram());
+ if (nextEpisode == null) {
+ mDvrPlayer.reset();
+ mActivity.finish();
+ } else {
+ Intent intent = new Intent(activity, DvrPlaybackActivity.class);
+ intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, nextEpisode.getId());
+ mActivity.startActivity(intent);
+ }
+ }
+ });
+ initializeMediaSession(mediaSessionTag);
+ }
+
+ /**
+ * Stops DVR player and release media session.
+ */
+ public void release() {
+ if (mDvrPlayer != null) {
+ mDvrPlayer.reset();
+ }
+ if (mMediaSession != null) {
+ mMediaSession.release();
+ }
+ }
+
+ /**
+ * Updates media session's playback state and speed.
+ */
+ public void updateMediaSessionPlaybackState() {
+ mMediaSession.setPlaybackState(new PlaybackState.Builder()
+ .setState(mDvrPlayer.getPlaybackState(), mDvrPlayer.getPlaybackPosition(),
+ mSpeedLevel).build());
+ }
+
+ /**
+ * Sets the recorded program for playback.
+ *
+ * @param program The recorded program to play. {@code null} to reset the DVR player.
+ */
+ public void setupPlayback(RecordedProgram program, long seekPositionMs) {
+ if (program != null) {
+ mDvrPlayer.setProgram(program, seekPositionMs);
+ setupMediaSession(program);
+ } else {
+ mDvrPlayer.reset();
+ mMediaSession.setActive(false);
+ }
+ }
+
+ /**
+ * Returns the recorded program now playing.
+ */
+ public RecordedProgram getProgram() {
+ return mDvrPlayer.getProgram();
+ }
+
+ /**
+ * Checks if the recorded program is the same as now playing one.
+ */
+ public boolean isCurrentProgram(RecordedProgram program) {
+ return program != null && program.equals(getProgram());
+ }
+
+ /**
+ * Returns playback state.
+ */
+ public int getPlaybackState() {
+ return mDvrPlayer.getPlaybackState();
+ }
+
+ /**
+ * Returns the underlying DVR player.
+ */
+ public DvrPlayer getDvrPlayer() {
+ return mDvrPlayer;
+ }
+
+ private void initializeMediaSession(String mediaSessionTag) {
+ mMediaSession = new MediaSession(mActivity, mediaSessionTag);
+ mMediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
+ | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);
+ mNowPlayingCardWidth = mActivity.getResources()
+ .getDimensionPixelSize(R.dimen.notif_card_img_max_width);
+ mNowPlayingCardHeight = mActivity.getResources()
+ .getDimensionPixelSize(R.dimen.notif_card_img_height);
+ mMediaSession.setCallback(new MediaSessionCallback());
+ mActivity.setMediaController(
+ new MediaController(mActivity, mMediaSession.getSessionToken()));
+ updateMediaSessionPlaybackState();
+ }
+
+ private void setupMediaSession(RecordedProgram program) {
+ mProgramDurationMs = program.getDurationMillis();
+ String cardTitleText = program.getTitle();
+ if (TextUtils.isEmpty(cardTitleText)) {
+ Channel channel = mChannelDataManager.getChannel(program.getChannelId());
+ cardTitleText = (channel != null) ? channel.getDisplayName()
+ : mActivity.getString(R.string.no_program_information);
+ }
+ updateMediaMetadata(program.getId(), cardTitleText, program.getDescription(),
+ mProgramDurationMs, null, 0);
+ String posterArtUri = program.getPosterArtUri();
+ if (posterArtUri == null) {
+ posterArtUri = TvContract.buildChannelLogoUri(program.getChannelId()).toString();
+ }
+ updatePosterArt(program, cardTitleText, program.getDescription(),
+ mProgramDurationMs, null, posterArtUri);
+ mMediaSession.setActive(true);
+ }
+
+ private void updatePosterArt(RecordedProgram program, String cardTitleText,
+ String cardSubtitleText, long duration,
+ @Nullable Bitmap posterArt, @Nullable String posterArtUri) {
+ if (posterArt != null) {
+ updateMediaMetadata(program.getId(), cardTitleText,
+ cardSubtitleText, duration, posterArt, 0);
+ } else if (posterArtUri != null) {
+ ImageLoader.loadBitmap(mActivity, posterArtUri, mNowPlayingCardWidth,
+ mNowPlayingCardHeight, new ProgramPosterArtCallback(
+ mActivity, program, cardTitleText, cardSubtitleText, duration));
+ } else {
+ updateMediaMetadata(program.getId(), cardTitleText,
+ cardSubtitleText, duration, null, R.drawable.default_now_card);
+ }
+ }
+
+ private class ProgramPosterArtCallback extends
+ ImageLoader.ImageLoaderCallback<Activity> {
+ private RecordedProgram mRecordedProgram;
+ private String mCardTitleText;
+ private String mCardSubtitleText;
+ private long mDuration;
+
+ public ProgramPosterArtCallback(Activity activity, RecordedProgram program,
+ String cardTitleText, String cardSubtitleText, long duration) {
+ super(activity);
+ mRecordedProgram = program;
+ mCardTitleText = cardTitleText;
+ mCardSubtitleText = cardSubtitleText;
+ mDuration = duration;
+ }
+
+ @Override
+ public void onBitmapLoaded(Activity activity, @Nullable Bitmap posterArt) {
+ if (isCurrentProgram(mRecordedProgram)) {
+ updatePosterArt(mRecordedProgram, mCardTitleText,
+ mCardSubtitleText, mDuration, posterArt, null);
+ }
+ }
+ }
+
+ private void updateMediaMetadata(final long programId, final String title,
+ final String subtitle, final long duration,
+ final Bitmap posterArt, final int imageResId) {
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... arg0) {
+ MediaMetadata.Builder builder = new MediaMetadata.Builder();
+ builder.putLong(MediaMetadata.METADATA_KEY_MEDIA_ID, programId)
+ .putString(MediaMetadata.METADATA_KEY_TITLE, title)
+ .putLong(MediaMetadata.METADATA_KEY_DURATION, duration);
+ if (subtitle != null) {
+ builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle);
+ }
+ Bitmap programPosterArt = posterArt;
+ if (programPosterArt == null && imageResId != 0) {
+ programPosterArt =
+ BitmapFactory.decodeResource(mActivity.getResources(), imageResId);
+ }
+ if (programPosterArt != null) {
+ builder.putBitmap(MediaMetadata.METADATA_KEY_ART, programPosterArt);
+ }
+ mMediaSession.setMetadata(builder.build());
+ return null;
+ }
+ }.execute();
+ }
+
+ // An event was triggered by MediaController.TransportControls and must be handled here.
+ // Here we update the media itself to act on the event that was triggered.
+ private class MediaSessionCallback extends MediaSession.Callback {
+ @Override
+ public void onPrepare() {
+ if (!mDvrPlayer.isPlaybackPrepared()) {
+ mDvrPlayer.prepare(true);
+ }
+ }
+
+ @Override
+ public void onPlay() {
+ if (mDvrPlayer.isPlaybackPrepared()) {
+ mDvrPlayer.play();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ if (mDvrPlayer.isPlaybackPrepared()) {
+ mDvrPlayer.pause();
+ }
+ }
+
+ @Override
+ public void onFastForward() {
+ if (!mDvrPlayer.isPlaybackPrepared()) {
+ return;
+ }
+ if (mDvrPlayer.getPlaybackState() == PlaybackState.STATE_FAST_FORWARDING) {
+ if (mSpeedLevel < TimeShiftUtils.MAX_SPEED_LEVEL) {
+ mSpeedLevel++;
+ } else {
+ return;
+ }
+ } else {
+ mSpeedLevel = 0;
+ }
+ mDvrPlayer.fastForward(
+ TimeShiftUtils.getPlaybackSpeed(mSpeedLevel, mProgramDurationMs));
+ }
+
+ @Override
+ public void onRewind() {
+ if (!mDvrPlayer.isPlaybackPrepared()) {
+ return;
+ }
+ if (mDvrPlayer.getPlaybackState() == PlaybackState.STATE_REWINDING) {
+ if (mSpeedLevel < TimeShiftUtils.MAX_SPEED_LEVEL) {
+ mSpeedLevel++;
+ } else {
+ return;
+ }
+ } else {
+ mSpeedLevel = 0;
+ }
+ mDvrPlayer.rewind(TimeShiftUtils.getPlaybackSpeed(mSpeedLevel, mProgramDurationMs));
+ }
+
+ @Override
+ public void onSeekTo(long positionMs) {
+ if (mDvrPlayer.isPlaybackPrepared()) {
+ mDvrPlayer.seekTo(positionMs);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/DvrPlayer.java b/src/com/android/tv/dvr/DvrPlayer.java
new file mode 100644
index 00000000..5656655c
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrPlayer.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import android.media.PlaybackParams;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvTrackInfo;
+import android.media.tv.TvView;
+import android.media.session.PlaybackState;
+import android.util.Log;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class DvrPlayer {
+ private static final String TAG = "DvrPlayer";
+ private static final boolean DEBUG = false;
+
+ /**
+ * The max rewinding speed supported by DVR player.
+ */
+ public static final int MAX_REWIND_SPEED = 256;
+ /**
+ * The max fast-forwarding speed supported by DVR player.
+ */
+ public static final int MAX_FAST_FORWARD_SPEED = 256;
+
+ private static final long SEEK_POSITION_MARGIN_MS = TimeUnit.SECONDS.toMillis(2);
+ private static final long REWIND_POSITION_MARGIN_MS = 32; // Workaround value. b/29994826
+
+ private RecordedProgram mProgram;
+ private long mInitialSeekPositionMs;
+ private final TvView mTvView;
+ private DvrPlayerCallback mCallback;
+ private AspectRatioChangedListener mAspectRatioChangedListener;
+ private ContentBlockedListener mContentBlockedListener;
+ private float mAspectRatio = Float.NaN;
+ private int mPlaybackState = PlaybackState.STATE_NONE;
+ private long mTimeShiftCurrentPositionMs;
+ private boolean mPauseOnPrepared;
+ private final PlaybackParams mPlaybackParams = new PlaybackParams();
+ private final DvrPlayerCallback mEmptyCallback = new DvrPlayerCallback();
+ private long mStartPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
+ private boolean mTimeShiftPlayAvailable;
+
+ public static class DvrPlayerCallback {
+ /**
+ * Called when the playback position is changed. The normal updating frequency is
+ * around 1 sec., which is restricted to the implementation of
+ * {@link android.media.tv.TvInputService}.
+ */
+ public void onPlaybackPositionChanged(long positionMs) { }
+ /**
+ * Called when the playback state or the playback speed is changed.
+ */
+ public void onPlaybackStateChanged(int playbackState, int playbackSpeed) { }
+ /**
+ * Called when the playback toward the end.
+ */
+ public void onPlaybackEnded() { }
+ }
+
+ public interface AspectRatioChangedListener {
+ /**
+ * Called when the Video's aspect ratio is changed.
+ */
+ void onAspectRatioChanged(float videoAspectRatio);
+ }
+
+ public interface ContentBlockedListener {
+ /**
+ * Called when the Video's aspect ratio is changed.
+ */
+ void onContentBlocked(TvContentRating rating);
+ }
+
+ public DvrPlayer(TvView tvView) {
+ mTvView = tvView;
+ mPlaybackParams.setSpeed(1.0f);
+ setTvViewCallbacks();
+ setCallback(null);
+ }
+
+ /**
+ * Prepares playback.
+ *
+ * @param doPlay indicates DVR player do or do not start playback after media is prepared.
+ */
+ public void prepare(boolean doPlay) throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "prepare()");
+ if (mProgram == null) {
+ throw new IllegalStateException("Recorded program not set");
+ } else if (mPlaybackState != PlaybackState.STATE_NONE) {
+ throw new IllegalStateException("Playback is already prepared");
+ }
+ mTvView.timeShiftPlay(mProgram.getInputId(), mProgram.getUri());
+ mPlaybackState = PlaybackState.STATE_CONNECTING;
+ mPauseOnPrepared = !doPlay;
+ mCallback.onPlaybackStateChanged(mPlaybackState, 1);
+ }
+
+ /**
+ * Resumes playback.
+ */
+ public void play() throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "play()");
+ if (!isPlaybackPrepared()) {
+ throw new IllegalStateException("Recorded program not set or video not ready yet");
+ }
+ switch (mPlaybackState) {
+ case PlaybackState.STATE_FAST_FORWARDING:
+ case PlaybackState.STATE_REWINDING:
+ setPlaybackSpeed(1);
+ break;
+ default:
+ mTvView.timeShiftResume();
+ }
+ mPlaybackState = PlaybackState.STATE_PLAYING;
+ mCallback.onPlaybackStateChanged(mPlaybackState, 1);
+ }
+
+ /**
+ * Pauses playback.
+ */
+ public void pause() throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "pause()");
+ if (!isPlaybackPrepared()) {
+ throw new IllegalStateException("Recorded program not set or playback not started yet");
+ }
+ switch (mPlaybackState) {
+ case PlaybackState.STATE_FAST_FORWARDING:
+ case PlaybackState.STATE_REWINDING:
+ setPlaybackSpeed(1);
+ // falls through
+ case PlaybackState.STATE_PLAYING:
+ mTvView.timeShiftPause();
+ mPlaybackState = PlaybackState.STATE_PAUSED;
+ break;
+ default:
+ break;
+ }
+ mCallback.onPlaybackStateChanged(mPlaybackState, 1);
+ }
+
+ /**
+ * Fast-forwards playback with the given speed. If the given speed is larger than
+ * {@value #MAX_FAST_FORWARD_SPEED}, uses {@value #MAX_FAST_FORWARD_SPEED}.
+ */
+ public void fastForward(int speed) throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "fastForward()");
+ if (!isPlaybackPrepared()) {
+ throw new IllegalStateException("Recorded program not set or playback not started yet");
+ }
+ if (speed <= 0) {
+ throw new IllegalArgumentException("Speed cannot be negative or 0");
+ }
+ if (mTimeShiftCurrentPositionMs >= mProgram.getDurationMillis() - SEEK_POSITION_MARGIN_MS) {
+ return;
+ }
+ speed = Math.min(speed, MAX_FAST_FORWARD_SPEED);
+ if (DEBUG) Log.d(TAG, "Let's play with speed: " + speed);
+ setPlaybackSpeed(speed);
+ mPlaybackState = PlaybackState.STATE_FAST_FORWARDING;
+ mCallback.onPlaybackStateChanged(mPlaybackState, speed);
+ }
+
+ /**
+ * Rewinds playback with the given speed. If the given speed is larger than
+ * {@value #MAX_REWIND_SPEED}, uses {@value #MAX_REWIND_SPEED}.
+ */
+ public void rewind(int speed) throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "rewind()");
+ if (!isPlaybackPrepared()) {
+ throw new IllegalStateException("Recorded program not set or playback not started yet");
+ }
+ if (speed <= 0) {
+ throw new IllegalArgumentException("Speed cannot be negative or 0");
+ }
+ if (mTimeShiftCurrentPositionMs <= REWIND_POSITION_MARGIN_MS) {
+ return;
+ }
+ speed = Math.min(speed, MAX_REWIND_SPEED);
+ if (DEBUG) Log.d(TAG, "Let's play with speed: " + speed);
+ setPlaybackSpeed(-speed);
+ mPlaybackState = PlaybackState.STATE_REWINDING;
+ mCallback.onPlaybackStateChanged(mPlaybackState, speed);
+ }
+
+ /**
+ * Seeks playback to the specified position.
+ */
+ public void seekTo(long positionMs) throws IllegalStateException {
+ if (DEBUG) Log.d(TAG, "seekTo()");
+ if (!isPlaybackPrepared()) {
+ throw new IllegalStateException("Recorded program not set or playback not started yet");
+ }
+ if (mProgram == null || mPlaybackState == PlaybackState.STATE_NONE) {
+ return;
+ }
+ positionMs = getRealSeekPosition(positionMs, SEEK_POSITION_MARGIN_MS);
+ if (DEBUG) Log.d(TAG, "Now: " + getPlaybackPosition() + ", shift to: " + positionMs);
+ mTvView.timeShiftSeekTo(positionMs + mStartPositionMs);
+ if (mPlaybackState == PlaybackState.STATE_FAST_FORWARDING ||
+ mPlaybackState == PlaybackState.STATE_REWINDING) {
+ mPlaybackState = PlaybackState.STATE_PLAYING;
+ mTvView.timeShiftResume();
+ mCallback.onPlaybackStateChanged(mPlaybackState, 1);
+ }
+ }
+
+ /**
+ * Resets playback.
+ */
+ public void reset() {
+ if (DEBUG) Log.d(TAG, "reset()");
+ mCallback.onPlaybackStateChanged(PlaybackState.STATE_NONE, 1);
+ mPlaybackState = PlaybackState.STATE_NONE;
+ mTvView.reset();
+ mTimeShiftPlayAvailable = false;
+ mStartPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
+ mTimeShiftCurrentPositionMs = 0;
+ mPlaybackParams.setSpeed(1.0f);
+ mProgram = null;
+ }
+
+ /**
+ * Sets callbacks for playback.
+ */
+ public void setCallback(DvrPlayerCallback callback) {
+ if (callback != null) {
+ mCallback = callback;
+ } else {
+ mCallback = mEmptyCallback;
+ }
+ }
+
+ /**
+ * Sets listener to aspect ratio changing.
+ */
+ public void setAspectRatioChangedListener(AspectRatioChangedListener listener) {
+ mAspectRatioChangedListener = listener;
+ }
+
+ /**
+ * Sets listener to content blocking.
+ */
+ public void setContentBlockedListener(ContentBlockedListener listener) {
+ mContentBlockedListener = listener;
+ }
+
+ /**
+ * Sets recorded programs for playback. If the player is playing another program, stops it.
+ */
+ public void setProgram(RecordedProgram program, long initialSeekPositionMs) {
+ if (mProgram != null && mProgram.equals(program)) {
+ return;
+ }
+ if (mPlaybackState != PlaybackState.STATE_NONE) {
+ reset();
+ }
+ mInitialSeekPositionMs = initialSeekPositionMs;
+ mProgram = program;
+ }
+
+ /**
+ * Returns the recorded program now playing.
+ */
+ public RecordedProgram getProgram() {
+ return mProgram;
+ }
+
+ /**
+ * Returns the currrent playback posistion in msecs.
+ */
+ public long getPlaybackPosition() {
+ return mTimeShiftCurrentPositionMs;
+ }
+
+ /**
+ * Returns the playback speed currently used.
+ */
+ public int getPlaybackSpeed() {
+ return (int) mPlaybackParams.getSpeed();
+ }
+
+ /**
+ * Returns the playback state defined in {@link android.media.session.PlaybackState}.
+ */
+ public int getPlaybackState() {
+ return mPlaybackState;
+ }
+
+ /**
+ * Returns if playback of the recorded program is started.
+ */
+ public boolean isPlaybackPrepared() {
+ return mPlaybackState != PlaybackState.STATE_NONE
+ && mPlaybackState != PlaybackState.STATE_CONNECTING;
+ }
+
+ private void setPlaybackSpeed(int speed) {
+ mPlaybackParams.setSpeed(speed);
+ mTvView.timeShiftSetPlaybackParams(mPlaybackParams);
+ }
+
+ private long getRealSeekPosition(long seekPositionMs, long endMarginMs) {
+ return Math.max(0, Math.min(seekPositionMs, mProgram.getDurationMillis() - endMarginMs));
+ }
+
+ private void setTvViewCallbacks() {
+ mTvView.setTimeShiftPositionCallback(new TvView.TimeShiftPositionCallback() {
+ @Override
+ public void onTimeShiftStartPositionChanged(String inputId, long timeMs) {
+ if (DEBUG) Log.d(TAG, "onTimeShiftStartPositionChanged:" + timeMs);
+ mStartPositionMs = timeMs;
+ if (mTimeShiftPlayAvailable) {
+ resumeToWatchedPositionIfNeeded();
+ }
+ }
+
+ @Override
+ public void onTimeShiftCurrentPositionChanged(String inputId, long timeMs) {
+ if (DEBUG) Log.d(TAG, "onTimeShiftCurrentPositionChanged: " + timeMs);
+ if (!mTimeShiftPlayAvailable) {
+ // Workaround of b/31436263
+ return;
+ }
+ // Workaround of b/32211561, TIF won't report start position when TIS report
+ // its start position as 0. In that case, we have to do the prework of playback
+ // on the first time we get current position, and the start position should be 0
+ // at that time.
+ if (mStartPositionMs == TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ mStartPositionMs = 0;
+ resumeToWatchedPositionIfNeeded();
+ }
+ timeMs -= mStartPositionMs;
+ if (mPlaybackState == PlaybackState.STATE_REWINDING
+ && timeMs <= REWIND_POSITION_MARGIN_MS) {
+ play();
+ } else {
+ mTimeShiftCurrentPositionMs = getRealSeekPosition(timeMs, 0);
+ mCallback.onPlaybackPositionChanged(mTimeShiftCurrentPositionMs);
+ if (timeMs >= mProgram.getDurationMillis()) {
+ pause();
+ mCallback.onPlaybackEnded();
+ }
+ }
+ }
+ });
+ mTvView.setCallback(new TvView.TvInputCallback() {
+ @Override
+ public void onTimeShiftStatusChanged(String inputId, int status) {
+ if (DEBUG) Log.d(TAG, "onTimeShiftStatusChanged:" + status);
+ if (status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE
+ && mPlaybackState == PlaybackState.STATE_CONNECTING) {
+ mTimeShiftPlayAvailable = true;
+ }
+ }
+
+ @Override
+ public void onTrackSelected(String inputId, int type, String trackId) {
+ if (trackId == null || type != TvTrackInfo.TYPE_VIDEO
+ || mAspectRatioChangedListener == null) {
+ return;
+ }
+ List<TvTrackInfo> trackInfos = mTvView.getTracks(TvTrackInfo.TYPE_VIDEO);
+ if (trackInfos != null) {
+ for (TvTrackInfo trackInfo : trackInfos) {
+ if (trackInfo.getId().equals(trackId)) {
+ float videoAspectRatio = trackInfo.getVideoPixelAspectRatio()
+ * trackInfo.getVideoWidth() / trackInfo.getVideoHeight();
+ if (DEBUG) Log.d(TAG, "Aspect Ratio: " + videoAspectRatio);
+ if (!Float.isNaN(videoAspectRatio)
+ && mAspectRatio != videoAspectRatio) {
+ mAspectRatioChangedListener
+ .onAspectRatioChanged(videoAspectRatio);
+ mAspectRatio = videoAspectRatio;
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onContentBlocked(String inputId, TvContentRating rating) {
+ if (mContentBlockedListener != null) {
+ mContentBlockedListener.onContentBlocked(rating);
+ }
+ }
+ });
+ }
+
+ private void resumeToWatchedPositionIfNeeded() {
+ if (mInitialSeekPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ mTvView.timeShiftSeekTo(getRealSeekPosition(mInitialSeekPositionMs,
+ SEEK_POSITION_MARGIN_MS) + mStartPositionMs);
+ mInitialSeekPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
+ }
+ if (mPauseOnPrepared) {
+ mTvView.timeShiftPause();
+ mPlaybackState = PlaybackState.STATE_PAUSED;
+ mPauseOnPrepared = false;
+ } else {
+ mTvView.timeShiftResume();
+ mPlaybackState = PlaybackState.STATE_PLAYING;
+ }
+ mCallback.onPlaybackStateChanged(mPlaybackState, 1);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/DvrRecordingService.java b/src/com/android/tv/dvr/DvrRecordingService.java
index 2f3abccf..8c40aaa8 100644
--- a/src/com/android/tv/dvr/DvrRecordingService.java
+++ b/src/com/android/tv/dvr/DvrRecordingService.java
@@ -20,7 +20,6 @@ import android.app.AlarmManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
-import android.os.Binder;
import android.os.HandlerThread;
import android.os.IBinder;
import android.support.annotation.Nullable;
@@ -29,10 +28,10 @@ import android.util.Log;
import com.android.tv.ApplicationSingletons;
import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.util.Clock;
import com.android.tv.util.RecurringRunner;
-import com.android.tv.common.SoftPreconditions;
/**
* DVR Scheduler service.
@@ -60,31 +59,18 @@ public class DvrRecordingService extends Service {
private final Clock mClock = Clock.SYSTEM;
private RecurringRunner mReaperRunner;
- private WritableDvrDataManager mDataManager;
-
- /**
- * Class for clients to access. Because we know this service always
- * runs in the same process as its clients, we don't need to deal with
- * IPC.
- */
- public class SchedulerBinder extends Binder {
- Scheduler getScheduler() {
- return mScheduler;
- }
- }
-
- private final IBinder mBinder = new SchedulerBinder();
private Scheduler mScheduler;
private HandlerThread mHandlerThread;
@Override
public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, true);
if (DEBUG) Log.d(TAG, "onCreate");
super.onCreate();
SoftPreconditions.checkFeatureEnabled(this, CommonFeatures.DVR, TAG);
ApplicationSingletons singletons = TvApplication.getSingletons(this);
- mDataManager = (WritableDvrDataManager) singletons.getDvrDataManager();
+ WritableDvrDataManager dataManager = (WritableDvrDataManager) singletons.getDvrDataManager();
AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
// mScheduler may have been set for testing.
@@ -92,12 +78,13 @@ public class DvrRecordingService extends Service {
mHandlerThread = new HandlerThread(HANDLER_THREAD_NAME);
mHandlerThread.start();
mScheduler = new Scheduler(mHandlerThread.getLooper(), singletons.getDvrManager(),
- singletons.getDvrSessionManger(), mDataManager,
- singletons.getChannelDataManager(), this, mClock, alarmManager);
+ singletons.getInputSessionManager(), dataManager,
+ singletons.getChannelDataManager(), singletons.getTvInputManagerHelper(), this,
+ mClock, alarmManager);
+ mScheduler.start();
}
- mDataManager.addScheduledRecordingListener(mScheduler);
mReaperRunner = new RecurringRunner(this, java.util.concurrent.TimeUnit.DAYS.toMillis(1),
- new ScheduledProgramReaper(mDataManager, mClock), null);
+ new ScheduledProgramReaper(dataManager, mClock), null);
mReaperRunner.start();
}
@@ -112,10 +99,10 @@ public class DvrRecordingService extends Service {
public void onDestroy() {
if (DEBUG) Log.d(TAG, "onDestroy");
mReaperRunner.stop();
- mDataManager.removeScheduledRecordingListener(mScheduler);
+ mScheduler.stop();
mScheduler = null;
if (mHandlerThread != null) {
- mHandlerThread.quit();
+ mHandlerThread.quitSafely();
mHandlerThread = null;
}
super.onDestroy();
@@ -124,7 +111,7 @@ public class DvrRecordingService extends Service {
@Nullable
@Override
public IBinder onBind(Intent intent) {
- return mBinder;
+ return null;
}
@VisibleForTesting
diff --git a/src/com/android/tv/dvr/DvrScheduleManager.java b/src/com/android/tv/dvr/DvrScheduleManager.java
new file mode 100644
index 00000000..a5851a75
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrScheduleManager.java
@@ -0,0 +1,980 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.tv.TvInputInfo;
+import android.os.Build;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArraySet;
+import android.util.LongSparseArray;
+import android.util.Range;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.util.CompositeComparator;
+import com.android.tv.util.Utils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * A class to manage the schedules.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+@MainThread
+public class DvrScheduleManager {
+ private static final String TAG = "DvrScheduleManager";
+
+ /**
+ * The default priority of scheduled recording.
+ */
+ public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1;
+ /**
+ * The default priority of series recording.
+ */
+ public static final long DEFAULT_SERIES_PRIORITY = DEFAULT_PRIORITY >> 1;
+ // The new priority will have the offset from the existing one.
+ private static final long PRIORITY_OFFSET = 1024;
+
+ private static final Comparator<ScheduledRecording> RESULT_COMPARATOR =
+ new CompositeComparator<>(
+ ScheduledRecording.PRIORITY_COMPARATOR.reversed(),
+ ScheduledRecording.START_TIME_COMPARATOR,
+ ScheduledRecording.ID_COMPARATOR.reversed());
+
+ // The candidate comparator should be the consistent with
+ // InputTaskScheduler#CANDIDATE_COMPARATOR.
+ private static final Comparator<ScheduledRecording> CANDIDATE_COMPARATOR =
+ new CompositeComparator<>(
+ ScheduledRecording.PRIORITY_COMPARATOR,
+ ScheduledRecording.END_TIME_COMPARATOR,
+ ScheduledRecording.ID_COMPARATOR);
+
+ private final Context mContext;
+ private final DvrDataManagerImpl mDataManager;
+ private final ChannelDataManager mChannelDataManager;
+
+ private final Map<String, List<ScheduledRecording>> mInputScheduleMap = new HashMap<>();
+ // The inner map is a hash map from scheduled recording to its conflicting status, i.e.,
+ // the boolean value true denotes the schedule is just partially conflicting, which means
+ // although there's conflictit, it might still be recorded partially.
+ private final Map<String, Map<ScheduledRecording, Boolean>> mInputConflictInfoMap =
+ new HashMap<>();
+
+ private boolean mInitialized;
+
+ private final Set<OnInitializeListener> mOnInitializeListeners = new CopyOnWriteArraySet<>();
+ private final Set<ScheduledRecordingListener> mScheduledRecordingListeners = new ArraySet<>();
+ private final Set<OnConflictStateChangeListener> mOnConflictStateChangeListeners =
+ new ArraySet<>();
+
+ public DvrScheduleManager(Context context) {
+ mContext = context;
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ mDataManager = (DvrDataManagerImpl) appSingletons.getDvrDataManager();
+ mChannelDataManager = appSingletons.getChannelDataManager();
+ if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) {
+ buildData();
+ } else {
+ mDataManager.addDvrScheduleLoadFinishedListener(
+ new OnDvrScheduleLoadFinishedListener() {
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ mDataManager.removeDvrScheduleLoadFinishedListener(this);
+ if (mChannelDataManager.isDbLoadFinished() && !mInitialized) {
+ buildData();
+ }
+ }
+ });
+ }
+ ScheduledRecordingListener scheduledRecordingListener = new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ if (!mInitialized) {
+ return;
+ }
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ if (!schedule.isNotStarted() && !schedule.isInProgress()) {
+ continue;
+ }
+ TvInputInfo input = Utils
+ .getTvInputInfoForInputId(mContext, schedule.getInputId());
+ if (!SoftPreconditions.checkArgument(input != null, TAG,
+ "Input was removed for : " + schedule)) {
+ // Input removed.
+ mInputScheduleMap.remove(schedule.getInputId());
+ mInputConflictInfoMap.remove(schedule.getInputId());
+ continue;
+ }
+ String inputId = input.getId();
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules == null) {
+ schedules = new ArrayList<>();
+ mInputScheduleMap.put(inputId, schedules);
+ }
+ schedules.add(schedule);
+ }
+ onSchedulesChanged();
+ notifyScheduledRecordingAdded(scheduledRecordings);
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ if (!mInitialized) {
+ return;
+ }
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ TvInputInfo input = Utils
+ .getTvInputInfoForInputId(mContext, schedule.getInputId());
+ if (input == null) {
+ // Input removed.
+ mInputScheduleMap.remove(schedule.getInputId());
+ mInputConflictInfoMap.remove(schedule.getInputId());
+ continue;
+ }
+ String inputId = input.getId();
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules != null) {
+ schedules.remove(schedule);
+ if (schedules.isEmpty()) {
+ mInputScheduleMap.remove(inputId);
+ }
+ }
+ Map<ScheduledRecording, Boolean> conflictInfo =
+ mInputConflictInfoMap.get(inputId);
+ if (conflictInfo != null) {
+ conflictInfo.remove(schedule);
+ if (conflictInfo.isEmpty()) {
+ mInputConflictInfoMap.remove(inputId);
+ }
+ }
+ }
+ onSchedulesChanged();
+ notifyScheduledRecordingRemoved(scheduledRecordings);
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(
+ ScheduledRecording... scheduledRecordings) {
+ if (!mInitialized) {
+ return;
+ }
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ TvInputInfo input = Utils
+ .getTvInputInfoForInputId(mContext, schedule.getInputId());
+ if (!SoftPreconditions.checkArgument(input != null, TAG,
+ "Input was removed for : " + schedule)) {
+ // Input removed.
+ mInputScheduleMap.remove(schedule.getInputId());
+ mInputConflictInfoMap.remove(schedule.getInputId());
+ continue;
+ }
+ String inputId = input.getId();
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules == null) {
+ schedules = new ArrayList<>();
+ mInputScheduleMap.put(inputId, schedules);
+ }
+ // Compare ID because ScheduledRecording.equals() doesn't work if the state
+ // is changed.
+ for (Iterator<ScheduledRecording> i = schedules.iterator(); i.hasNext(); ) {
+ if (i.next().getId() == schedule.getId()) {
+ i.remove();
+ break;
+ }
+ }
+ if (schedule.isNotStarted() || schedule.isInProgress()) {
+ schedules.add(schedule);
+ }
+ if (schedules.isEmpty()) {
+ mInputScheduleMap.remove(inputId);
+ }
+ // Update conflict list as well
+ Map<ScheduledRecording, Boolean> conflictInfo =
+ mInputConflictInfoMap.get(inputId);
+ if (conflictInfo != null) {
+ // Compare ID because ScheduledRecording.equals() doesn't work if the state
+ // is changed.
+ ScheduledRecording oldSchedule = null;
+ for (ScheduledRecording s : conflictInfo.keySet()) {
+ if (s.getId() == schedule.getId()) {
+ oldSchedule = s;
+ break;
+ }
+ }
+ if (oldSchedule != null) {
+ conflictInfo.put(schedule, conflictInfo.get(oldSchedule));
+ conflictInfo.remove(oldSchedule);
+ }
+ }
+ }
+ onSchedulesChanged();
+ notifyScheduledRecordingStatusChanged(scheduledRecordings);
+ }
+ };
+ mDataManager.addScheduledRecordingListener(scheduledRecordingListener);
+ ChannelDataManager.Listener channelDataManagerListener = new ChannelDataManager.Listener() {
+ @Override
+ public void onLoadFinished() {
+ if (mDataManager.isDvrScheduleLoadFinished() && !mInitialized) {
+ buildData();
+ }
+ }
+
+ @Override
+ public void onChannelListUpdated() {
+ if (mDataManager.isDvrScheduleLoadFinished()) {
+ buildData();
+ }
+ }
+
+ @Override
+ public void onChannelBrowsableChanged() {
+ }
+ };
+ mChannelDataManager.addListener(channelDataManagerListener);
+ }
+
+ /**
+ * Returns the started recordings for the given input.
+ */
+ private List<ScheduledRecording> getStartedRecordings(String inputId) {
+ if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
+ return Collections.emptyList();
+ }
+ List<ScheduledRecording> result = new ArrayList<>();
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules != null) {
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ result.add(schedule);
+ }
+ }
+ }
+ return result;
+ }
+
+ private void buildData() {
+ mInputScheduleMap.clear();
+ for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
+ if (!schedule.isNotStarted() && !schedule.isInProgress()) {
+ continue;
+ }
+ Channel channel = mChannelDataManager.getChannel(schedule.getChannelId());
+ if (channel != null) {
+ String inputId = channel.getInputId();
+ // Do not check whether the input is valid or not. The input might be temporarily
+ // invalid.
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules == null) {
+ schedules = new ArrayList<>();
+ mInputScheduleMap.put(inputId, schedules);
+ }
+ schedules.add(schedule);
+ }
+ }
+ if (!mInitialized) {
+ mInitialized = true;
+ notifyInitialize();
+ }
+ onSchedulesChanged();
+ }
+
+ private void onSchedulesChanged() {
+ // TODO: notify conflict state change when some conflicting recording becomes partially
+ // conflicting, vice versa.
+ List<ScheduledRecording> addedConflicts = new ArrayList<>();
+ List<ScheduledRecording> removedConflicts = new ArrayList<>();
+ for (String inputId : mInputScheduleMap.keySet()) {
+ Map<ScheduledRecording, Boolean> oldConflictsInfo = mInputConflictInfoMap.get(inputId);
+ Map<Long, ScheduledRecording> oldConflictMap = new HashMap<>();
+ if (oldConflictsInfo != null) {
+ for (ScheduledRecording r : oldConflictsInfo.keySet()) {
+ oldConflictMap.put(r.getId(), r);
+ }
+ }
+ Map<ScheduledRecording, Boolean> conflictInfo = getConflictingSchedulesInfo(inputId);
+ if (conflictInfo.isEmpty()) {
+ mInputConflictInfoMap.remove(inputId);
+ } else {
+ mInputConflictInfoMap.put(inputId, conflictInfo);
+ List<ScheduledRecording> conflicts = new ArrayList<>(conflictInfo.keySet());
+ for (ScheduledRecording r : conflicts) {
+ if (oldConflictMap.remove(r.getId()) == null) {
+ addedConflicts.add(r);
+ }
+ }
+ }
+ removedConflicts.addAll(oldConflictMap.values());
+ }
+ if (!removedConflicts.isEmpty()) {
+ notifyConflictStateChange(false, ScheduledRecording.toArray(removedConflicts));
+ }
+ if (!addedConflicts.isEmpty()) {
+ notifyConflictStateChange(true, ScheduledRecording.toArray(addedConflicts));
+ }
+ }
+
+ /**
+ * Returns {@code true} if this class has been initialized.
+ */
+ public boolean isInitialized() {
+ return mInitialized;
+ }
+
+ /**
+ * Adds a {@link ScheduledRecordingListener}.
+ */
+ public final void addScheduledRecordingListener(ScheduledRecordingListener listener) {
+ mScheduledRecordingListeners.add(listener);
+ }
+
+ /**
+ * Removes a {@link ScheduledRecordingListener}.
+ */
+ public final void removeScheduledRecordingListener(ScheduledRecordingListener listener) {
+ mScheduledRecordingListeners.remove(listener);
+ }
+
+ /**
+ * Calls {@link ScheduledRecordingListener#onScheduledRecordingAdded} for each listener.
+ */
+ private void notifyScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
+ l.onScheduledRecordingAdded(scheduledRecordings);
+ }
+ }
+
+ /**
+ * Calls {@link ScheduledRecordingListener#onScheduledRecordingRemoved} for each listener.
+ */
+ private void notifyScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
+ l.onScheduledRecordingRemoved(scheduledRecordings);
+ }
+ }
+
+ /**
+ * Calls {@link ScheduledRecordingListener#onScheduledRecordingStatusChanged} for each listener.
+ */
+ private void notifyScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecordingListener l : mScheduledRecordingListeners) {
+ l.onScheduledRecordingStatusChanged(scheduledRecordings);
+ }
+ }
+
+ /**
+ * Adds a {@link OnInitializeListener}.
+ */
+ public final void addOnInitializeListener(OnInitializeListener listener) {
+ mOnInitializeListeners.add(listener);
+ }
+
+ /**
+ * Removes a {@link OnInitializeListener}.
+ */
+ public final void removeOnInitializeListener(OnInitializeListener listener) {
+ mOnInitializeListeners.remove(listener);
+ }
+
+ /**
+ * Calls {@link OnInitializeListener#onInitialize} for each listener.
+ */
+ private void notifyInitialize() {
+ for (OnInitializeListener l : mOnInitializeListeners) {
+ l.onInitialize();
+ }
+ }
+
+ /**
+ * Adds a {@link OnConflictStateChangeListener}.
+ */
+ public final void addOnConflictStateChangeListener(OnConflictStateChangeListener listener) {
+ mOnConflictStateChangeListeners.add(listener);
+ }
+
+ /**
+ * Removes a {@link OnConflictStateChangeListener}.
+ */
+ public final void removeOnConflictStateChangeListener(OnConflictStateChangeListener listener) {
+ mOnConflictStateChangeListeners.remove(listener);
+ }
+
+ /**
+ * Calls {@link OnConflictStateChangeListener#onConflictStateChange} for each listener.
+ */
+ private void notifyConflictStateChange(boolean conflict,
+ ScheduledRecording... scheduledRecordings) {
+ for (OnConflictStateChangeListener l : mOnConflictStateChangeListeners) {
+ l.onConflictStateChange(conflict, scheduledRecordings);
+ }
+ }
+
+ /**
+ * Returns the priority for the program if it is recorded.
+ * <p>
+ * The recording will have the higher priority than the existing ones.
+ */
+ public long suggestNewPriority() {
+ if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
+ return DEFAULT_PRIORITY;
+ }
+ return suggestHighestPriority();
+ }
+
+ private long suggestHighestPriority() {
+ long highestPriority = DEFAULT_PRIORITY - PRIORITY_OFFSET;
+ for (ScheduledRecording schedule : mDataManager.getAllScheduledRecordings()) {
+ if (schedule.getPriority() > highestPriority) {
+ highestPriority = schedule.getPriority();
+ }
+ }
+ return highestPriority + PRIORITY_OFFSET;
+ }
+
+ /**
+ * Suggests the higher priority than the schedules which overlap with {@code schedule}.
+ */
+ public long suggestHighestPriority(ScheduledRecording schedule) {
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(schedule.getInputId());
+ if (schedules == null) {
+ return DEFAULT_PRIORITY;
+ }
+ long highestPriority = Long.MIN_VALUE;
+ for (ScheduledRecording r : schedules) {
+ if (!r.equals(schedule) && r.isOverLapping(schedule)
+ && r.getPriority() > highestPriority) {
+ highestPriority = r.getPriority();
+ }
+ }
+ if (highestPriority == Long.MIN_VALUE || highestPriority < schedule.getPriority()) {
+ return schedule.getPriority();
+ }
+ return highestPriority + PRIORITY_OFFSET;
+ }
+
+ /**
+ * Suggests the higher priority than the schedules which overlap with {@code schedule}.
+ */
+ public long suggestHighestPriority(String inputId, Range<Long> peroid, long basePriority) {
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(inputId);
+ if (schedules == null) {
+ return DEFAULT_PRIORITY;
+ }
+ long highestPriority = Long.MIN_VALUE;
+ for (ScheduledRecording r : schedules) {
+ if (r.isOverLapping(peroid) && r.getPriority() > highestPriority) {
+ highestPriority = r.getPriority();
+ }
+ }
+ if (highestPriority == Long.MIN_VALUE || highestPriority < basePriority) {
+ return basePriority;
+ }
+ return highestPriority + PRIORITY_OFFSET;
+ }
+
+ /**
+ * Returns the priority for a series recording.
+ * <p>
+ * The recording will have the higher priority than the existing series.
+ */
+ public long suggestNewSeriesPriority() {
+ if (!SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet")) {
+ return DEFAULT_SERIES_PRIORITY;
+ }
+ return suggestHighestSeriesPriority();
+ }
+
+ /**
+ * Returns the priority for a series recording by order of series recording priority.
+ *
+ * Higher order will have higher priority.
+ */
+ public static long suggestSeriesPriority(int order) {
+ return DEFAULT_SERIES_PRIORITY + order * PRIORITY_OFFSET;
+ }
+
+ private long suggestHighestSeriesPriority() {
+ long highestPriority = DEFAULT_SERIES_PRIORITY - PRIORITY_OFFSET;
+ for (SeriesRecording schedule : mDataManager.getSeriesRecordings()) {
+ if (schedule.getPriority() > highestPriority) {
+ highestPriority = schedule.getPriority();
+ }
+ }
+ return highestPriority + PRIORITY_OFFSET;
+ }
+
+ /**
+ * Returns a sorted list of all scheduled recordings that will not be recorded if
+ * this program is going to be recorded, with their priorities in decending order.
+ * <p>
+ * An empty list means there is no conflicts. If there is conflict, a priority higher than
+ * the first recording in the returned list should be assigned to the new schedule of this
+ * program to guarantee the program would be completely recorded.
+ */
+ public List<ScheduledRecording> getConflictingSchedules(Program program) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ SoftPreconditions.checkState(Program.isValid(program), TAG,
+ "Program is invalid: " + program);
+ SoftPreconditions.checkState(
+ program.getStartTimeUtcMillis() < program.getEndTimeUtcMillis(), TAG,
+ "Program duration is empty: " + program);
+ if (!mInitialized || !Program.isValid(program)
+ || program.getStartTimeUtcMillis() >= program.getEndTimeUtcMillis()) {
+ return Collections.emptyList();
+ }
+ TvInputInfo input = Utils.getTvInputInfoForProgram(mContext, program);
+ if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
+ return Collections.emptyList();
+ }
+ return getConflictingSchedules(input, Collections.singletonList(
+ ScheduledRecording.builder(input.getId(), program)
+ .setPriority(suggestHighestPriority())
+ .build()));
+ }
+
+ /**
+ * Returns list of all conflicting scheduled recordings with schedules belonging to {@code
+ * seriesRecording}
+ * recording.
+ * <p>
+ * Any empty list means there is no conflicts.
+ */
+ public List<ScheduledRecording> getConflictingSchedules(SeriesRecording seriesRecording) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ SoftPreconditions.checkState(seriesRecording != null, TAG, "series recording is null");
+ if (!mInitialized || seriesRecording == null) {
+ return Collections.emptyList();
+ }
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, seriesRecording.getInputId());
+ if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
+ return Collections.emptyList();
+ }
+ List<ScheduledRecording> schedulesForSeries = mDataManager.getScheduledRecordings(
+ seriesRecording.getId());
+ return getConflictingSchedules(input, schedulesForSeries);
+ }
+
+ /**
+ * Returns a sorted list of all scheduled recordings that will not be recorded if
+ * this channel is going to be recorded, with their priority in decending order.
+ * <p>
+ * An empty list means there is no conflicts. If there is conflict, a priority higher than
+ * the first recording in the returned list should be assigned to the new schedule of this
+ * channel to guarantee the channel would be completely recorded in the designated time range.
+ */
+ public List<ScheduledRecording> getConflictingSchedules(long channelId, long startTimeMs,
+ long endTimeMs) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
+ SoftPreconditions.checkState(startTimeMs < endTimeMs, TAG, "Recording duration is empty.");
+ if (!mInitialized || channelId == Channel.INVALID_ID || startTimeMs >= endTimeMs) {
+ return Collections.emptyList();
+ }
+ TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
+ if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
+ return Collections.emptyList();
+ }
+ return getConflictingSchedules(input, Collections.singletonList(
+ ScheduledRecording.builder(input.getId(), channelId, startTimeMs, endTimeMs)
+ .setPriority(suggestHighestPriority())
+ .build()));
+ }
+
+ /**
+ * Returns all the scheduled recordings that conflicts and will not be recorded or clipped for
+ * the given input.
+ */
+ @NonNull
+ private Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(String inputId) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, inputId);
+ SoftPreconditions.checkState(input != null, TAG, "Can't find input for : " + inputId);
+ if (!mInitialized || input == null) {
+ return Collections.emptyMap();
+ }
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId());
+ if (schedules == null || schedules.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ return getConflictingSchedulesInfo(schedules, input.getTunerCount());
+ }
+
+ /**
+ * Checks if the schedule is conflicting.
+ *
+ * <p>Note that the {@code schedule} should be the existing one. If not, this returns
+ * {@code false}.
+ */
+ public boolean isConflicting(ScheduledRecording schedule) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
+ SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : "
+ + schedule.getChannelId());
+ if (!mInitialized || input == null) {
+ return false;
+ }
+ Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId());
+ return conflicts != null && conflicts.containsKey(schedule);
+ }
+
+ /**
+ * Checks if the schedule is partially conflicting, i.e., part of the scheduled program might be
+ * recorded even if the priority of the schedule is not raised.
+ * <p>
+ * If the given schedule is not conflicting or is totally conflicting, i.e., cannot be recorded
+ * at all, this method returns {@code false} in both cases.
+ */
+ public boolean isPartiallyConflicting(@NonNull ScheduledRecording schedule) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
+ SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID : "
+ + schedule.getChannelId());
+ if (!mInitialized || input == null) {
+ return false;
+ }
+ Map<ScheduledRecording, Boolean> conflicts = mInputConflictInfoMap.get(input.getId());
+ return conflicts != null && conflicts.getOrDefault(schedule, false);
+ }
+
+ /**
+ * Returns priority ordered list of all scheduled recordings that will not be recorded if
+ * this channel is tuned to.
+ */
+ public List<ScheduledRecording> getConflictingSchedulesForTune(long channelId) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
+ TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
+ SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: "
+ + channelId);
+ if (!mInitialized || channelId == Channel.INVALID_ID || input == null) {
+ return Collections.emptyList();
+ }
+ return getConflictingSchedulesForTune(input.getId(), channelId, System.currentTimeMillis(),
+ suggestHighestPriority(), getStartedRecordings(input.getId()),
+ input.getTunerCount());
+ }
+
+ @VisibleForTesting
+ public static List<ScheduledRecording> getConflictingSchedulesForTune(String inputId,
+ long channelId, long currentTimeMs, long newPriority,
+ List<ScheduledRecording> startedRecordings, int tunerCount) {
+ boolean channelFound = false;
+ for (ScheduledRecording schedule : startedRecordings) {
+ if (schedule.getChannelId() == channelId) {
+ channelFound = true;
+ break;
+ }
+ }
+ List<ScheduledRecording> schedules;
+ if (!channelFound) {
+ // The current channel is not being recorded.
+ schedules = new ArrayList<>(startedRecordings);
+ schedules.add(ScheduledRecording
+ .builder(inputId, channelId, currentTimeMs, currentTimeMs + 1)
+ .setPriority(newPriority)
+ .build());
+ } else {
+ schedules = startedRecordings;
+ }
+ return getConflictingSchedules(schedules, tunerCount);
+ }
+
+ /**
+ * Returns priority ordered list of all scheduled recordings that will not be recorded if
+ * the user keeps watching this channel.
+ * <p>
+ * Note that if the user keeps watching the channel, the channel can be recorded.
+ */
+ public List<ScheduledRecording> getConflictingSchedulesForWatching(long channelId) {
+ SoftPreconditions.checkState(mInitialized, TAG, "Not initialized yet");
+ SoftPreconditions.checkState(channelId != Channel.INVALID_ID, TAG, "Invalid channel ID");
+ TvInputInfo input = Utils.getTvInputInfoForChannelId(mContext, channelId);
+ SoftPreconditions.checkState(input != null, TAG, "Can't find input for channel ID: "
+ + channelId);
+ if (!mInitialized || channelId == Channel.INVALID_ID || input == null) {
+ return Collections.emptyList();
+ }
+ List<ScheduledRecording> schedules = mInputScheduleMap.get(input.getId());
+ if (schedules == null || schedules.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return getConflictingSchedulesForWatching(input.getId(), channelId,
+ System.currentTimeMillis(), suggestNewPriority(), schedules, input.getTunerCount());
+ }
+
+ private List<ScheduledRecording> getConflictingSchedules(TvInputInfo input,
+ List<ScheduledRecording> schedulesToAdd) {
+ SoftPreconditions.checkNotNull(input);
+ if (input == null || !input.canRecord() || input.getTunerCount() <= 0) {
+ return Collections.emptyList();
+ }
+ List<ScheduledRecording> currentSchedules = mInputScheduleMap.get(input.getId());
+ if (currentSchedules == null || currentSchedules.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return getConflictingSchedules(schedulesToAdd, currentSchedules, input.getTunerCount());
+ }
+
+ @VisibleForTesting
+ static List<ScheduledRecording> getConflictingSchedulesForWatching(String inputId,
+ long channelId, long currentTimeMs, long newPriority,
+ @NonNull List<ScheduledRecording> schedules, int tunerCount) {
+ List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
+ List<ScheduledRecording> schedulesSameChannel = new ArrayList<>();
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getChannelId() == channelId) {
+ schedulesSameChannel.add(schedule);
+ schedulesToCheck.remove(schedule);
+ }
+ }
+ // Assume that the user will watch the current channel forever.
+ schedulesToCheck.add(ScheduledRecording
+ .builder(inputId, channelId, currentTimeMs, Long.MAX_VALUE)
+ .setPriority(newPriority)
+ .build());
+ List<ScheduledRecording> result = new ArrayList<>();
+ result.addAll(getConflictingSchedules(schedulesSameChannel, 1));
+ result.addAll(getConflictingSchedules(schedulesToCheck, tunerCount));
+ Collections.sort(result, RESULT_COMPARATOR);
+ return result;
+ }
+
+ @VisibleForTesting
+ static List<ScheduledRecording> getConflictingSchedules(List<ScheduledRecording> schedulesToAdd,
+ List<ScheduledRecording> currentSchedules, int tunerCount) {
+ List<ScheduledRecording> schedulesToCheck = new ArrayList<>(currentSchedules);
+ // When the duplicate schedule is to be added, remove the current duplicate recording.
+ for (Iterator<ScheduledRecording> iter = schedulesToCheck.iterator(); iter.hasNext(); ) {
+ ScheduledRecording schedule = iter.next();
+ for (ScheduledRecording toAdd : schedulesToAdd) {
+ if (schedule.getType() == ScheduledRecording.TYPE_PROGRAM) {
+ if (toAdd.getProgramId() == schedule.getProgramId()) {
+ iter.remove();
+ break;
+ }
+ } else {
+ if (toAdd.getChannelId() == schedule.getChannelId()
+ && toAdd.getStartTimeMs() == schedule.getStartTimeMs()
+ && toAdd.getEndTimeMs() == schedule.getEndTimeMs()) {
+ iter.remove();
+ break;
+ }
+ }
+ }
+ }
+ schedulesToCheck.addAll(schedulesToAdd);
+ List<Range<Long>> ranges = new ArrayList<>();
+ for (ScheduledRecording schedule : schedulesToAdd) {
+ ranges.add(new Range<>(schedule.getStartTimeMs(), schedule.getEndTimeMs()));
+ }
+ return getConflictingSchedules(schedulesToCheck, tunerCount, ranges);
+ }
+
+ /**
+ * Returns all conflicting scheduled recordings for the given schedules and count of tuner.
+ */
+ public static List<ScheduledRecording> getConflictingSchedules(
+ List<ScheduledRecording> schedules, int tunerCount) {
+ return getConflictingSchedules(schedules, tunerCount, null);
+ }
+
+ @VisibleForTesting
+ static List<ScheduledRecording> getConflictingSchedules(
+ List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
+ List<ScheduledRecording> result = new ArrayList<>(
+ getConflictingSchedulesInfo(schedules, tunerCount, periods).keySet());
+ Collections.sort(result, RESULT_COMPARATOR);
+ return result;
+ }
+
+ @VisibleForTesting
+ static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(
+ List<ScheduledRecording> schedules, int tunerCount) {
+ return getConflictingSchedulesInfo(schedules, tunerCount, null);
+ }
+
+ /**
+ * This is the core method to calculate all the conflicting schedules (in given periods).
+ * <p>
+ * Note that this method will ignore duplicated schedules with a same hash code. (Please refer
+ * to {@link ScheduledRecording#hashCode}.)
+ *
+ * @return A {@link HashMap} from {@link ScheduledRecording} to {@link Boolean}. The boolean
+ * value denotes if the scheduled recording is partially conflicting, i.e., is possible
+ * to be partially recorded under the given schedules and tuner count {@code true},
+ * or not {@code false}.
+ */
+ private static Map<ScheduledRecording, Boolean> getConflictingSchedulesInfo(
+ List<ScheduledRecording> schedules, int tunerCount, List<Range<Long>> periods) {
+ List<ScheduledRecording> schedulesToCheck = new ArrayList<>(schedules);
+ // Sort by the same order as that in InputTaskScheduler.
+ Collections.sort(schedulesToCheck, InputTaskScheduler.getRecordingOrderComparator());
+ List<ScheduledRecording> recordings = new ArrayList<>();
+ Map<ScheduledRecording, Boolean> conflicts = new HashMap<>();
+ Map<ScheduledRecording, ScheduledRecording> modified2OriginalSchedules = new HashMap<>();
+ // Simulate InputTaskScheduler.
+ while (!schedulesToCheck.isEmpty()) {
+ ScheduledRecording schedule = schedulesToCheck.remove(0);
+ removeFinishedRecordings(recordings, schedule.getStartTimeMs());
+ if (recordings.size() < tunerCount) {
+ recordings.add(schedule);
+ if (modified2OriginalSchedules.containsKey(schedule)) {
+ // Schedule has been modified, which means it's already conflicted.
+ // Modify its state to partially conflicted.
+ conflicts.put(modified2OriginalSchedules.get(schedule), true);
+ }
+ } else {
+ ScheduledRecording candidate = findReplaceableRecording(recordings, schedule);
+ if (candidate != null) {
+ if (!modified2OriginalSchedules.containsKey(candidate)) {
+ conflicts.put(candidate, true);
+ }
+ recordings.remove(candidate);
+ recordings.add(schedule);
+ if (modified2OriginalSchedules.containsKey(schedule)) {
+ // Schedule has been modified, which means it's already conflicted.
+ // Modify its state to partially conflicted.
+ conflicts.put(modified2OriginalSchedules.get(schedule), true);
+ }
+ } else {
+ if (!modified2OriginalSchedules.containsKey(schedule)) {
+ // if schedule has been modified, it's already conflicted.
+ // No need to add it again.
+ conflicts.put(schedule, false);
+ }
+ long earliestEndTime = getEarliestEndTime(recordings);
+ if (earliestEndTime < schedule.getEndTimeMs()) {
+ // The schedule can starts when other recording ends even though it's
+ // clipped.
+ ScheduledRecording modifiedSchedule = ScheduledRecording.buildFrom(schedule)
+ .setStartTimeMs(earliestEndTime).build();
+ ScheduledRecording originalSchedule =
+ modified2OriginalSchedules.getOrDefault(schedule, schedule);
+ modified2OriginalSchedules.put(modifiedSchedule, originalSchedule);
+ int insertPosition = Collections.binarySearch(schedulesToCheck,
+ modifiedSchedule,
+ ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR);
+ if (insertPosition >= 0) {
+ schedulesToCheck.add(insertPosition, modifiedSchedule);
+ } else {
+ schedulesToCheck.add(-insertPosition - 1, modifiedSchedule);
+ }
+ }
+ }
+ }
+ }
+ // Returns only the schedules with the given range.
+ if (periods != null && !periods.isEmpty()) {
+ for (Iterator<ScheduledRecording> iter = conflicts.keySet().iterator();
+ iter.hasNext(); ) {
+ boolean overlapping = false;
+ ScheduledRecording schedule = iter.next();
+ for (Range<Long> period : periods) {
+ if (schedule.isOverLapping(period)) {
+ overlapping = true;
+ break;
+ }
+ }
+ if (!overlapping) {
+ iter.remove();
+ }
+ }
+ }
+ return conflicts;
+ }
+
+ private static void removeFinishedRecordings(List<ScheduledRecording> recordings,
+ long currentTimeMs) {
+ for (Iterator<ScheduledRecording> iter = recordings.iterator(); iter.hasNext(); ) {
+ if (iter.next().getEndTimeMs() <= currentTimeMs) {
+ iter.remove();
+ }
+ }
+ }
+
+ /**
+ * @see InputTaskScheduler#getReplacableTask
+ */
+ private static ScheduledRecording findReplaceableRecording(List<ScheduledRecording> recordings,
+ ScheduledRecording schedule) {
+ // Returns the recording with the following priority.
+ // 1. The recording with the lowest priority is returned.
+ // 2. If the priorities are the same, the recording which finishes early is returned.
+ // 3. If 1) and 2) are the same, the early created schedule is returned.
+ ScheduledRecording candidate = null;
+ for (ScheduledRecording recording : recordings) {
+ if (schedule.getPriority() > recording.getPriority()) {
+ if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, recording) > 0) {
+ candidate = recording;
+ }
+ }
+ }
+ return candidate;
+ }
+
+ private static long getEarliestEndTime(List<ScheduledRecording> recordings) {
+ long earliest = Long.MAX_VALUE;
+ for (ScheduledRecording recording : recordings) {
+ if (earliest > recording.getEndTimeMs()) {
+ earliest = recording.getEndTimeMs();
+ }
+ }
+ return earliest;
+ }
+
+ /**
+ * A listener which is notified the initialization of schedule manager.
+ */
+ public interface OnInitializeListener {
+ /**
+ * Called when the schedule manager has been initialized.
+ */
+ void onInitialize();
+ }
+
+ /**
+ * A listener which is notified the conflict state change of the schedules.
+ */
+ public interface OnConflictStateChangeListener {
+ /**
+ * Called when the conflicting schedules change.
+ *
+ * @param conflict {@code true} if the {@code schedules} are the new conflicts, otherwise
+ * {@code false}.
+ * @param schedules the schedules
+ */
+ void onConflictStateChange(boolean conflict, ScheduledRecording... schedules);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/DvrSessionManager.java b/src/com/android/tv/dvr/DvrSessionManager.java
deleted file mode 100644
index fba05cb6..00000000
--- a/src/com/android/tv/dvr/DvrSessionManager.java
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License
- */
-
-package com.android.tv.dvr;
-
-import android.annotation.TargetApi;
-import android.content.Context;
-import android.media.tv.TvInputInfo;
-import android.media.tv.TvInputManager;
-import android.media.tv.TvRecordingClient;
-import android.os.Build;
-import android.os.Handler;
-import android.support.annotation.Nullable;
-import android.support.annotation.VisibleForTesting;
-import android.support.v4.util.ArrayMap;
-import android.util.Log;
-
-import com.android.tv.common.SoftPreconditions;
-import com.android.tv.common.feature.CommonFeatures;
-import com.android.tv.data.Channel;
-
-/**
- * Manages Dvr Sessions.
- * Responsible for:
- * <ul>
- * <li>Manage DvrSession</li>
- * <li>Manage capabilities (conflict)</li>
- * </ul>
- */
-@TargetApi(Build.VERSION_CODES.N)
-public class DvrSessionManager extends TvInputManager.TvInputCallback {
- //consider moving all of this to TvInputManagerHelper
- private final static String TAG = "DvrSessionManager";
- private static final boolean DEBUG = false;
-
- private final Context mContext;
- private final TvInputManager mTvInputManager;
- private final ArrayMap<String, TvInputInfo> mRecordingTvInputs = new ArrayMap<>();
-
- public DvrSessionManager(Context context) {
- this(context, (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE),
- new Handler());
- }
-
- @VisibleForTesting
- DvrSessionManager(Context context, TvInputManager tvInputManager, Handler handler) {
- SoftPreconditions.checkFeatureEnabled(context, CommonFeatures.DVR, TAG);
- mTvInputManager = tvInputManager;
- mContext = context.getApplicationContext();
- for (TvInputInfo info : tvInputManager.getTvInputList()) {
- if (DEBUG) {
- Log.d(TAG, info + " canRecord=" + info.canRecord() + " tunerCount=" + info
- .getTunerCount());
- }
- if (info.canRecord()) {
- mRecordingTvInputs.put(info.getId(), info);
- }
- }
- tvInputManager.registerCallback(this, handler);
-
- }
-
- public TvRecordingClient createTvRecordingClient(String tag,
- TvRecordingClient.RecordingCallback callback, Handler handler) {
- return new TvRecordingClient(mContext, tag, callback, handler);
- }
-
- public boolean canAcquireDvrSession(String inputId, Channel channel) {
- // TODO(DVR): implement checking tuner count etc.
- TvInputInfo info = mRecordingTvInputs.get(inputId);
- return info != null;
- }
-
- public void releaseTvRecordingClient(TvRecordingClient recordingClient) {
- recordingClient.release();
- }
-
- @Override
- public void onInputAdded(String inputId) {
- super.onInputAdded(inputId);
- TvInputInfo info = mTvInputManager.getTvInputInfo(inputId);
- if (DEBUG) {
- Log.d(TAG, "onInputAdded " + info.toString() + " canRecord=" + info.canRecord()
- + " tunerCount=" + info.getTunerCount());
- }
- if (info.canRecord()) {
- mRecordingTvInputs.put(inputId, info);
- }
- }
-
- @Override
- public void onInputRemoved(String inputId) {
- super.onInputRemoved(inputId);
- if (DEBUG) Log.d(TAG, "onInputRemoved " + inputId);
- mRecordingTvInputs.remove(inputId);
- }
-
- @Override
- public void onInputUpdated(String inputId) {
- super.onInputUpdated(inputId);
- TvInputInfo info = mTvInputManager.getTvInputInfo(inputId);
- if (DEBUG) {
- Log.d(TAG, "onInputUpdated " + info.toString() + " canRecord=" + info.canRecord()
- + " tunerCount=" + info.getTunerCount());
- }
- if (info.canRecord()) {
- mRecordingTvInputs.put(inputId, info);
- } else {
- mRecordingTvInputs.remove(inputId);
- }
- }
-
- @Nullable
- public TvInputInfo getTvInputInfo(String inputId) {
- return mRecordingTvInputs.get(inputId);
- }
-}
diff --git a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java b/src/com/android/tv/dvr/DvrStartRecordingReceiver.java
index 3649ad1e..6d2f0d43 100644
--- a/src/com/android/tv/dvr/DvrStartRecordingReceiver.java
+++ b/src/com/android/tv/dvr/DvrStartRecordingReceiver.java
@@ -16,6 +16,8 @@
package com.android.tv.dvr;
+import com.android.tv.TvApplication;
+
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@@ -26,6 +28,7 @@ import android.content.Intent;
public class DvrStartRecordingReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
+ TvApplication.setCurrentRunningProcess(context, true);
DvrRecordingService.startService(context);
}
}
diff --git a/src/com/android/tv/dvr/DvrStorageStatusManager.java b/src/com/android/tv/dvr/DvrStorageStatusManager.java
new file mode 100644
index 00000000..a653b5f4
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrStorageStatusManager.java
@@ -0,0 +1,376 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import android.content.BroadcastReceiver;
+import android.content.ContentProviderOperation;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.OperationApplicationException;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Environment;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.StatFs;
+import android.support.annotation.AnyThread;
+import android.support.annotation.IntDef;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * Signals DVR storage status change such as plugging/unplugging.
+ */
+public class DvrStorageStatusManager {
+ private static final String TAG = "DvrStorageStatusManager";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Minimum storage size to support DVR
+ */
+ public static final long MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES = 50 * 1024 * 1024 * 1024L; // 50GB
+ private static final long MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES
+ = 10 * 1024 * 1024 * 1024L; // 10GB
+ private static final String RECORDING_DATA_SUB_PATH = "/recording";
+
+ private static final String[] PROJECTION = {
+ TvContract.RecordedPrograms._ID,
+ TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI
+ };
+ private final static int BATCH_OPERATION_COUNT = 100;
+
+ @IntDef({STORAGE_STATUS_OK, STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL,
+ STORAGE_STATUS_FREE_SPACE_INSUFFICIENT, STORAGE_STATUS_MISSING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface StorageStatus {
+ }
+
+ /**
+ * Current storage is OK to record a program.
+ */
+ public static final int STORAGE_STATUS_OK = 0;
+
+ /**
+ * Current storage's total capacity is smaller than DVR requirement.
+ */
+ public static final int STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL = 1;
+
+ /**
+ * Current storage's free space is insufficient to record programs.
+ */
+ public static final int STORAGE_STATUS_FREE_SPACE_INSUFFICIENT = 2;
+
+ /**
+ * Current storage is missing.
+ */
+ public static final int STORAGE_STATUS_MISSING = 3;
+
+ private final Context mContext;
+ private final Set<OnStorageMountChangedListener> mOnStorageMountChangedListeners =
+ new CopyOnWriteArraySet<>();
+ private final boolean mRunningInMainProcess;
+ private MountedStorageStatus mMountedStorageStatus;
+ private boolean mStorageValid;
+ private CleanUpDbTask mCleanUpDbTask;
+
+ private class MountedStorageStatus {
+ private final boolean mStorageMounted;
+ private final File mStorageMountedDir;
+ private final long mStorageMountedCapacity;
+
+ private MountedStorageStatus(boolean mounted, File mountedDir, long capacity) {
+ mStorageMounted = mounted;
+ mStorageMountedDir = mountedDir;
+ mStorageMountedCapacity = capacity;
+ }
+
+ private boolean isValidForDvr() {
+ return mStorageMounted && mStorageMountedCapacity >= MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof MountedStorageStatus)) {
+ return false;
+ }
+ MountedStorageStatus status = (MountedStorageStatus) other;
+ return mStorageMounted == status.mStorageMounted
+ && Objects.equals(mStorageMountedDir, status.mStorageMountedDir)
+ && mStorageMountedCapacity == status.mStorageMountedCapacity;
+ }
+ }
+
+ public interface OnStorageMountChangedListener {
+
+ /**
+ * Listener for DVR storage status change.
+ *
+ * @param storageMounted {@code true} when DVR possible storage is mounted,
+ * {@code false} otherwise.
+ */
+ void onStorageMountChanged(boolean storageMounted);
+ }
+
+ private final class StorageStatusBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ MountedStorageStatus result = getStorageStatusInternal();
+ if (mMountedStorageStatus.equals(result)) {
+ return;
+ }
+ mMountedStorageStatus = result;
+ if (result.mStorageMounted && mRunningInMainProcess) {
+ // Cleans up DB in LC process.
+ // Tuner process is not always on.
+ if (mCleanUpDbTask != null) {
+ mCleanUpDbTask.cancel(true);
+ }
+ mCleanUpDbTask = new CleanUpDbTask();
+ mCleanUpDbTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ boolean valid = result.isValidForDvr();
+ if (valid == mStorageValid) {
+ return;
+ }
+ mStorageValid = valid;
+ for (OnStorageMountChangedListener l : mOnStorageMountChangedListeners) {
+ l.onStorageMountChanged(valid);
+ }
+ }
+ }
+
+ /**
+ * Creates DvrStorageStatusManager.
+ *
+ * @param context {@link Context}
+ */
+ public DvrStorageStatusManager(final Context context, boolean runningInMainProcess) {
+ mContext = context;
+ mRunningInMainProcess = runningInMainProcess;
+ mMountedStorageStatus = getStorageStatusInternal();
+ mStorageValid = mMountedStorageStatus.isValidForDvr();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_MEDIA_MOUNTED);
+ filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
+ filter.addAction(Intent.ACTION_MEDIA_EJECT);
+ filter.addAction(Intent.ACTION_MEDIA_REMOVED);
+ filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL);
+ filter.addDataScheme(ContentResolver.SCHEME_FILE);
+ mContext.registerReceiver(new StorageStatusBroadcastReceiver(), filter);
+ }
+
+ /**
+ * Adds the listener for receiving storage status change.
+ *
+ * @param listener
+ */
+ public void addListener(OnStorageMountChangedListener listener) {
+ mOnStorageMountChangedListeners.add(listener);
+ }
+
+ /**
+ * Removes the current listener.
+ */
+ public void removeListener(OnStorageMountChangedListener listener) {
+ mOnStorageMountChangedListeners.remove(listener);
+ }
+
+ /**
+ * Returns true if a storage is mounted.
+ */
+ public boolean isStorageMounted() {
+ return mMountedStorageStatus.mStorageMounted;
+ }
+
+ /**
+ * Returns the path to DVR recording data directory.
+ * This can take for a while sometimes.
+ */
+ @WorkerThread
+ public File getRecordingRootDataDirectory() {
+ SoftPreconditions.checkState(Looper.myLooper() != Looper.getMainLooper());
+ if (mMountedStorageStatus.mStorageMountedDir == null) {
+ return null;
+ }
+ File root = mContext.getExternalFilesDir(null);
+ String rootPath;
+ try {
+ rootPath = root != null ? root.getCanonicalPath() : null;
+ } catch (IOException | SecurityException e) {
+ return null;
+ }
+ return rootPath == null ? null : new File(rootPath + RECORDING_DATA_SUB_PATH);
+ }
+
+ /**
+ * Returns the current storage status for DVR recordings.
+ *
+ * @return {@link StorageStatus}
+ */
+ @AnyThread
+ public @StorageStatus int getDvrStorageStatus() {
+ MountedStorageStatus status = mMountedStorageStatus;
+ if (status.mStorageMountedDir == null) {
+ return STORAGE_STATUS_MISSING;
+ }
+ if (CommonFeatures.FORCE_RECORDING_UNTIL_NO_SPACE.isEnabled(mContext)) {
+ return STORAGE_STATUS_OK;
+ }
+ if (status.mStorageMountedCapacity < MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES) {
+ return STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL;
+ }
+ try {
+ StatFs statFs = new StatFs(status.mStorageMountedDir.toString());
+ if (statFs.getAvailableBytes() < MIN_FREE_STORAGE_SIZE_FOR_DVR_IN_BYTES) {
+ return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT;
+ }
+ } catch (IllegalArgumentException e) {
+ // In rare cases, storage status change was not notified yet.
+ SoftPreconditions.checkState(false);
+ return STORAGE_STATUS_FREE_SPACE_INSUFFICIENT;
+ }
+ return STORAGE_STATUS_OK;
+ }
+
+ /**
+ * Returns whether the storage has sufficient storage.
+ *
+ * @return {@code true} when there is sufficient storage, {@code false} otherwise
+ */
+ public boolean isStorageSufficient() {
+ return getDvrStorageStatus() == STORAGE_STATUS_OK;
+ }
+
+ private MountedStorageStatus getStorageStatusInternal() {
+ boolean storageMounted =
+ Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
+ File storageMountedDir = storageMounted ? Environment.getExternalStorageDirectory() : null;
+ storageMounted = storageMounted && storageMountedDir != null;
+ long storageMountedCapacity = 0L;
+ if (storageMounted) {
+ try {
+ StatFs statFs = new StatFs(storageMountedDir.toString());
+ storageMountedCapacity = statFs.getTotalBytes();
+ } catch (IllegalArgumentException e) {
+ Log.e(TAG, "Storage mount status was changed.");
+ storageMounted = false;
+ storageMountedDir = null;
+ }
+ }
+ return new MountedStorageStatus(
+ storageMounted, storageMountedDir, storageMountedCapacity);
+ }
+
+ private class CleanUpDbTask extends AsyncTask<Void, Void, Void> {
+ private final ContentResolver mContentResolver;
+
+ private CleanUpDbTask() {
+ mContentResolver = mContext.getContentResolver();
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ @DvrStorageStatusManager.StorageStatus int storageStatus = getDvrStorageStatus();
+ if (storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) {
+ return null;
+ }
+ List<ContentProviderOperation> ops = getDeleteOps(storageStatus
+ == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL);
+ if (ops == null || ops.isEmpty()) {
+ return null;
+ }
+ Log.i(TAG, "New device storage mounted. # of recordings to be forgotten : "
+ + ops.size());
+ for (int i = 0 ; i < ops.size() && !isCancelled() ; i += BATCH_OPERATION_COUNT) {
+ int toIndex = (i + BATCH_OPERATION_COUNT) > ops.size()
+ ? ops.size() : (i + BATCH_OPERATION_COUNT);
+ ArrayList<ContentProviderOperation> batchOps =
+ new ArrayList<>(ops.subList(i, toIndex));
+ try {
+ mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, batchOps);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.e(TAG, "Failed to clean up RecordedPrograms.", e);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ if (mCleanUpDbTask == this) {
+ mCleanUpDbTask = null;
+ }
+ }
+
+ private List<ContentProviderOperation> getDeleteOps(boolean deleteAll) {
+ List<ContentProviderOperation> ops = new ArrayList<>();
+
+ try (Cursor c = mContentResolver.query(
+ TvContract.RecordedPrograms.CONTENT_URI, PROJECTION, null, null, null)) {
+ if (c == null) {
+ return null;
+ }
+ while (c.moveToNext()) {
+ @DvrStorageStatusManager.StorageStatus int storageStatus =
+ getDvrStorageStatus();
+ if (isCancelled()
+ || storageStatus == DvrStorageStatusManager.STORAGE_STATUS_MISSING) {
+ ops.clear();
+ break;
+ }
+ String id = c.getString(0);
+ String packageName = c.getString(1);
+ String dataUriString = c.getString(2);
+ if (dataUriString == null) {
+ continue;
+ }
+ Uri dataUri = Uri.parse(dataUriString);
+ if (!Utils.isInBundledPackageSet(packageName)
+ || dataUri == null || dataUri.getPath() == null
+ || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) {
+ continue;
+ }
+ File recordedProgramDir = new File(dataUri.getPath());
+ if (deleteAll || !recordedProgramDir.exists()) {
+ ops.add(ContentProviderOperation.newDelete(
+ TvContract.buildRecordedProgramUri(Long.parseLong(id))).build());
+ }
+ }
+ return ops;
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/DvrUiHelper.java b/src/com/android/tv/dvr/DvrUiHelper.java
new file mode 100644
index 00000000..c0d3b0c5
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrUiHelper.java
@@ -0,0 +1,450 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.media.tv.TvInputManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.MainThread;
+import android.support.annotation.Nullable;
+import android.support.v4.app.ActivityOptionsCompat;
+import android.text.TextUtils;
+import android.widget.ImageView;
+import android.widget.Toast;
+
+import com.android.tv.MainActivity;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.ui.DvrDetailsActivity;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrAlreadyRecordedDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrAlreadyScheduledDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrChannelRecordDurationOptionDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrChannelWatchConflictDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrInsufficientSpaceErrorDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrMissingStorageErrorDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrProgramConflictDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrScheduleDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrSmallSizedStorageErrorDialogFragment;
+import com.android.tv.dvr.ui.DvrHalfSizedDialogFragment.DvrStopRecordingDialogFragment;
+import com.android.tv.dvr.ui.DvrSchedulesActivity;
+import com.android.tv.dvr.ui.DvrSeriesDeletionActivity;
+import com.android.tv.dvr.ui.DvrSeriesScheduledDialogActivity;
+import com.android.tv.dvr.ui.DvrSeriesSettingsActivity;
+import com.android.tv.dvr.ui.DvrStopRecordingFragment;
+import com.android.tv.dvr.ui.DvrStopSeriesRecordingDialogFragment;
+import com.android.tv.dvr.ui.DvrStopSeriesRecordingFragment;
+import com.android.tv.dvr.ui.HalfSizedDialogFragment;
+import com.android.tv.dvr.ui.list.DvrSchedulesFragment;
+import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment;
+import com.android.tv.util.Utils;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A helper class for DVR UI.
+ */
+@MainThread
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrUiHelper {
+ /**
+ * Handles the action to create the new schedule. It returns {@code true} if the schedule is
+ * added and there's no additional UI, otherwise {@code false}.
+ */
+ public static boolean handleCreateSchedule(MainActivity activity, Program program) {
+ if (program == null) {
+ return false;
+ }
+ DvrManager dvrManager = TvApplication.getSingletons(activity).getDvrManager();
+ if (!program.isEpisodic()) {
+ // One time recording.
+ dvrManager.addSchedule(program);
+ if (!dvrManager.getConflictingSchedules(program).isEmpty()) {
+ DvrUiHelper.showScheduleConflictDialog(activity, program);
+ return false;
+ }
+ } else {
+ SeriesRecording seriesRecording = dvrManager.getSeriesRecording(program);
+ if (seriesRecording == null || seriesRecording.isStopped()) {
+ DvrUiHelper.showScheduleDialog(activity, program);
+ return false;
+ } else {
+ // Show recorded program rather than the schedule.
+ RecordedProgram recordedProgram = dvrManager.getRecordedProgram(program.getTitle(),
+ program.getSeasonNumber(), program.getEpisodeNumber());
+ if (recordedProgram != null) {
+ DvrUiHelper.showAlreadyRecordedDialog(activity, program);
+ return false;
+ }
+ ScheduledRecording duplicate = dvrManager.getScheduledRecording(program.getTitle(),
+ program.getSeasonNumber(), program.getEpisodeNumber());
+ if (duplicate != null
+ && (duplicate.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
+ || duplicate.getState()
+ == ScheduledRecording.STATE_RECORDING_IN_PROGRESS)) {
+ DvrUiHelper.showAlreadyScheduleDialog(activity, program);
+ return false;
+ }
+ // Just add the schedule.
+ dvrManager.addSchedule(program);
+ }
+ }
+ return true;
+
+ }
+
+ /**
+ * Checks if the storage status is good for recording and shows error messages if needed.
+ *
+ * @return true if the storage status is fine to be recorded for {@code inputId}.
+ */
+ public static boolean checkStorageStatusAndShowErrorMessage(Activity activity, String inputId) {
+ if (!Utils.isBundledInput(inputId)) {
+ return true;
+ }
+ DvrStorageStatusManager dvrStorageStatusManager =
+ TvApplication.getSingletons(activity).getDvrStorageStatusManager();
+ int status = dvrStorageStatusManager.getDvrStorageStatus();
+ if (status == DvrStorageStatusManager.STORAGE_STATUS_TOTAL_CAPACITY_TOO_SMALL) {
+ showDvrSmallSizedStorageErrorDialog(activity);
+ return false;
+ } else if (status == DvrStorageStatusManager.STORAGE_STATUS_MISSING) {
+ showDvrMissingStorageErrorDialog(activity, inputId);
+ return false;
+ } else if (status == DvrStorageStatusManager.STORAGE_STATUS_FREE_SPACE_INSUFFICIENT) {
+ // TODO: handle insufficient storage case.
+ return true;
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Shows the schedule dialog.
+ */
+ public static void showScheduleDialog(MainActivity activity, Program program) {
+ if (SoftPreconditions.checkNotNull(program) == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program);
+ showDialogFragment(activity, new DvrScheduleDialogFragment(), args, true, true);
+ }
+
+ /**
+ * Shows the recording duration options dialog.
+ */
+ public static void showChannelRecordDurationOptions(MainActivity activity, Channel channel) {
+ if (SoftPreconditions.checkNotNull(channel) == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channel.getId());
+ showDialogFragment(activity, new DvrChannelRecordDurationOptionDialogFragment(), args);
+ }
+
+ /**
+ * Shows the dialog which says that the new schedule conflicts with others.
+ */
+ public static void showScheduleConflictDialog(MainActivity activity, Program program) {
+ if (program == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program);
+ showDialogFragment(activity, new DvrProgramConflictDialogFragment(), args, false, true);
+ }
+
+ /**
+ * Shows the conflict dialog for the channel watching.
+ */
+ public static void showChannelWatchConflictDialog(MainActivity activity, Channel channel) {
+ if (channel == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channel.getId());
+ showDialogFragment(activity, new DvrChannelWatchConflictDialogFragment(), args);
+ }
+
+ /**
+ * Shows DVR insufficient space error dialog.
+ */
+ public static void showDvrInsufficientSpaceErrorDialog(MainActivity activity) {
+ showDialogFragment(activity, new DvrInsufficientSpaceErrorDialogFragment(), null);
+ Utils.clearRecordingFailedReason(activity,
+ TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ }
+
+ /**
+ * Shows DVR missing storage error dialog.
+ */
+ private static void showDvrMissingStorageErrorDialog(Activity activity, String inputId) {
+ SoftPreconditions.checkArgument(!TextUtils.isEmpty(inputId));
+ Bundle args = new Bundle();
+ args.putString(DvrHalfSizedDialogFragment.KEY_INPUT_ID, inputId);
+ showDialogFragment(activity, new DvrMissingStorageErrorDialogFragment(), args);
+ }
+
+ /**
+ * Shows DVR small sized storage error dialog.
+ */
+ public static void showDvrSmallSizedStorageErrorDialog(Activity activity) {
+ showDialogFragment(activity, new DvrSmallSizedStorageErrorDialogFragment(), null);
+ }
+
+ /**
+ * Shows stop recording dialog.
+ */
+ public static void showStopRecordingDialog(Activity activity, long channelId, int reason,
+ HalfSizedDialogFragment.OnActionClickListener listener) {
+ Bundle args = new Bundle();
+ args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, channelId);
+ args.putInt(DvrStopRecordingFragment.KEY_REASON, reason);
+ DvrHalfSizedDialogFragment fragment = new DvrStopRecordingDialogFragment();
+ fragment.setOnActionClickListener(listener);
+ showDialogFragment(activity, fragment, args);
+ }
+
+ /**
+ * Shows "already scheduled" dialog.
+ */
+ public static void showAlreadyScheduleDialog(MainActivity activity, Program program) {
+ if (program == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program);
+ showDialogFragment(activity, new DvrAlreadyScheduledDialogFragment(), args, false, true);
+ }
+
+ /**
+ * Shows "already recorded" dialog.
+ */
+ public static void showAlreadyRecordedDialog(MainActivity activity, Program program) {
+ if (program == null) {
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, program);
+ showDialogFragment(activity, new DvrAlreadyRecordedDialogFragment(), args, false, true);
+ }
+
+ private static void showDialogFragment(Activity activity,
+ DvrHalfSizedDialogFragment dialogFragment, Bundle args) {
+ showDialogFragment(activity, dialogFragment, args, false, false);
+ }
+
+ private static void showDialogFragment(Activity activity,
+ DvrHalfSizedDialogFragment dialogFragment, Bundle args, boolean keepSidePanelHistory,
+ boolean keepProgramGuide) {
+ dialogFragment.setArguments(args);
+ if (activity instanceof MainActivity) {
+ ((MainActivity) activity).getOverlayManager()
+ .showDialogFragment(DvrHalfSizedDialogFragment.DIALOG_TAG, dialogFragment,
+ keepSidePanelHistory, keepProgramGuide);
+ } else {
+ dialogFragment.show(activity.getFragmentManager(),
+ DvrHalfSizedDialogFragment.DIALOG_TAG);
+ }
+ }
+
+ /**
+ * Checks whether channel watch conflict dialog is open or not.
+ */
+ public static boolean isChannelWatchConflictDialogShown(MainActivity activity) {
+ return activity.getOverlayManager().getCurrentDialog() instanceof
+ DvrChannelWatchConflictDialogFragment;
+ }
+
+ private static ScheduledRecording getEarliestScheduledRecording(List<ScheduledRecording>
+ recordings) {
+ ScheduledRecording earlistScheduledRecording = null;
+ if (!recordings.isEmpty()) {
+ Collections.sort(recordings,
+ ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR);
+ earlistScheduledRecording = recordings.get(0);
+ }
+ return earlistScheduledRecording;
+ }
+
+ /**
+ * Shows the schedules activity to resolve the tune conflict.
+ */
+ public static void startSchedulesActivityForTuneConflict(Context context, Channel channel) {
+ if (channel == null) {
+ return;
+ }
+ List<ScheduledRecording> conflicts = TvApplication.getSingletons(context).getDvrManager()
+ .getConflictingSchedulesForTune(channel.getId());
+ startSchedulesActivity(context, getEarliestScheduledRecording(conflicts));
+ }
+
+ /**
+ * Shows the schedules activity to resolve the one time recording conflict.
+ */
+ public static void startSchedulesActivityForOneTimeRecordingConflict(Context context,
+ List<ScheduledRecording> conflicts) {
+ startSchedulesActivity(context, getEarliestScheduledRecording(conflicts));
+ }
+
+ /**
+ * Shows the schedules activity with full schedule.
+ */
+ public static void startSchedulesActivity(Context context, ScheduledRecording
+ focusedScheduledRecording) {
+ Intent intent = new Intent(context, DvrSchedulesActivity.class);
+ intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE,
+ DvrSchedulesActivity.TYPE_FULL_SCHEDULE);
+ if (focusedScheduledRecording != null) {
+ intent.putExtra(DvrSchedulesFragment.SCHEDULES_KEY_SCHEDULED_RECORDING,
+ focusedScheduledRecording);
+ }
+ context.startActivity(intent);
+ }
+
+ /**
+ * Shows the schedules activity for series recording.
+ */
+ public static void startSchedulesActivityForSeries(Context context,
+ SeriesRecording seriesRecording) {
+ Intent intent = new Intent(context, DvrSchedulesActivity.class);
+ intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE,
+ DvrSchedulesActivity.TYPE_SERIES_SCHEDULE);
+ intent.putExtra(DvrSeriesSchedulesFragment.SERIES_SCHEDULES_KEY_SERIES_RECORDING,
+ seriesRecording);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Shows the series settings activity.
+ *
+ * @param channelIds Channel ID list which has programs belonging to the series.
+ */
+ public static void startSeriesSettingsActivity(Context context, long seriesRecordingId,
+ @Nullable long[] channelIds, boolean removeEmptySeriesSchedule,
+ boolean isWindowTranslucent, boolean showViewScheduleOptionInDialog) {
+ Intent intent = new Intent(context, DvrSeriesSettingsActivity.class);
+ intent.putExtra(DvrSeriesSettingsActivity.SERIES_RECORDING_ID, seriesRecordingId);
+ intent.putExtra(DvrSeriesSettingsActivity.CHANNEL_ID_LIST, channelIds);
+ intent.putExtra(DvrSeriesSettingsActivity.REMOVE_EMPTY_SERIES_RECORDING,
+ removeEmptySeriesSchedule);
+ intent.putExtra(DvrSeriesSettingsActivity.IS_WINDOW_TRANSLUCENT, isWindowTranslucent);
+ intent.putExtra(DvrSeriesSettingsActivity.SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG,
+ showViewScheduleOptionInDialog);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Shows "series recording scheduled" dialog activity.
+ */
+ public static void StartSeriesScheduledDialogActivity(Context context,
+ SeriesRecording seriesRecording, boolean showViewScheduleOptionInDialog) {
+ if (seriesRecording == null) {
+ return;
+ }
+ Intent intent = new Intent(context, DvrSeriesScheduledDialogActivity.class);
+ intent.putExtra(DvrSeriesScheduledDialogActivity.SERIES_RECORDING_ID,
+ seriesRecording.getId());
+ intent.putExtra(DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION,
+ showViewScheduleOptionInDialog);
+ context.startActivity(intent);
+ }
+
+ /**
+ * Shows the details activity for the DVR items. The type of DVR items may be
+ * {@link ScheduledRecording}, {@link RecordedProgram}, or {@link SeriesRecording}.
+ */
+ public static void startDetailsActivity(Activity activity, Object dvrItem,
+ @Nullable ImageView imageView, boolean hideViewSchedule) {
+ if (dvrItem == null) {
+ return;
+ }
+ Intent intent = new Intent(activity, DvrDetailsActivity.class);
+ long recordingId;
+ int viewType;
+ if (dvrItem instanceof ScheduledRecording) {
+ ScheduledRecording schedule = (ScheduledRecording) dvrItem;
+ recordingId = schedule.getId();
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ viewType = DvrDetailsActivity.SCHEDULED_RECORDING_VIEW;
+ } else if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ viewType = DvrDetailsActivity.CURRENT_RECORDING_VIEW;
+ } else {
+ return;
+ }
+ } else if (dvrItem instanceof RecordedProgram) {
+ recordingId = ((RecordedProgram) dvrItem).getId();
+ viewType = DvrDetailsActivity.RECORDED_PROGRAM_VIEW;
+ } else if (dvrItem instanceof SeriesRecording) {
+ recordingId = ((SeriesRecording) dvrItem).getId();
+ viewType = DvrDetailsActivity.SERIES_RECORDING_VIEW;
+ } else {
+ return;
+ }
+ intent.putExtra(DvrDetailsActivity.RECORDING_ID, recordingId);
+ intent.putExtra(DvrDetailsActivity.DETAILS_VIEW_TYPE, viewType);
+ intent.putExtra(DvrDetailsActivity.HIDE_VIEW_SCHEDULE, hideViewSchedule);
+ Bundle bundle = null;
+ if (imageView != null) {
+ bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, imageView,
+ DvrDetailsActivity.SHARED_ELEMENT_NAME).toBundle();
+ }
+ activity.startActivity(intent, bundle);
+ }
+
+ /**
+ * Shows the cancel all dialog for series schedules list.
+ */
+ public static void showCancelAllSeriesRecordingDialog(DvrSchedulesActivity activity,
+ SeriesRecording seriesRecording) {
+ DvrStopSeriesRecordingDialogFragment dvrStopSeriesRecordingDialogFragment =
+ new DvrStopSeriesRecordingDialogFragment();
+ Bundle arguments = new Bundle();
+ arguments.putParcelable(DvrStopSeriesRecordingFragment.KEY_SERIES_RECORDING,
+ seriesRecording);
+ dvrStopSeriesRecordingDialogFragment.setArguments(arguments);
+ dvrStopSeriesRecordingDialogFragment.show(activity.getFragmentManager(),
+ DvrStopSeriesRecordingDialogFragment.DIALOG_TAG);
+ }
+
+ /**
+ * Shows the series deletion activity.
+ */
+ public static void startSeriesDeletionActivity(Context context, long seriesRecordingId) {
+ Intent intent = new Intent(context, DvrSeriesDeletionActivity.class);
+ intent.putExtra(DvrSeriesDeletionActivity.SERIES_RECORDING_ID, seriesRecordingId);
+ context.startActivity(intent);
+ }
+
+ public static void showAddScheduleToast(Context context,
+ String title, long startTimeMs, long endTimeMs) {
+ String msg = (startTimeMs > System.currentTimeMillis()) ?
+ context.getString(R.string.dvr_msg_program_scheduled, title)
+ : context.getString(R.string.dvr_msg_current_program_scheduled, title,
+ Utils.toTimeString(endTimeMs, false));
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/src/com/android/tv/dvr/DvrWatchedPositionManager.java b/src/com/android/tv/dvr/DvrWatchedPositionManager.java
new file mode 100644
index 00000000..4eada742
--- /dev/null
+++ b/src/com/android/tv/dvr/DvrWatchedPositionManager.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.media.tv.TvInputManager;
+import android.support.annotation.IntDef;
+
+import com.android.tv.common.SharedPreferencesUtils;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+/**
+ * A class to manage DVR watched state.
+ * It will remember and provides previous watched position of DVR playback.
+ */
+public class DvrWatchedPositionManager {
+ private final static String TAG = "DvrWatchedPositionManager";
+ private final boolean DEBUG = false;
+
+ private SharedPreferences mWatchedPositions;
+ private final Map<Long, Set> mListeners = new HashMap<>();
+
+ /**
+ * The minimum percentage of recorded program being watched that will be considered as being
+ * completely watched.
+ */
+ public static final float DVR_WATCHED_THRESHOLD_RATE = 0.98f;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({DVR_WATCHED_STATUS_NEW, DVR_WATCHED_STATUS_WATCHING, DVR_WATCHED_STATUS_WATCHED})
+ public @interface DvrWatchedStatus {}
+ /**
+ * The status indicates the recorded program has not been watched at all.
+ */
+ public static final int DVR_WATCHED_STATUS_NEW = 0;
+ /**
+ * The status indicates the recorded program is being watched.
+ */
+ public static final int DVR_WATCHED_STATUS_WATCHING = 1;
+ /**
+ * The status indicates the recorded program was completely watched.
+ */
+ public static final int DVR_WATCHED_STATUS_WATCHED = 2;
+
+ public DvrWatchedPositionManager(Context context) {
+ mWatchedPositions = context.getSharedPreferences(
+ SharedPreferencesUtils.SHARED_PREF_DVR_WATCHED_POSITION, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Sets the watched position of the give program.
+ */
+ public void setWatchedPosition(long recordedProgramId, long positionMs) {
+ mWatchedPositions.edit().putLong(Long.toString(recordedProgramId), positionMs).apply();
+ notifyWatchedPositionChanged(recordedProgramId, positionMs);
+ }
+
+ /**
+ * Gets the watched position of the give program.
+ */
+ public long getWatchedPosition(long recordedProgramId) {
+ return mWatchedPositions.getLong(Long.toString(recordedProgramId),
+ TvInputManager.TIME_SHIFT_INVALID_TIME);
+ }
+
+ @DvrWatchedStatus public int getWatchedStatus(RecordedProgram recordedProgram) {
+ long watchedPosition = getWatchedPosition(recordedProgram.getId());
+ if (watchedPosition == TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ return DVR_WATCHED_STATUS_NEW;
+ } else if (watchedPosition > recordedProgram
+ .getDurationMillis() * DVR_WATCHED_THRESHOLD_RATE) {
+ return DVR_WATCHED_STATUS_WATCHED;
+ } else {
+ return DVR_WATCHED_STATUS_WATCHING;
+ }
+ }
+
+ /**
+ * Adds {@link WatchedPositionChangedListener}.
+ */
+ public void addListener(WatchedPositionChangedListener listener, long recordedProgramId) {
+ if (recordedProgramId == RecordedProgram.ID_NOT_SET) {
+ return;
+ }
+ Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId);
+ if (listenerSet == null) {
+ listenerSet = new CopyOnWriteArraySet<>();
+ mListeners.put(recordedProgramId, listenerSet);
+ }
+ listenerSet.add(listener);
+ }
+
+ /**
+ * Removes {@link WatchedPositionChangedListener}.
+ */
+ public void removeListener(WatchedPositionChangedListener listener) {
+ for (long recordedProgramId : new ArrayList<>(mListeners.keySet())) {
+ removeListener(listener, recordedProgramId);
+ }
+ }
+
+ /**
+ * Removes {@link WatchedPositionChangedListener}.
+ */
+ public void removeListener(WatchedPositionChangedListener listener, long recordedProgramId) {
+ Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId);
+ if (listenerSet == null) {
+ return;
+ }
+ listenerSet.remove(listener);
+ if (listenerSet.isEmpty()) {
+ mListeners.remove(recordedProgramId);
+ }
+ }
+
+ private void notifyWatchedPositionChanged(long recordedProgramId, long positionMs) {
+ Set<WatchedPositionChangedListener> listenerSet = mListeners.get(recordedProgramId);
+ if (listenerSet == null) {
+ return;
+ }
+ for (WatchedPositionChangedListener listener : listenerSet) {
+ listener.onWatchedPositionChanged(recordedProgramId, positionMs);
+ }
+ }
+
+ public interface WatchedPositionChangedListener {
+ /**
+ * Called when the watched position of some program is changed.
+ */
+ void onWatchedPositionChanged(long recordedProgramId, long positionMs);
+ }
+}
diff --git a/src/com/android/tv/dvr/EpisodicProgramLoadTask.java b/src/com/android/tv/dvr/EpisodicProgramLoadTask.java
new file mode 100644
index 00000000..15ca2700
--- /dev/null
+++ b/src/com/android/tv/dvr/EpisodicProgramLoadTask.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.media.tv.TvContract.Programs;
+import android.net.Uri;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.annotation.WorkerThread;
+import android.text.TextUtils;
+
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.util.AsyncDbTask.AsyncProgramQueryTask;
+import com.android.tv.util.AsyncDbTask.CursorFilter;
+import com.android.tv.util.PermissionUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A wrapper of AsyncProgramQueryTask to load the episodic programs for the series recordings.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+abstract public class EpisodicProgramLoadTask {
+ private static final String TAG = "EpisodicProgramLoadTask";
+
+ private static final int PROGRAM_ID_INDEX = Program.getColumnIndex(Programs._ID);
+ private static final int START_TIME_INDEX =
+ Program.getColumnIndex(Programs.COLUMN_START_TIME_UTC_MILLIS);
+ private static final int RECORDING_PROHIBITED_INDEX =
+ Program.getColumnIndex(Programs.COLUMN_RECORDING_PROHIBITED);
+
+ private static final String PARAM_START_TIME = "start_time";
+ private static final String PARAM_END_TIME = "end_time";
+
+ private static final String PROGRAM_PREDICATE =
+ Programs.COLUMN_START_TIME_UTC_MILLIS + ">? AND "
+ + Programs.COLUMN_RECORDING_PROHIBITED + "=0";
+ private static final String PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM =
+ Programs.COLUMN_END_TIME_UTC_MILLIS + ">? AND "
+ + Programs.COLUMN_RECORDING_PROHIBITED + "=0";
+ private static final String CHANNEL_ID_PREDICATE = Programs.COLUMN_CHANNEL_ID + "=?";
+ private static final String PROGRAM_TITLE_PREDICATE = Programs.COLUMN_TITLE + "=?";
+
+ private final Context mContext;
+ private final DvrDataManager mDataManager;
+ private boolean mQueryAllChannels;
+ private boolean mLoadCurrentProgram;
+ private boolean mLoadScheduledEpisode;
+ private boolean mLoadDisallowedProgram;
+ // If true, match programs with OPTION_CHANNEL_ALL.
+ private boolean mIgnoreChannelOption;
+ private final ArrayList<SeriesRecording> mSeriesRecordings = new ArrayList<>();
+ private AsyncProgramQueryTask mProgramQueryTask;
+
+ /**
+ *
+ * Constructor used to load programs for one series recording with the given channel option.
+ */
+ public EpisodicProgramLoadTask(Context context, SeriesRecording seriesRecording) {
+ this(context, Collections.singletonList(seriesRecording));
+ }
+
+ /**
+ * Constructor used to load programs for multiple series recordings. The channel option is
+ * {@link SeriesRecording#OPTION_CHANNEL_ALL}.
+ */
+ public EpisodicProgramLoadTask(Context context, Collection<SeriesRecording> seriesRecordings) {
+ mContext = context.getApplicationContext();
+ mDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ mSeriesRecordings.addAll(seriesRecordings);
+ }
+
+ /**
+ * Returns the series recordings.
+ */
+ public List<SeriesRecording> getSeriesRecordings() {
+ return mSeriesRecordings;
+ }
+
+ /**
+ * Returns the program query task. It is {@code null} until it is executed.
+ */
+ @Nullable
+ public AsyncProgramQueryTask getTask() {
+ return mProgramQueryTask;
+ }
+
+ /**
+ * Enables loading current programs. The default value is {@code false}.
+ */
+ public EpisodicProgramLoadTask setLoadCurrentProgram(boolean loadCurrentProgram) {
+ SoftPreconditions.checkState(mProgramQueryTask == null, TAG,
+ "Can't change setting after execution.");
+ mLoadCurrentProgram = loadCurrentProgram;
+ return this;
+ }
+
+ /**
+ * Enables already schedules episodes. The default value is {@code false}.
+ */
+ public EpisodicProgramLoadTask setLoadScheduledEpisode(boolean loadScheduledEpisode) {
+ SoftPreconditions.checkState(mProgramQueryTask == null, TAG,
+ "Can't change setting after execution.");
+ mLoadScheduledEpisode = loadScheduledEpisode;
+ return this;
+ }
+
+ /**
+ * Enables loading disallowed programs whose schedules were removed manually by the user.
+ * The default value is {@code false}.
+ */
+ public EpisodicProgramLoadTask setLoadDisallowedProgram(boolean loadDisallowedProgram) {
+ SoftPreconditions.checkState(mProgramQueryTask == null, TAG,
+ "Can't change setting after execution.");
+ mLoadDisallowedProgram = loadDisallowedProgram;
+ return this;
+ }
+
+ /**
+ * Gives the option whether to ignore the channel option when matching programs.
+ * If {@code ignoreChannelOption} is {@code true}, the program will be matched with
+ * {@link SeriesRecording#OPTION_CHANNEL_ALL} option.
+ */
+ public EpisodicProgramLoadTask setIgnoreChannelOption(boolean ignoreChannelOption) {
+ SoftPreconditions.checkState(mProgramQueryTask == null, TAG,
+ "Can't change setting after execution.");
+ mIgnoreChannelOption = ignoreChannelOption;
+ return this;
+ }
+
+ /**
+ * Executes the task.
+ *
+ * @see com.android.tv.util.AsyncDbTask#executeOnDbThread
+ */
+ public void execute() {
+ if (SoftPreconditions.checkState(mProgramQueryTask == null, TAG,
+ "Can't execute task: the task is already running.")) {
+ mQueryAllChannels = mSeriesRecordings.size() > 1
+ || mSeriesRecordings.get(0).getChannelOption()
+ == SeriesRecording.OPTION_CHANNEL_ALL
+ || mIgnoreChannelOption;
+ mProgramQueryTask = createTask();
+ mProgramQueryTask.executeOnDbThread();
+ }
+ }
+
+ /**
+ * Cancels the task.
+ *
+ * @see android.os.AsyncTask#cancel
+ */
+ public void cancel(boolean mayInterruptIfRunning) {
+ if (mProgramQueryTask != null) {
+ mProgramQueryTask.cancel(mayInterruptIfRunning);
+ }
+ }
+
+ /**
+ * Runs on the UI thread after the program loading finishes successfully.
+ */
+ protected void onPostExecute(List<Program> programs) {
+ }
+
+ /**
+ * Runs on the UI thread after the program loading was canceled.
+ */
+ protected void onCancelled(List<Program> programs) {
+ }
+
+ private AsyncProgramQueryTask createTask() {
+ SqlParams sqlParams = createSqlParams();
+ return new AsyncProgramQueryTask(mContext.getContentResolver(), sqlParams.uri,
+ sqlParams.selection, sqlParams.selectionArgs, null, sqlParams.filter) {
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ EpisodicProgramLoadTask.this.onPostExecute(programs);
+ }
+
+ @Override
+ protected void onCancelled(List<Program> programs) {
+ EpisodicProgramLoadTask.this.onCancelled(programs);
+ }
+ };
+ }
+
+ private SqlParams createSqlParams() {
+ SqlParams sqlParams = new SqlParams();
+ if (PermissionUtils.hasAccessAllEpg(mContext)) {
+ sqlParams.uri = Programs.CONTENT_URI;
+ // Base
+ StringBuilder selection = new StringBuilder(mLoadCurrentProgram
+ ? PROGRAM_PREDICATE_WITH_CURRENT_PROGRAM : PROGRAM_PREDICATE);
+ List<String> args = new ArrayList<>();
+ args.add(Long.toString(System.currentTimeMillis()));
+ // Channel option
+ if (!mQueryAllChannels) {
+ selection.append(" AND ").append(CHANNEL_ID_PREDICATE);
+ args.add(Long.toString(mSeriesRecordings.get(0).getChannelId()));
+ }
+ // Title
+ if (mSeriesRecordings.size() == 1) {
+ selection.append(" AND ").append(PROGRAM_TITLE_PREDICATE);
+ args.add(mSeriesRecordings.get(0).getTitle());
+ }
+ sqlParams.selection = selection.toString();
+ sqlParams.selectionArgs = args.toArray(new String[args.size()]);
+ sqlParams.filter = new SeriesRecordingCursorFilter(mSeriesRecordings);
+ } else {
+ // The query includes the current program. Will be filtered if needed.
+ if (mQueryAllChannels) {
+ sqlParams.uri = Programs.CONTENT_URI.buildUpon()
+ .appendQueryParameter(PARAM_START_TIME,
+ String.valueOf(System.currentTimeMillis()))
+ .appendQueryParameter(PARAM_END_TIME, String.valueOf(Long.MAX_VALUE))
+ .build();
+ } else {
+ sqlParams.uri = TvContract.buildProgramsUriForChannel(
+ mSeriesRecordings.get(0).getChannelId(),
+ System.currentTimeMillis(), Long.MAX_VALUE);
+ }
+ sqlParams.selection = null;
+ sqlParams.selectionArgs = null;
+ sqlParams.filter = new SeriesRecordingCursorFilterForNonSystem(mSeriesRecordings);
+ }
+ return sqlParams;
+ }
+
+ @VisibleForTesting
+ static boolean isEpisodeScheduled(Collection<ScheduledEpisode> scheduledEpisodes,
+ ScheduledEpisode episode) {
+ // The episode whose season number or episode number is null will always be scheduled.
+ return scheduledEpisodes.contains(episode) && !TextUtils.isEmpty(episode.seasonNumber)
+ && !TextUtils.isEmpty(episode.episodeNumber);
+ }
+
+ /**
+ * Filter the programs which match the series recording. The episodes which the schedules are
+ * already created for are filtered out too.
+ */
+ private class SeriesRecordingCursorFilter implements CursorFilter {
+ private final Set<Long> mDisallowedProgramIds = new HashSet<>();
+ private final Set<ScheduledEpisode> mScheduledEpisodes = new HashSet<>();
+
+ SeriesRecordingCursorFilter(List<SeriesRecording> seriesRecordings) {
+ if (!mLoadDisallowedProgram) {
+ mDisallowedProgramIds.addAll(mDataManager.getDisallowedProgramIds());
+ }
+ if (!mLoadScheduledEpisode) {
+ Set<Long> seriesRecordingIds = new HashSet<>();
+ for (SeriesRecording r : seriesRecordings) {
+ seriesRecordingIds.add(r.getId());
+ }
+ for (ScheduledRecording r : mDataManager.getAllScheduledRecordings()) {
+ if (seriesRecordingIds.contains(r.getSeriesRecordingId())
+ && r.getState() != ScheduledRecording.STATE_RECORDING_FAILED
+ && r.getState() != ScheduledRecording.STATE_RECORDING_CLIPPED) {
+ mScheduledEpisodes.add(new ScheduledEpisode(r));
+ }
+ }
+ }
+ }
+
+ @Override
+ @WorkerThread
+ public boolean filter(Cursor c) {
+ if (!mLoadDisallowedProgram
+ && mDisallowedProgramIds.contains(c.getLong(PROGRAM_ID_INDEX))) {
+ return false;
+ }
+ Program program = Program.fromCursor(c);
+ for (SeriesRecording seriesRecording : mSeriesRecordings) {
+ boolean programMatches;
+ if (mIgnoreChannelOption) {
+ programMatches = seriesRecording.matchProgram(program,
+ SeriesRecording.OPTION_CHANNEL_ALL);
+ } else {
+ programMatches = seriesRecording.matchProgram(program);
+ }
+ if (programMatches) {
+ return mLoadScheduledEpisode
+ || !isEpisodeScheduled(mScheduledEpisodes, new ScheduledEpisode(
+ seriesRecording.getId(), program.getSeasonNumber(),
+ program.getEpisodeNumber()));
+ }
+ }
+ return false;
+ }
+ }
+
+ private class SeriesRecordingCursorFilterForNonSystem extends SeriesRecordingCursorFilter {
+ SeriesRecordingCursorFilterForNonSystem(List<SeriesRecording> seriesRecordings) {
+ super(seriesRecordings);
+ }
+
+ @Override
+ public boolean filter(Cursor c) {
+ return (mLoadCurrentProgram || c.getLong(START_TIME_INDEX) > System.currentTimeMillis())
+ && c.getInt(RECORDING_PROHIBITED_INDEX) != 0 && super.filter(c);
+ }
+ }
+
+ private static class SqlParams {
+ public Uri uri;
+ public String selection;
+ public String[] selectionArgs;
+ public CursorFilter filter;
+ }
+
+ /**
+ * A plain java object which includes the season/episode number for the series recording.
+ */
+ public static class ScheduledEpisode {
+ public final long seriesRecordingId;
+ public final String seasonNumber;
+ public final String episodeNumber;
+
+ /**
+ * Create a new Builder with the values set from an existing {@link ScheduledRecording}.
+ */
+ ScheduledEpisode(ScheduledRecording r) {
+ this(r.getSeriesRecordingId(), r.getSeasonNumber(), r.getEpisodeNumber());
+ }
+
+ public ScheduledEpisode(long seriesRecordingId, String seasonNumber, String episodeNumber) {
+ this.seriesRecordingId = seriesRecordingId;
+ this.seasonNumber = seasonNumber;
+ this.episodeNumber = episodeNumber;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ScheduledEpisode)) return false;
+ ScheduledEpisode that = (ScheduledEpisode) o;
+ return seriesRecordingId == that.seriesRecordingId
+ && Objects.equals(seasonNumber, that.seasonNumber)
+ && Objects.equals(episodeNumber, that.episodeNumber);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(seriesRecordingId, seasonNumber, episodeNumber);
+ }
+
+ @Override
+ public String toString() {
+ return "ScheduledEpisode{" +
+ "seriesRecordingId=" + seriesRecordingId +
+ ", seasonNumber='" + seasonNumber +
+ ", episodeNumber=" + episodeNumber +
+ '}';
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/IdGenerator.java b/src/com/android/tv/dvr/IdGenerator.java
new file mode 100644
index 00000000..0ed6362c
--- /dev/null
+++ b/src/com/android/tv/dvr/IdGenerator.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A class which generate the ID which increases sequentially.
+ */
+public class IdGenerator {
+ /**
+ * ID generator for the scheduled recording.
+ */
+ public static final IdGenerator SCHEDULED_RECORDING = new IdGenerator();
+
+ /**
+ * ID generator for the series recording.
+ */
+ public static final IdGenerator SERIES_RECORDING = new IdGenerator();
+
+ private final AtomicLong mMaxId = new AtomicLong(0);
+
+ /**
+ * Sets the new maximum ID.
+ */
+ public void setMaxId(long maxId) {
+ mMaxId.set(maxId);
+ }
+
+ /**
+ * Returns the new ID which is greater than the existing maximum ID by 1.
+ */
+ public long newId() {
+ return mMaxId.incrementAndGet();
+ }
+}
diff --git a/src/com/android/tv/dvr/InputTaskScheduler.java b/src/com/android/tv/dvr/InputTaskScheduler.java
new file mode 100644
index 00000000..53c89ebc
--- /dev/null
+++ b/src/com/android/tv/dvr/InputTaskScheduler.java
@@ -0,0 +1,431 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.content.Context;
+import android.media.tv.TvInputInfo;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.LongSparseArray;
+
+import com.android.tv.InputSessionManager;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.util.Clock;
+import com.android.tv.util.CompositeComparator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The scheduler for a TV input.
+ */
+public class InputTaskScheduler {
+ private static final String TAG = "InputTaskScheduler";
+ private static final boolean DEBUG = false;
+
+ private static final int MSG_ADD_SCHEDULED_RECORDING = 1;
+ private static final int MSG_REMOVE_SCHEDULED_RECORDING = 2;
+ private static final int MSG_UPDATE_SCHEDULED_RECORDING = 3;
+ private static final int MSG_BUILD_SCHEDULE = 4;
+ private static final int MSG_STOP_SCHEDULE = 5;
+
+ private static final float MIN_REMAIN_DURATION_PERCENT = 0.05f;
+
+ // The candidate comparator should be the consistent with
+ // DvrScheduleManager#CANDIDATE_COMPARATOR.
+ private static final Comparator<RecordingTask> CANDIDATE_COMPARATOR =
+ new CompositeComparator<>(
+ RecordingTask.PRIORITY_COMPARATOR,
+ RecordingTask.END_TIME_COMPARATOR,
+ RecordingTask.ID_COMPARATOR);
+
+ /**
+ * Returns the comparator which the schedules are sorted with when executed.
+ */
+ public static Comparator<ScheduledRecording> getRecordingOrderComparator() {
+ return ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR;
+ }
+
+ /**
+ * Wraps a {@link RecordingTask} removing it from {@link #mPendingRecordings} when it is done.
+ */
+ public final class HandlerWrapper extends Handler {
+ public static final int MESSAGE_REMOVE = 999;
+ private final long mId;
+ private final RecordingTask mTask;
+
+ HandlerWrapper(Looper looper, ScheduledRecording scheduledRecording,
+ RecordingTask recordingTask) {
+ super(looper, recordingTask);
+ mId = scheduledRecording.getId();
+ mTask = recordingTask;
+ mTask.setHandler(this);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ // The RecordingTask gets a chance first.
+ // It must return false to pass this message to here.
+ if (msg.what == MESSAGE_REMOVE) {
+ if (DEBUG) Log.d(TAG, "done " + mId);
+ mPendingRecordings.remove(mId);
+ }
+ removeCallbacksAndMessages(null);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ super.handleMessage(msg);
+ }
+ }
+
+ private TvInputInfo mInput;
+ private final Looper mLooper;
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrManager mDvrManager;
+ private final WritableDvrDataManager mDataManager;
+ private final InputSessionManager mSessionManager;
+ private final Clock mClock;
+ private final Context mContext;
+
+ private final LongSparseArray<HandlerWrapper> mPendingRecordings = new LongSparseArray<>();
+ private final Map<Long, ScheduledRecording> mWaitingSchedules = new ArrayMap<>();
+ private final Handler mMainThreadHandler;
+ private final Handler mHandler;
+ private final Object mInputLock = new Object();
+ private final RecordingTaskFactory mRecordingTaskFactory;
+
+ public InputTaskScheduler(Context context, TvInputInfo input, Looper looper,
+ ChannelDataManager channelDataManager, DvrManager dvrManager,
+ DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock) {
+ this(context, input, looper, channelDataManager, dvrManager, dataManager, sessionManager,
+ clock, new Handler(Looper.getMainLooper()), null, null);
+ }
+
+ @VisibleForTesting
+ InputTaskScheduler(Context context, TvInputInfo input, Looper looper,
+ ChannelDataManager channelDataManager, DvrManager dvrManager,
+ DvrDataManager dataManager, InputSessionManager sessionManager, Clock clock,
+ Handler mainThreadHandler, @Nullable Handler workerThreadHandler,
+ RecordingTaskFactory recordingTaskFactory) {
+ if (DEBUG) Log.d(TAG, "Creating scheduler for " + input);
+ mContext = context;
+ mInput = input;
+ mLooper = looper;
+ mChannelDataManager = channelDataManager;
+ mDvrManager = dvrManager;
+ mDataManager = (WritableDvrDataManager) dataManager;
+ mSessionManager = sessionManager;
+ mClock = clock;
+ mMainThreadHandler = mainThreadHandler;
+ mRecordingTaskFactory = recordingTaskFactory != null ? recordingTaskFactory
+ : new RecordingTaskFactory() {
+ @Override
+ public RecordingTask createRecordingTask(ScheduledRecording schedule, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, Clock clock) {
+ return new RecordingTask(mContext, schedule, channel, mDvrManager, mSessionManager,
+ mDataManager, mClock);
+ }
+ };
+ if (workerThreadHandler == null) {
+ mHandler = new WorkerThreadHandler(looper);
+ } else {
+ mHandler = workerThreadHandler;
+ }
+ }
+
+ /**
+ * Adds a {@link ScheduledRecording}.
+ */
+ public void addSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleAddSchedule(ScheduledRecording schedule) {
+ if (mPendingRecordings.get(schedule.getId()) != null
+ || mWaitingSchedules.containsKey(schedule.getId())) {
+ return;
+ }
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+
+ /**
+ * Removes the {@link ScheduledRecording}.
+ */
+ public void removeSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_REMOVE_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleRemoveSchedule(ScheduledRecording schedule) {
+ HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId());
+ if (wrapper != null) {
+ wrapper.mTask.cancel();
+ return;
+ }
+ if (mWaitingSchedules.containsKey(schedule.getId())) {
+ mWaitingSchedules.remove(schedule.getId());
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+ }
+
+ /**
+ * Updates the {@link ScheduledRecording}.
+ */
+ public void updateSchedule(ScheduledRecording schedule) {
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_SCHEDULED_RECORDING, schedule));
+ }
+
+ @VisibleForTesting
+ void handleUpdateSchedule(ScheduledRecording schedule) {
+ HandlerWrapper wrapper = mPendingRecordings.get(schedule.getId());
+ if (wrapper != null) {
+ if (schedule.getStartTimeMs() > mClock.currentTimeMillis()
+ && schedule.getStartTimeMs() > wrapper.mTask.getStartTimeMs()) {
+ // It shouldn't have started. Cancel and put to the waiting list.
+ // The schedules will be rebuilt when the task is removed.
+ // The reschedule is called in Scheduler.
+ wrapper.mTask.cancel();
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ return;
+ }
+ wrapper.sendMessage(wrapper.obtainMessage(RecordingTask.MSG_UDPATE_SCHEDULE, schedule));
+ return;
+ }
+ if (mWaitingSchedules.containsKey(schedule.getId())) {
+ mWaitingSchedules.put(schedule.getId(), schedule);
+ mHandler.removeMessages(MSG_BUILD_SCHEDULE);
+ mHandler.sendEmptyMessage(MSG_BUILD_SCHEDULE);
+ }
+ }
+
+ /**
+ * Updates the TV input.
+ */
+ public void updateTvInputInfo(TvInputInfo input) {
+ synchronized (mInputLock) {
+ mInput = input;
+ }
+ }
+
+ /**
+ * Stops the input task scheduler.
+ */
+ public void stop() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_STOP_SCHEDULE);
+ }
+
+ private void handleStopSchedule() {
+ mWaitingSchedules.clear();
+ int size = mPendingRecordings.size();
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ task.cleanUp();
+ }
+ }
+
+ @VisibleForTesting
+ void handleBuildSchedule() {
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ long currentTimeMs = mClock.currentTimeMillis();
+ // Remove past schedules.
+ for (Iterator<ScheduledRecording> iter = mWaitingSchedules.values().iterator();
+ iter.hasNext(); ) {
+ ScheduledRecording schedule = iter.next();
+ if (schedule.getEndTimeMs() - currentTimeMs
+ <= MIN_REMAIN_DURATION_PERCENT * schedule.getDuration()) {
+ fail(schedule);
+ iter.remove();
+ }
+ }
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ // Record the schedules which should start now.
+ List<ScheduledRecording> schedulesToStart = new ArrayList<>();
+ for (ScheduledRecording schedule : mWaitingSchedules.values()) {
+ if (schedule.getState() != ScheduledRecording.STATE_RECORDING_CANCELED
+ && schedule.getStartTimeMs() - RecordingTask.RECORDING_EARLY_START_OFFSET_MS
+ <= currentTimeMs && schedule.getEndTimeMs() > currentTimeMs) {
+ schedulesToStart.add(schedule);
+ }
+ }
+ // The schedules will be executed with the following order.
+ // 1. The schedule which starts early. It can be replaced later when the schedule with the
+ // higher priority needs to start.
+ // 2. The schedule with the higher priority. It can be replaced later when the schedule with
+ // the higher priority needs to start.
+ // 3. The schedule which was created recently.
+ Collections.sort(schedulesToStart, getRecordingOrderComparator());
+ int tunerCount;
+ synchronized (mInputLock) {
+ tunerCount = mInput.canRecord() ? mInput.getTunerCount() : 0;
+ }
+ for (ScheduledRecording schedule : schedulesToStart) {
+ if (hasTaskWhichFinishEarlier(schedule)) {
+ // If there is a schedule which finishes earlier than the new schedule, rebuild the
+ // schedules after it finishes.
+ return;
+ }
+ if (mPendingRecordings.size() < tunerCount) {
+ // Tuners available.
+ createRecordingTask(schedule).start();
+ mWaitingSchedules.remove(schedule.getId());
+ } else {
+ // No available tuners.
+ RecordingTask task = getReplacableTask(schedule);
+ if (task != null) {
+ task.stop();
+ // Just return. The schedules will be rebuilt after the task is stopped.
+ return;
+ }
+ }
+ }
+ if (mWaitingSchedules.isEmpty()) {
+ return;
+ }
+ // Set next scheduling.
+ long earliest = Long.MAX_VALUE;
+ for (ScheduledRecording schedule : mWaitingSchedules.values()) {
+ // The conflicting schedules will be removed if they end before conflicting resolved.
+ if (schedulesToStart.contains(schedule)) {
+ if (earliest > schedule.getEndTimeMs()) {
+ earliest = schedule.getEndTimeMs();
+ }
+ } else {
+ if (earliest > schedule.getStartTimeMs()
+ - RecordingTask.RECORDING_EARLY_START_OFFSET_MS) {
+ earliest = schedule.getStartTimeMs()
+ - RecordingTask.RECORDING_EARLY_START_OFFSET_MS;
+ }
+ }
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_BUILD_SCHEDULE, earliest - currentTimeMs);
+ }
+
+ private RecordingTask createRecordingTask(ScheduledRecording schedule) {
+ Channel channel = mChannelDataManager.getChannel(schedule.getChannelId());
+ RecordingTask recordingTask = mRecordingTaskFactory.createRecordingTask(schedule, channel,
+ mDvrManager, mSessionManager, mDataManager, mClock);
+ HandlerWrapper handlerWrapper = new HandlerWrapper(mLooper, schedule, recordingTask);
+ mPendingRecordings.put(schedule.getId(), handlerWrapper);
+ return recordingTask;
+ }
+
+ private boolean hasTaskWhichFinishEarlier(ScheduledRecording schedule) {
+ int size = mPendingRecordings.size();
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ if (task.getEndTimeMs() <= schedule.getStartTimeMs()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private RecordingTask getReplacableTask(ScheduledRecording schedule) {
+ // Returns the recording with the following priority.
+ // 1. The recording with the lowest priority is returned.
+ // 2. If the priorities are the same, the recording which finishes early is returned.
+ // 3. If 1) and 2) are the same, the early created schedule is returned.
+ int size = mPendingRecordings.size();
+ RecordingTask candidate = null;
+ for (int i = 0; i < size; ++i) {
+ RecordingTask task = mPendingRecordings.get(mPendingRecordings.keyAt(i)).mTask;
+ if (schedule.getPriority() > task.getPriority()) {
+ if (candidate == null || CANDIDATE_COMPARATOR.compare(candidate, task) > 0) {
+ candidate = task;
+ }
+ }
+ }
+ return candidate;
+ }
+
+ private void fail(ScheduledRecording schedule) {
+ // It's called when the scheduling has been failed without creating RecordingTask.
+ runOnMainHandler(new Runnable() {
+ @Override
+ public void run() {
+ ScheduledRecording scheduleInManager =
+ mDataManager.getScheduledRecording(schedule.getId());
+ if (scheduleInManager != null) {
+ // The schedule should be updated based on the object from DataManager in case
+ // when it has been updated.
+ mDataManager.changeState(scheduleInManager,
+ ScheduledRecording.STATE_RECORDING_FAILED);
+ }
+ }
+ });
+ }
+
+ private void runOnMainHandler(Runnable runnable) {
+ if (Looper.myLooper() == mMainThreadHandler.getLooper()) {
+ runnable.run();
+ } else {
+ mMainThreadHandler.post(runnable);
+ }
+ }
+
+ @VisibleForTesting
+ interface RecordingTaskFactory {
+ RecordingTask createRecordingTask(ScheduledRecording scheduledRecording, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
+ WritableDvrDataManager dataManager, Clock clock);
+ }
+
+ private class WorkerThreadHandler extends Handler {
+ public WorkerThreadHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_ADD_SCHEDULED_RECORDING:
+ handleAddSchedule((ScheduledRecording) msg.obj);
+ break;
+ case MSG_REMOVE_SCHEDULED_RECORDING:
+ handleRemoveSchedule((ScheduledRecording) msg.obj);
+ break;
+ case MSG_UPDATE_SCHEDULED_RECORDING:
+ handleUpdateSchedule((ScheduledRecording) msg.obj);
+ case MSG_BUILD_SCHEDULE:
+ handleBuildSchedule();
+ break;
+ case MSG_STOP_SCHEDULE:
+ handleStopSchedule();
+ break;
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/RecordedProgram.java b/src/com/android/tv/dvr/RecordedProgram.java
new file mode 100644
index 00000000..dd744f80
--- /dev/null
+++ b/src/com/android/tv/dvr/RecordedProgram.java
@@ -0,0 +1,868 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+import static android.media.tv.TvContract.RecordedPrograms;
+
+import android.annotation.TargetApi;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.android.tv.common.R;
+import com.android.tv.data.BaseProgram;
+import com.android.tv.data.GenreItems;
+import com.android.tv.data.InternalDataUtils;
+import com.android.tv.util.Utils;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Immutable instance of {@link android.media.tv.TvContract.RecordedPrograms}.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class RecordedProgram extends BaseProgram {
+ public static final int ID_NOT_SET = -1;
+
+ public final static String[] PROJECTION = {
+ // These are in exactly the order listed in RecordedPrograms
+ RecordedPrograms._ID,
+ RecordedPrograms.COLUMN_PACKAGE_NAME,
+ RecordedPrograms.COLUMN_INPUT_ID,
+ RecordedPrograms.COLUMN_CHANNEL_ID,
+ RecordedPrograms.COLUMN_TITLE,
+ RecordedPrograms.COLUMN_SEASON_DISPLAY_NUMBER,
+ RecordedPrograms.COLUMN_SEASON_TITLE,
+ RecordedPrograms.COLUMN_EPISODE_DISPLAY_NUMBER,
+ RecordedPrograms.COLUMN_EPISODE_TITLE,
+ RecordedPrograms.COLUMN_START_TIME_UTC_MILLIS,
+ RecordedPrograms.COLUMN_END_TIME_UTC_MILLIS,
+ RecordedPrograms.COLUMN_BROADCAST_GENRE,
+ RecordedPrograms.COLUMN_CANONICAL_GENRE,
+ RecordedPrograms.COLUMN_SHORT_DESCRIPTION,
+ RecordedPrograms.COLUMN_LONG_DESCRIPTION,
+ RecordedPrograms.COLUMN_VIDEO_WIDTH,
+ RecordedPrograms.COLUMN_VIDEO_HEIGHT,
+ RecordedPrograms.COLUMN_AUDIO_LANGUAGE,
+ RecordedPrograms.COLUMN_CONTENT_RATING,
+ RecordedPrograms.COLUMN_POSTER_ART_URI,
+ RecordedPrograms.COLUMN_THUMBNAIL_URI,
+ RecordedPrograms.COLUMN_SEARCHABLE,
+ RecordedPrograms.COLUMN_RECORDING_DATA_URI,
+ RecordedPrograms.COLUMN_RECORDING_DATA_BYTES,
+ RecordedPrograms.COLUMN_RECORDING_DURATION_MILLIS,
+ RecordedPrograms.COLUMN_RECORDING_EXPIRE_TIME_UTC_MILLIS,
+ RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG1,
+ RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG2,
+ RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG3,
+ RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG4,
+ RecordedPrograms.COLUMN_VERSION_NUMBER,
+ RecordedPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
+ };
+
+ public static RecordedProgram fromCursor(Cursor cursor) {
+ int index = 0;
+ Builder builder = builder()
+ .setId(cursor.getLong(index++))
+ .setPackageName(cursor.getString(index++))
+ .setInputId(cursor.getString(index++))
+ .setChannelId(cursor.getLong(index++))
+ .setTitle(cursor.getString(index++))
+ .setSeasonNumber(cursor.getString(index++))
+ .setSeasonTitle(cursor.getString(index++))
+ .setEpisodeNumber(cursor.getString(index++))
+ .setEpisodeTitle(cursor.getString(index++))
+ .setStartTimeUtcMillis(cursor.getLong(index++))
+ .setEndTimeUtcMillis(cursor.getLong(index++))
+ .setBroadcastGenres(cursor.getString(index++))
+ .setCanonicalGenres(cursor.getString(index++))
+ .setShortDescription(cursor.getString(index++))
+ .setLongDescription(cursor.getString(index++))
+ .setVideoWidth(cursor.getInt(index++))
+ .setVideoHeight(cursor.getInt(index++))
+ .setAudioLanguage(cursor.getString(index++))
+ .setContentRating(cursor.getString(index++))
+ .setPosterArtUri(cursor.getString(index++))
+ .setThumbnailUri(cursor.getString(index++))
+ .setSearchable(cursor.getInt(index++) == 1)
+ .setDataUri(cursor.getString(index++))
+ .setDataBytes(cursor.getLong(index++))
+ .setDurationMillis(cursor.getLong(index++))
+ .setExpireTimeUtcMillis(cursor.getLong(index++))
+ .setInternalProviderFlag1(cursor.getInt(index++))
+ .setInternalProviderFlag2(cursor.getInt(index++))
+ .setInternalProviderFlag3(cursor.getInt(index++))
+ .setInternalProviderFlag4(cursor.getInt(index++))
+ .setVersionNumber(cursor.getInt(index++));
+ if (Utils.isInBundledPackageSet(builder.mPackageName)) {
+ InternalDataUtils.deserializeInternalProviderData(cursor.getBlob(index), builder);
+ }
+ return builder.build();
+ }
+
+ public static ContentValues toValues(RecordedProgram recordedProgram) {
+ ContentValues values = new ContentValues();
+ if (recordedProgram.mId != ID_NOT_SET) {
+ values.put(RecordedPrograms._ID, recordedProgram.mId);
+ }
+ values.put(RecordedPrograms.COLUMN_INPUT_ID, recordedProgram.mInputId);
+ values.put(RecordedPrograms.COLUMN_CHANNEL_ID, recordedProgram.mChannelId);
+ values.put(RecordedPrograms.COLUMN_TITLE, recordedProgram.mTitle);
+ values.put(RecordedPrograms.COLUMN_SEASON_DISPLAY_NUMBER, recordedProgram.mSeasonNumber);
+ values.put(RecordedPrograms.COLUMN_SEASON_TITLE, recordedProgram.mSeasonTitle);
+ values.put(RecordedPrograms.COLUMN_EPISODE_DISPLAY_NUMBER, recordedProgram.mEpisodeNumber);
+ values.put(RecordedPrograms.COLUMN_EPISODE_TITLE, recordedProgram.mTitle);
+ values.put(RecordedPrograms.COLUMN_START_TIME_UTC_MILLIS,
+ recordedProgram.mStartTimeUtcMillis);
+ values.put(RecordedPrograms.COLUMN_END_TIME_UTC_MILLIS, recordedProgram.mEndTimeUtcMillis);
+ values.put(RecordedPrograms.COLUMN_BROADCAST_GENRE,
+ safeEncode(recordedProgram.mBroadcastGenres));
+ values.put(RecordedPrograms.COLUMN_CANONICAL_GENRE,
+ safeEncode(recordedProgram.mCanonicalGenres));
+ values.put(RecordedPrograms.COLUMN_SHORT_DESCRIPTION, recordedProgram.mShortDescription);
+ values.put(RecordedPrograms.COLUMN_LONG_DESCRIPTION, recordedProgram.mLongDescription);
+ if (recordedProgram.mVideoWidth == 0) {
+ values.putNull(RecordedPrograms.COLUMN_VIDEO_WIDTH);
+ } else {
+ values.put(RecordedPrograms.COLUMN_VIDEO_WIDTH, recordedProgram.mVideoWidth);
+ }
+ if (recordedProgram.mVideoHeight == 0) {
+ values.putNull(RecordedPrograms.COLUMN_VIDEO_HEIGHT);
+ } else {
+ values.put(RecordedPrograms.COLUMN_VIDEO_HEIGHT, recordedProgram.mVideoHeight);
+ }
+ values.put(RecordedPrograms.COLUMN_AUDIO_LANGUAGE, recordedProgram.mAudioLanguage);
+ values.put(RecordedPrograms.COLUMN_CONTENT_RATING, recordedProgram.mContentRating);
+ values.put(RecordedPrograms.COLUMN_POSTER_ART_URI, recordedProgram.mPosterArtUri);
+ values.put(RecordedPrograms.COLUMN_THUMBNAIL_URI, recordedProgram.mThumbnailUri);
+ values.put(RecordedPrograms.COLUMN_SEARCHABLE, recordedProgram.mSearchable ? 1 : 0);
+ values.put(RecordedPrograms.COLUMN_RECORDING_DATA_URI,
+ safeToString(recordedProgram.mDataUri));
+ values.put(RecordedPrograms.COLUMN_RECORDING_DATA_BYTES, recordedProgram.mDataBytes);
+ values.put(RecordedPrograms.COLUMN_RECORDING_DURATION_MILLIS,
+ recordedProgram.mDurationMillis);
+ values.put(RecordedPrograms.COLUMN_RECORDING_EXPIRE_TIME_UTC_MILLIS,
+ recordedProgram.mExpireTimeUtcMillis);
+ values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
+ InternalDataUtils.serializeInternalProviderData(recordedProgram));
+ values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG1,
+ recordedProgram.mInternalProviderFlag1);
+ values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG2,
+ recordedProgram.mInternalProviderFlag2);
+ values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG3,
+ recordedProgram.mInternalProviderFlag3);
+ values.put(RecordedPrograms.COLUMN_INTERNAL_PROVIDER_FLAG4,
+ recordedProgram.mInternalProviderFlag4);
+ values.put(RecordedPrograms.COLUMN_VERSION_NUMBER, recordedProgram.mVersionNumber);
+ return values;
+ }
+
+ public static class Builder{
+ private long mId = ID_NOT_SET;
+ private String mPackageName;
+ private String mInputId;
+ private long mChannelId;
+ private String mTitle;
+ private String mSeriesId;
+ private String mSeasonNumber;
+ private String mSeasonTitle;
+ private String mEpisodeNumber;
+ private String mEpisodeTitle;
+ private long mStartTimeUtcMillis;
+ private long mEndTimeUtcMillis;
+ private String[] mBroadcastGenres;
+ private String[] mCanonicalGenres;
+ private String mShortDescription;
+ private String mLongDescription;
+ private int mVideoWidth;
+ private int mVideoHeight;
+ private String mAudioLanguage;
+ private String mContentRating;
+ private String mPosterArtUri;
+ private String mThumbnailUri;
+ private boolean mSearchable = true;
+ private Uri mDataUri;
+ private long mDataBytes;
+ private long mDurationMillis;
+ private long mExpireTimeUtcMillis;
+ private int mInternalProviderFlag1;
+ private int mInternalProviderFlag2;
+ private int mInternalProviderFlag3;
+ private int mInternalProviderFlag4;
+ private int mVersionNumber;
+
+ public Builder setId(long id) {
+ mId = id;
+ return this;
+ }
+
+ public Builder setPackageName(String packageName) {
+ mPackageName = packageName;
+ return this;
+ }
+
+ public Builder setInputId(String inputId) {
+ mInputId = inputId;
+ return this;
+ }
+
+ public Builder setChannelId(long channelId) {
+ mChannelId = channelId;
+ return this;
+ }
+
+ public Builder setTitle(String title) {
+ mTitle = title;
+ return this;
+ }
+
+ public Builder setSeriesId(String seriesId) {
+ mSeriesId = seriesId;
+ return this;
+ }
+
+ public Builder setSeasonNumber(String seasonNumber) {
+ mSeasonNumber = seasonNumber;
+ return this;
+ }
+
+ public Builder setSeasonTitle(String seasonTitle) {
+ mSeasonTitle = seasonTitle;
+ return this;
+ }
+
+ public Builder setEpisodeNumber(String episodeNumber) {
+ mEpisodeNumber = episodeNumber;
+ return this;
+ }
+
+ public Builder setEpisodeTitle(String episodeTitle) {
+ mEpisodeTitle = episodeTitle;
+ return this;
+ }
+
+ public Builder setStartTimeUtcMillis(long startTimeUtcMillis) {
+ mStartTimeUtcMillis = startTimeUtcMillis;
+ return this;
+ }
+
+ public Builder setEndTimeUtcMillis(long endTimeUtcMillis) {
+ mEndTimeUtcMillis = endTimeUtcMillis;
+ return this;
+ }
+
+ public Builder setBroadcastGenres(String broadcastGenres) {
+ if (TextUtils.isEmpty(broadcastGenres)) {
+ mBroadcastGenres = null;
+ return this;
+ }
+ return setBroadcastGenres(TvContract.Programs.Genres.decode(broadcastGenres));
+ }
+
+ private Builder setBroadcastGenres(String[] broadcastGenres) {
+ mBroadcastGenres = broadcastGenres;
+ return this;
+ }
+
+ public Builder setCanonicalGenres(String canonicalGenres) {
+ if (TextUtils.isEmpty(canonicalGenres)) {
+ mCanonicalGenres = null;
+ return this;
+ }
+ return setCanonicalGenres(TvContract.Programs.Genres.decode(canonicalGenres));
+ }
+
+ private Builder setCanonicalGenres(String[] canonicalGenres) {
+ mCanonicalGenres = canonicalGenres;
+ return this;
+ }
+
+ public Builder setShortDescription(String shortDescription) {
+ mShortDescription = shortDescription;
+ return this;
+ }
+
+ public Builder setLongDescription(String longDescription) {
+ mLongDescription = longDescription;
+ return this;
+ }
+
+ public Builder setVideoWidth(int videoWidth) {
+ mVideoWidth = videoWidth;
+ return this;
+ }
+
+ public Builder setVideoHeight(int videoHeight) {
+ mVideoHeight = videoHeight;
+ return this;
+ }
+
+ public Builder setAudioLanguage(String audioLanguage) {
+ mAudioLanguage = audioLanguage;
+ return this;
+ }
+
+ public Builder setContentRating(String contentRating) {
+ mContentRating = contentRating;
+ return this;
+ }
+
+ private Uri toUri(String uriString) {
+ try {
+ return uriString == null ? null : Uri.parse(uriString);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ public Builder setPosterArtUri(String posterArtUri) {
+ mPosterArtUri = posterArtUri;
+ return this;
+ }
+
+ public Builder setThumbnailUri(String thumbnailUri) {
+ mThumbnailUri = thumbnailUri;
+ return this;
+ }
+
+ public Builder setSearchable(boolean searchable) {
+ mSearchable = searchable;
+ return this;
+ }
+
+ public Builder setDataUri(String dataUri) {
+ return setDataUri(toUri(dataUri));
+ }
+
+ public Builder setDataUri(Uri dataUri) {
+ mDataUri = dataUri;
+ return this;
+ }
+
+ public Builder setDataBytes(long dataBytes) {
+ mDataBytes = dataBytes;
+ return this;
+ }
+
+ public Builder setDurationMillis(long durationMillis) {
+ mDurationMillis = durationMillis;
+ return this;
+ }
+
+ public Builder setExpireTimeUtcMillis(long expireTimeUtcMillis) {
+ mExpireTimeUtcMillis = expireTimeUtcMillis;
+ return this;
+ }
+
+ public Builder setInternalProviderFlag1(int internalProviderFlag1) {
+ mInternalProviderFlag1 = internalProviderFlag1;
+ return this;
+ }
+
+ public Builder setInternalProviderFlag2(int internalProviderFlag2) {
+ mInternalProviderFlag2 = internalProviderFlag2;
+ return this;
+ }
+
+ public Builder setInternalProviderFlag3(int internalProviderFlag3) {
+ mInternalProviderFlag3 = internalProviderFlag3;
+ return this;
+ }
+
+ public Builder setInternalProviderFlag4(int internalProviderFlag4) {
+ mInternalProviderFlag4 = internalProviderFlag4;
+ return this;
+ }
+
+ public Builder setVersionNumber(int versionNumber) {
+ mVersionNumber = versionNumber;
+ return this;
+ }
+
+ public RecordedProgram build() {
+ // Generate the series ID for the episodic program of other TV input.
+ if (TextUtils.isEmpty(mSeriesId)
+ && !TextUtils.isEmpty(mEpisodeNumber)) {
+ setSeriesId(BaseProgram.generateSeriesId(mPackageName, mTitle));
+ }
+ return new RecordedProgram(mId, mPackageName, mInputId, mChannelId, mTitle, mSeriesId,
+ mSeasonNumber, mSeasonTitle, mEpisodeNumber, mEpisodeTitle, mStartTimeUtcMillis,
+ mEndTimeUtcMillis, mBroadcastGenres, mCanonicalGenres, mShortDescription,
+ mLongDescription, mVideoWidth, mVideoHeight, mAudioLanguage, mContentRating,
+ mPosterArtUri, mThumbnailUri, mSearchable, mDataUri, mDataBytes,
+ mDurationMillis, mExpireTimeUtcMillis, mInternalProviderFlag1,
+ mInternalProviderFlag2, mInternalProviderFlag3, mInternalProviderFlag4,
+ mVersionNumber);
+ }
+ }
+
+ public static Builder builder() { return new Builder(); }
+
+ public static Builder buildFrom(RecordedProgram orig) {
+ return builder()
+ .setId(orig.getId())
+ .setPackageName(orig.getPackageName())
+ .setInputId(orig.getInputId())
+ .setChannelId(orig.getChannelId())
+ .setTitle(orig.getTitle())
+ .setSeriesId(orig.getSeriesId())
+ .setSeasonNumber(orig.getSeasonNumber())
+ .setSeasonTitle(orig.getSeasonTitle())
+ .setEpisodeNumber(orig.getEpisodeNumber())
+ .setEpisodeTitle(orig.getEpisodeTitle())
+ .setStartTimeUtcMillis(orig.getStartTimeUtcMillis())
+ .setEndTimeUtcMillis(orig.getEndTimeUtcMillis())
+ .setBroadcastGenres(orig.getBroadcastGenres())
+ .setCanonicalGenres(orig.getCanonicalGenres())
+ .setShortDescription(orig.getDescription())
+ .setLongDescription(orig.getLongDescription())
+ .setVideoWidth(orig.getVideoWidth())
+ .setVideoHeight(orig.getVideoHeight())
+ .setAudioLanguage(orig.getAudioLanguage())
+ .setContentRating(orig.getContentRating())
+ .setPosterArtUri(orig.getPosterArtUri())
+ .setThumbnailUri(orig.getThumbnailUri())
+ .setSearchable(orig.isSearchable())
+ .setInternalProviderFlag1(orig.getInternalProviderFlag1())
+ .setInternalProviderFlag2(orig.getInternalProviderFlag2())
+ .setInternalProviderFlag3(orig.getInternalProviderFlag3())
+ .setInternalProviderFlag4(orig.getInternalProviderFlag4())
+ .setVersionNumber(orig.getVersionNumber());
+ }
+
+ public static final Comparator<RecordedProgram> START_TIME_THEN_ID_COMPARATOR =
+ new Comparator<RecordedProgram>() {
+ @Override
+ public int compare(RecordedProgram lhs, RecordedProgram rhs) {
+ int res =
+ Long.compare(lhs.getStartTimeUtcMillis(), rhs.getStartTimeUtcMillis());
+ if (res != 0) {
+ return res;
+ }
+ return Long.compare(lhs.mId, rhs.mId);
+ }
+ };
+
+ private static final long CLIPPED_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5);
+
+ private final long mId;
+ private final String mPackageName;
+ private final String mInputId;
+ private final long mChannelId;
+ private final String mTitle;
+ private final String mSeriesId;
+ private final String mSeasonNumber;
+ private final String mSeasonTitle;
+ private final String mEpisodeNumber;
+ private final String mEpisodeTitle;
+ private final long mStartTimeUtcMillis;
+ private final long mEndTimeUtcMillis;
+ private final String[] mBroadcastGenres;
+ private final String[] mCanonicalGenres;
+ private final String mShortDescription;
+ private final String mLongDescription;
+ private final int mVideoWidth;
+ private final int mVideoHeight;
+ private final String mAudioLanguage;
+ private final String mContentRating;
+ private final String mPosterArtUri;
+ private final String mThumbnailUri;
+ private final boolean mSearchable;
+ private final Uri mDataUri;
+ private final long mDataBytes;
+ private final long mDurationMillis;
+ private final long mExpireTimeUtcMillis;
+ private final int mInternalProviderFlag1;
+ private final int mInternalProviderFlag2;
+ private final int mInternalProviderFlag3;
+ private final int mInternalProviderFlag4;
+ private final int mVersionNumber;
+
+ private RecordedProgram(long id, String packageName, String inputId, long channelId,
+ String title, String seriesId, String seasonNumber, String seasonTitle,
+ String episodeNumber, String episodeTitle, long startTimeUtcMillis,
+ long endTimeUtcMillis, String[] broadcastGenres, String[] canonicalGenres,
+ String shortDescription, String longDescription, int videoWidth, int videoHeight,
+ String audioLanguage, String contentRating, String posterArtUri, String thumbnailUri,
+ boolean searchable, Uri dataUri, long dataBytes, long durationMillis,
+ long expireTimeUtcMillis, int internalProviderFlag1, int internalProviderFlag2,
+ int internalProviderFlag3, int internalProviderFlag4, int versionNumber) {
+ mId = id;
+ mPackageName = packageName;
+ mInputId = inputId;
+ mChannelId = channelId;
+ mTitle = title;
+ mSeriesId = seriesId;
+ mSeasonNumber = seasonNumber;
+ mSeasonTitle = seasonTitle;
+ mEpisodeNumber = episodeNumber;
+ mEpisodeTitle = episodeTitle;
+ mStartTimeUtcMillis = startTimeUtcMillis;
+ mEndTimeUtcMillis = endTimeUtcMillis;
+ mBroadcastGenres = broadcastGenres;
+ mCanonicalGenres = canonicalGenres;
+ mShortDescription = shortDescription;
+ mLongDescription = longDescription;
+ mVideoWidth = videoWidth;
+ mVideoHeight = videoHeight;
+
+ mAudioLanguage = audioLanguage;
+ mContentRating = contentRating;
+ mPosterArtUri = posterArtUri;
+ mThumbnailUri = thumbnailUri;
+ mSearchable = searchable;
+ mDataUri = dataUri;
+ mDataBytes = dataBytes;
+ mDurationMillis = durationMillis;
+ mExpireTimeUtcMillis = expireTimeUtcMillis;
+ mInternalProviderFlag1 = internalProviderFlag1;
+ mInternalProviderFlag2 = internalProviderFlag2;
+ mInternalProviderFlag3 = internalProviderFlag3;
+ mInternalProviderFlag4 = internalProviderFlag4;
+ mVersionNumber = versionNumber;
+ }
+
+ public String getAudioLanguage() {
+ return mAudioLanguage;
+ }
+
+ public String[] getBroadcastGenres() {
+ return mBroadcastGenres;
+ }
+
+ public String[] getCanonicalGenres() {
+ return mCanonicalGenres;
+ }
+
+ /**
+ * Returns array of canonical genre ID's for this recorded program.
+ */
+ @Override
+ public int[] getCanonicalGenreIds() {
+ if (mCanonicalGenres == null) {
+ return null;
+ }
+ int[] genreIds = new int[mCanonicalGenres.length];
+ for (int i = 0; i < mCanonicalGenres.length; i++) {
+ genreIds[i] = GenreItems.getId(mCanonicalGenres[i]);
+ }
+ return genreIds;
+ }
+
+ @Override
+ public long getChannelId() {
+ return mChannelId;
+ }
+
+ public String getContentRating() {
+ return mContentRating;
+ }
+
+ public Uri getDataUri() {
+ return mDataUri;
+ }
+
+ public long getDataBytes() {
+ return mDataBytes;
+ }
+
+ @Override
+ public long getDurationMillis() {
+ return mDurationMillis;
+ }
+
+ @Override
+ public long getEndTimeUtcMillis() {
+ return mEndTimeUtcMillis;
+ }
+
+ @Override
+ public String getEpisodeNumber() {
+ return mEpisodeNumber;
+ }
+
+ public String getEpisodeTitle() {
+ return mEpisodeTitle;
+ }
+
+ @Override
+ public String getEpisodeDisplayTitle(Context context) {
+ if (!TextUtils.isEmpty(mEpisodeNumber)) {
+ String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle;
+ if (TextUtils.equals(mSeasonNumber, "0")) {
+ // Do not show "S0: ".
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format_no_season_number),
+ mEpisodeNumber, episodeTitle);
+ } else {
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format),
+ mSeasonNumber, mEpisodeNumber, episodeTitle);
+ }
+ }
+ return mEpisodeTitle;
+ }
+
+ @Nullable
+ @Override
+ public String getTitleWithEpisodeNumber(Context context) {
+ if (TextUtils.isEmpty(mTitle)) {
+ return mTitle;
+ }
+ if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) {
+ return TextUtils.isEmpty(mEpisodeNumber) ? mTitle : context.getString(
+ R.string.program_title_with_episode_number_no_season, mTitle, mEpisodeNumber);
+ } else {
+ return context.getString(R.string.program_title_with_episode_number, mTitle,
+ mSeasonNumber, mEpisodeNumber);
+ }
+ }
+
+ @Nullable
+ public String getEpisodeDisplayNumber(Context context) {
+ if (!TextUtils.isEmpty(mEpisodeNumber)) {
+ if (TextUtils.equals(mSeasonNumber, "0")) {
+ // Do not show "S0: ".
+ return String.format(context.getResources().getString(
+ R.string.display_episode_number_format_no_season_number), mEpisodeNumber);
+ } else {
+ return String.format(context.getResources().getString(
+ R.string.display_episode_number_format), mSeasonNumber, mEpisodeNumber);
+ }
+ }
+ return null;
+ }
+
+ public long getExpireTimeUtcMillis() {
+ return mExpireTimeUtcMillis;
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public String getPackageName() {
+ return mPackageName;
+ }
+
+ public String getInputId() {
+ return mInputId;
+ }
+
+ public int getInternalProviderFlag1() {
+ return mInternalProviderFlag1;
+ }
+
+ public int getInternalProviderFlag2() {
+ return mInternalProviderFlag2;
+ }
+
+ public int getInternalProviderFlag3() {
+ return mInternalProviderFlag3;
+ }
+
+ public int getInternalProviderFlag4() {
+ return mInternalProviderFlag4;
+ }
+
+ @Override
+ public String getDescription() {
+ return mShortDescription;
+ }
+
+ @Override
+ public String getLongDescription() {
+ return mLongDescription;
+ }
+
+ @Override
+ public String getPosterArtUri() {
+ return mPosterArtUri;
+ }
+
+ @Override
+ public boolean isValid() {
+ return true;
+ }
+
+ public boolean isSearchable() {
+ return mSearchable;
+ }
+
+ @Override
+ public String getSeriesId() {
+ return mSeriesId;
+ }
+
+ @Override
+ public String getSeasonNumber() {
+ return mSeasonNumber;
+ }
+
+ public String getSeasonTitle() {
+ return mSeasonTitle;
+ }
+
+ @Override
+ public long getStartTimeUtcMillis() {
+ return mStartTimeUtcMillis;
+ }
+
+ @Override
+ public String getThumbnailUri() {
+ return mThumbnailUri;
+ }
+
+ @Override
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public Uri getUri() {
+ return ContentUris.withAppendedId(RecordedPrograms.CONTENT_URI, mId);
+ }
+
+ public int getVersionNumber() {
+ return mVersionNumber;
+ }
+
+ public int getVideoHeight() {
+ return mVideoHeight;
+ }
+
+ public int getVideoWidth() {
+ return mVideoWidth;
+ }
+
+ /**
+ * Checks whether the recording has been clipped or not.
+ */
+ public boolean isClipped() {
+ return mEndTimeUtcMillis - mStartTimeUtcMillis - mDurationMillis > CLIPPED_THRESHOLD_MS;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ RecordedProgram that = (RecordedProgram) o;
+ return Objects.equals(mId, that.mId) &&
+ Objects.equals(mChannelId, that.mChannelId) &&
+ Objects.equals(mSeriesId, that.mSeriesId) &&
+ Objects.equals(mSeasonNumber, that.mSeasonNumber) &&
+ Objects.equals(mSeasonTitle, that.mSeasonTitle) &&
+ Objects.equals(mEpisodeNumber, that.mEpisodeNumber) &&
+ Objects.equals(mStartTimeUtcMillis, that.mStartTimeUtcMillis) &&
+ Objects.equals(mEndTimeUtcMillis, that.mEndTimeUtcMillis) &&
+ Objects.equals(mVideoWidth, that.mVideoWidth) &&
+ Objects.equals(mVideoHeight, that.mVideoHeight) &&
+ Objects.equals(mSearchable, that.mSearchable) &&
+ Objects.equals(mDataBytes, that.mDataBytes) &&
+ Objects.equals(mDurationMillis, that.mDurationMillis) &&
+ Objects.equals(mExpireTimeUtcMillis, that.mExpireTimeUtcMillis) &&
+ Objects.equals(mInternalProviderFlag1, that.mInternalProviderFlag1) &&
+ Objects.equals(mInternalProviderFlag2, that.mInternalProviderFlag2) &&
+ Objects.equals(mInternalProviderFlag3, that.mInternalProviderFlag3) &&
+ Objects.equals(mInternalProviderFlag4, that.mInternalProviderFlag4) &&
+ Objects.equals(mVersionNumber, that.mVersionNumber) &&
+ Objects.equals(mTitle, that.mTitle) &&
+ Objects.equals(mEpisodeTitle, that.mEpisodeTitle) &&
+ Arrays.equals(mBroadcastGenres, that.mBroadcastGenres) &&
+ Arrays.equals(mCanonicalGenres, that.mCanonicalGenres) &&
+ Objects.equals(mShortDescription, that.mShortDescription) &&
+ Objects.equals(mLongDescription, that.mLongDescription) &&
+ Objects.equals(mAudioLanguage, that.mAudioLanguage) &&
+ Objects.equals(mContentRating, that.mContentRating) &&
+ Objects.equals(mPosterArtUri, that.mPosterArtUri) &&
+ Objects.equals(mThumbnailUri, that.mThumbnailUri);
+ }
+
+ /**
+ * Hashes based on the ID.
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(mId);
+ }
+
+ @Override
+ public String toString() {
+ return "RecordedProgram"
+ + "[" + mId +
+ "]{ mPackageName=" + mPackageName +
+ ", mInputId='" + mInputId + '\'' +
+ ", mChannelId='" + mChannelId + '\'' +
+ ", mTitle='" + mTitle + '\'' +
+ ", mSeriesId='" + mSeriesId + '\'' +
+ ", mEpisodeNumber=" + mEpisodeNumber +
+ ", mEpisodeTitle='" + mEpisodeTitle + '\'' +
+ ", mStartTimeUtcMillis=" + mStartTimeUtcMillis +
+ ", mEndTimeUtcMillis=" + mEndTimeUtcMillis +
+ ", mBroadcastGenres=" +
+ (mBroadcastGenres != null ? Arrays.toString(mBroadcastGenres) : "null") +
+ ", mCanonicalGenres=" +
+ (mCanonicalGenres != null ? Arrays.toString(mCanonicalGenres) : "null") +
+ ", mShortDescription='" + mShortDescription + '\'' +
+ ", mLongDescription='" + mLongDescription + '\'' +
+ ", mVideoHeight=" + mVideoHeight +
+ ", mVideoWidth=" + mVideoWidth +
+ ", mAudioLanguage='" + mAudioLanguage + '\'' +
+ ", mContentRating='" + mContentRating + '\'' +
+ ", mPosterArtUri=" + mPosterArtUri +
+ ", mThumbnailUri=" + mThumbnailUri +
+ ", mSearchable=" + mSearchable +
+ ", mDataUri=" + mDataUri +
+ ", mDataBytes=" + mDataBytes +
+ ", mDurationMillis=" + mDurationMillis +
+ ", mExpireTimeUtcMillis=" + mExpireTimeUtcMillis +
+ ", mInternalProviderFlag1=" + mInternalProviderFlag1 +
+ ", mInternalProviderFlag2=" + mInternalProviderFlag2 +
+ ", mInternalProviderFlag3=" + mInternalProviderFlag3 +
+ ", mInternalProviderFlag4=" + mInternalProviderFlag4 +
+ ", mSeasonNumber=" + mSeasonNumber +
+ ", mSeasonTitle=" + mSeasonTitle +
+ ", mVersionNumber=" + mVersionNumber +
+ '}';
+ }
+
+ @Nullable
+ private static String safeToString(@Nullable Object o) {
+ return o == null ? null : o.toString();
+ }
+
+ @Nullable
+ private static String safeEncode(@Nullable String[] genres) {
+ return genres == null ? null : TvContract.Programs.Genres.encode(genres);
+ }
+
+ /**
+ * Returns an array containing all of the elements in the list.
+ */
+ public static RecordedProgram[] toArray(Collection<RecordedProgram> recordedPrograms) {
+ return recordedPrograms.toArray(new RecordedProgram[recordedPrograms.size()]);
+ }
+}
diff --git a/src/com/android/tv/dvr/RecordingTask.java b/src/com/android/tv/dvr/RecordingTask.java
index 804485b3..c3d236b0 100644
--- a/src/com/android/tv/dvr/RecordingTask.java
+++ b/src/com/android/tv/dvr/RecordingTask.java
@@ -16,21 +16,32 @@
package com.android.tv.dvr;
+import android.annotation.TargetApi;
+import android.content.Context;
import android.media.tv.TvContract;
-import android.media.tv.TvRecordingClient;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvRecordingClient.RecordingCallback;
import android.net.Uri;
+import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.util.Log;
+import android.widget.Toast;
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.RecordingSession;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.Channel;
+import com.android.tv.dvr.InputTaskScheduler.HandlerWrapper;
import com.android.tv.util.Clock;
import com.android.tv.util.Utils;
+import java.util.Comparator;
import java.util.concurrent.TimeUnit;
/**
@@ -40,22 +51,66 @@ import java.util.concurrent.TimeUnit;
* There is only one looper so messages must be handled quickly or start a separate thread.
*/
@WorkerThread
-class RecordingTask extends TvRecordingClient.RecordingCallback
- implements Handler.Callback, DvrManager.Listener {
+@VisibleForTesting
+@TargetApi(Build.VERSION_CODES.N)
+public class RecordingTask extends RecordingCallback implements Handler.Callback,
+ DvrManager.Listener {
private static final String TAG = "RecordingTask";
private static final boolean DEBUG = false;
- @VisibleForTesting
- static final int MESSAGE_INIT = 1;
- @VisibleForTesting
- static final int MESSAGE_START_RECORDING = 2;
- @VisibleForTesting
- static final int MESSAGE_STOP_RECORDING = 3;
+ /**
+ * Compares the end time in ascending order.
+ */
+ public static final Comparator<RecordingTask> END_TIME_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getEndTimeMs(), rhs.getEndTimeMs());
+ }
+ };
+
+ /**
+ * Compares ID in ascending order.
+ */
+ public static final Comparator<RecordingTask> ID_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getScheduleId(), rhs.getScheduleId());
+ }
+ };
+
+ /**
+ * Compares the priority in ascending order.
+ */
+ public static final Comparator<RecordingTask> PRIORITY_COMPARATOR
+ = new Comparator<RecordingTask>() {
+ @Override
+ public int compare(RecordingTask lhs, RecordingTask rhs) {
+ return Long.compare(lhs.getPriority(), rhs.getPriority());
+ }
+ };
@VisibleForTesting
- static final long MS_BEFORE_START = TimeUnit.SECONDS.toMillis(5);
+ static final int MSG_INITIALIZE = 1;
@VisibleForTesting
- static final long MS_AFTER_END = TimeUnit.SECONDS.toMillis(5);
+ static final int MSG_START_RECORDING = 2;
+ @VisibleForTesting
+ static final int MSG_STOP_RECORDING = 3;
+ /**
+ * Message to update schedule.
+ */
+ public static final int MSG_UDPATE_SCHEDULE = 4;
+
+ /**
+ * The time when the start command will be sent before the recording starts.
+ */
+ public static final long RECORDING_EARLY_START_OFFSET_MS = TimeUnit.SECONDS.toMillis(3);
+ /**
+ * If the recording starts later than the scheduled start time or ends before the scheduled end
+ * time, it's considered as clipped.
+ */
+ private static final long CLIPPED_THRESHOLD_MS = TimeUnit.MINUTES.toMillis(5);
@VisibleForTesting
enum State {
@@ -63,27 +118,32 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
SESSION_ACQUIRED,
CONNECTION_PENDING,
CONNECTED,
- RECORDING_START_REQUESTED,
RECORDING_STARTED,
RECORDING_STOP_REQUESTED,
+ FINISHED,
ERROR,
RELEASED,
}
- private final DvrSessionManager mSessionManager;
+ private final InputSessionManager mSessionManager;
private final DvrManager mDvrManager;
+ private final Context mContext;
private final WritableDvrDataManager mDataManager;
private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
- private TvRecordingClient mTvRecordingClient;
+ private RecordingSession mRecordingSession;
private Handler mHandler;
private ScheduledRecording mScheduledRecording;
private final Channel mChannel;
private State mState = State.NOT_STARTED;
private final Clock mClock;
+ private boolean mStartedWithClipping;
+ private Uri mRecordedProgramUri;
+ private boolean mCanceled;
- RecordingTask(ScheduledRecording scheduledRecording, Channel channel,
- DvrManager dvrManager, DvrSessionManager sessionManager,
+ RecordingTask(Context context, ScheduledRecording scheduledRecording, Channel channel,
+ DvrManager dvrManager, InputSessionManager sessionManager,
WritableDvrDataManager dataManager, Clock clock) {
+ mContext = context;
mScheduledRecording = scheduledRecording;
mChannel = channel;
mSessionManager = sessionManager;
@@ -101,27 +161,30 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
@Override
public boolean handleMessage(Message msg) {
if (DEBUG) Log.d(TAG, "handleMessage " + msg);
- SoftPreconditions
- .checkState(msg.what == Scheduler.HandlerWrapper.MESSAGE_REMOVE || mHandler != null,
- TAG, "Null handler trying to handle " + msg);
+ SoftPreconditions.checkState(msg.what == HandlerWrapper.MESSAGE_REMOVE || mHandler != null,
+ TAG, "Null handler trying to handle " + msg);
try {
switch (msg.what) {
- case MESSAGE_INIT:
+ case MSG_INITIALIZE:
handleInit();
break;
- case MESSAGE_START_RECORDING:
+ case MSG_START_RECORDING:
handleStartRecording();
break;
- case MESSAGE_STOP_RECORDING:
+ case MSG_STOP_RECORDING:
handleStopRecording();
break;
- case Scheduler.HandlerWrapper.MESSAGE_REMOVE:
- // Clear the handler
+ case MSG_UDPATE_SCHEDULE:
+ handleUpdateSchedule((ScheduledRecording) msg.obj);
+ break;
+ case HandlerWrapper.MESSAGE_REMOVE:
+ mHandler.removeCallbacksAndMessages(null);
mHandler = null;
release();
return false;
default:
SoftPreconditions.checkArgument(false, TAG, "unexpected message type " + msg);
+ break;
}
return true;
} catch (Exception e) {
@@ -132,54 +195,91 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
}
@Override
+ public void onDisconnected(String inputId) {
+ if (DEBUG) Log.d(TAG, "onDisconnected(" + inputId + ")");
+ if (mRecordingSession != null && mState != State.FINISHED) {
+ failAndQuit();
+ }
+ }
+
+ @Override
+ public void onConnectionFailed(String inputId) {
+ if (DEBUG) Log.d(TAG, "onConnectionFailed(" + inputId + ")");
+ if (mRecordingSession != null) {
+ failAndQuit();
+ }
+ }
+
+ @Override
public void onTuned(Uri channelUri) {
- if (DEBUG) {
- Log.d(TAG, "onTuned");
+ if (DEBUG) Log.d(TAG, "onTuned");
+ if (mRecordingSession == null) {
+ return;
}
- super.onTuned(channelUri);
mState = State.CONNECTED;
- if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MESSAGE_START_RECORDING,
- mScheduledRecording.getStartTimeMs() - MS_BEFORE_START)) {
- mState = State.ERROR;
- return;
+ if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MSG_START_RECORDING,
+ mScheduledRecording.getStartTimeMs() - RECORDING_EARLY_START_OFFSET_MS)) {
+ failAndQuit();
}
}
-
@Override
public void onRecordingStopped(Uri recordedProgramUri) {
- super.onRecordingStopped(recordedProgramUri);
- mState = State.CONNECTED;
- updateRecording(ScheduledRecording.buildFrom(mScheduledRecording)
- .setState(ScheduledRecording.STATE_RECORDING_FINISHED).build());
+ if (DEBUG) Log.d(TAG, "onRecordingStopped");
+ if (mRecordingSession == null) {
+ return;
+ }
+ mRecordedProgramUri = recordedProgramUri;
+ mState = State.FINISHED;
+ int state = ScheduledRecording.STATE_RECORDING_FINISHED;
+ if (mStartedWithClipping || mScheduledRecording.getEndTimeMs() - CLIPPED_THRESHOLD_MS
+ > mClock.currentTimeMillis()) {
+ state = ScheduledRecording.STATE_RECORDING_CLIPPED;
+ }
+ updateRecordingState(state);
sendRemove();
+ if (mCanceled) {
+ removeRecordedProgram();
+ }
}
@Override
public void onError(int reason) {
if (DEBUG) Log.d(TAG, "onError reason " + reason);
- super.onError(reason);
- // TODO(dvr) handle success
+ if (mRecordingSession == null) {
+ return;
+ }
switch (reason) {
+ case TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE:
+ mMainThreadHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (TvApplication.getSingletons(mContext).getMainActivityWrapper()
+ .isResumed()) {
+ Toast.makeText(mContext.getApplicationContext(),
+ R.string.dvr_error_insufficient_space_description,
+ Toast.LENGTH_LONG)
+ .show();
+ } else {
+ Utils.setRecordingFailedReason(mContext.getApplicationContext(),
+ TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ }
+ }
+ });
+ // Pass through
default:
- updateRecording(ScheduledRecording.buildFrom(mScheduledRecording)
- .setState(ScheduledRecording.STATE_RECORDING_FAILED)
- .build());
+ failAndQuit();
+ break;
}
- release();
- sendRemove();
}
private void handleInit() {
if (DEBUG) Log.d(TAG, "handleInit " + mScheduledRecording);
- //TODO check recording preconditions
-
if (mScheduledRecording.getEndTimeMs() < mClock.currentTimeMillis()) {
Log.w(TAG, "End time already past, not recording " + mScheduledRecording);
failAndQuit();
return;
}
-
if (mChannel == null) {
Log.w(TAG, "Null channel for " + mScheduledRecording);
failAndQuit();
@@ -193,18 +293,12 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
}
String inputId = mChannel.getInputId();
- if (mSessionManager.canAcquireDvrSession(inputId, mChannel)) {
- mTvRecordingClient = mSessionManager
- .createTvRecordingClient("recordingTask-" + mScheduledRecording.getId(), this,
- mHandler);
- mState = State.SESSION_ACQUIRED;
- } else {
- Log.w(TAG, "Unable to acquire a session for " + mScheduledRecording);
- failAndQuit();
- return;
- }
+ mRecordingSession = mSessionManager.createRecordingSession(inputId,
+ "recordingTask-" + mScheduledRecording.getId(), this,
+ mHandler, mScheduledRecording.getEndTimeMs());
+ mState = State.SESSION_ACQUIRED;
mDvrManager.addListener(this, mHandler);
- mTvRecordingClient.tune(inputId, mChannel.getUri());
+ mRecordingSession.tune(inputId, mChannel.getUri());
mState = State.CONNECTION_PENDING;
}
@@ -218,41 +312,86 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
private void sendRemove() {
if (DEBUG) Log.d(TAG, "sendRemove");
if (mHandler != null) {
- mHandler.sendEmptyMessage(Scheduler.HandlerWrapper.MESSAGE_REMOVE);
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(
+ HandlerWrapper.MESSAGE_REMOVE));
}
}
private void handleStartRecording() {
if (DEBUG) Log.d(TAG, "handleStartRecording " + mScheduledRecording);
- // TODO(DVR) handle errors
long programId = mScheduledRecording.getProgramId();
- mTvRecordingClient.startRecording(programId == ScheduledRecording.ID_NOT_SET ? null
+ mRecordingSession.startRecording(programId == ScheduledRecording.ID_NOT_SET ? null
: TvContract.buildProgramUri(programId));
- updateRecording(ScheduledRecording.buildFrom(mScheduledRecording)
- .setState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS).build());
+ updateRecordingState(ScheduledRecording.STATE_RECORDING_IN_PROGRESS);
+ // If it starts late, it's clipped.
+ if (mScheduledRecording.getStartTimeMs() + CLIPPED_THRESHOLD_MS
+ < mClock.currentTimeMillis()) {
+ mStartedWithClipping = true;
+ }
mState = State.RECORDING_STARTED;
- if (mHandler == null || !sendEmptyMessageAtAbsoluteTime(MESSAGE_STOP_RECORDING,
- mScheduledRecording.getEndTimeMs() + MS_AFTER_END)) {
- mState = State.ERROR;
- return;
+ if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING,
+ mScheduledRecording.getEndTimeMs())) {
+ failAndQuit();
}
}
private void handleStopRecording() {
if (DEBUG) Log.d(TAG, "handleStopRecording " + mScheduledRecording);
- mTvRecordingClient.stopRecording();
+ mRecordingSession.stopRecording();
mState = State.RECORDING_STOP_REQUESTED;
}
+ private void handleUpdateSchedule(ScheduledRecording schedule) {
+ mScheduledRecording = schedule;
+ // Check end time only. The start time is checked in InputTaskScheduler.
+ if (schedule.getEndTimeMs() != mScheduledRecording.getEndTimeMs()) {
+ if (mRecordingSession != null) {
+ mRecordingSession.setEndTimeMs(schedule.getEndTimeMs());
+ }
+ if (mState == State.RECORDING_STARTED) {
+ mHandler.removeMessages(MSG_STOP_RECORDING);
+ if (!sendEmptyMessageAtAbsoluteTime(MSG_STOP_RECORDING, schedule.getEndTimeMs())) {
+ failAndQuit();
+ }
+ }
+ }
+ }
+
@VisibleForTesting
State getState() {
return mState;
}
+ private long getScheduleId() {
+ return mScheduledRecording.getId();
+ }
+
+ /**
+ * Returns the priority.
+ */
+ public long getPriority() {
+ return mScheduledRecording.getPriority();
+ }
+
+ /**
+ * Returns the start time of the recording.
+ */
+ public long getStartTimeMs() {
+ return mScheduledRecording.getStartTimeMs();
+ }
+
+ /**
+ * Returns the end time of the recording.
+ */
+ public long getEndTimeMs() {
+ return mScheduledRecording.getEndTimeMs();
+ }
+
private void release() {
- if (mTvRecordingClient != null) {
- mSessionManager.releaseTvRecordingClient(mTvRecordingClient);
+ if (mRecordingSession != null) {
+ mSessionManager.releaseRecordingSession(mRecordingSession);
+ mRecordingSession = null;
}
mDvrManager.removeListener(this);
}
@@ -268,22 +407,24 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
}
private void updateRecordingState(@ScheduledRecording.RecordingState int state) {
- updateRecording(ScheduledRecording.buildFrom(mScheduledRecording).setState(state).build());
- }
-
- @VisibleForTesting
- static Uri getIdAsMediaUri(ScheduledRecording scheduledRecording) {
- // TODO define the URI format
- return new Uri.Builder().appendPath(String.valueOf(scheduledRecording.getId())).build();
- }
-
- private void updateRecording(ScheduledRecording updatedScheduledRecording) {
- if (DEBUG) Log.d(TAG, "updateScheduledRecording " + updatedScheduledRecording);
- mScheduledRecording = updatedScheduledRecording;
- mMainThreadHandler.post(new Runnable() {
+ if (DEBUG) Log.d(TAG, "Updating the state of " + mScheduledRecording + " to " + state);
+ mScheduledRecording = ScheduledRecording.buildFrom(mScheduledRecording).setState(state)
+ .build();
+ runOnMainThread(new Runnable() {
@Override
public void run() {
- mDataManager.updateScheduledRecording(mScheduledRecording);
+ ScheduledRecording schedule = mDataManager.getScheduledRecording(
+ mScheduledRecording.getId());
+ if (schedule == null) {
+ // Schedule has been deleted. Delete the recorded program.
+ removeRecordedProgram();
+ } else {
+ // Update the state based on the object in DataManager in case when it has been
+ // updated. mScheduledRecording will be updated from
+ // onScheduledRecordingStateChanged.
+ mDataManager.updateScheduledRecording(ScheduledRecording.buildFrom(schedule)
+ .setState(state).build());
+ }
}
});
}
@@ -293,9 +434,24 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
if (recording.getId() != mScheduledRecording.getId()) {
return;
}
+ stop();
+ }
+
+ /**
+ * Starts the task.
+ */
+ public void start() {
+ mHandler.sendEmptyMessage(MSG_INITIALIZE);
+ }
+
+ /**
+ * Stops the task.
+ */
+ public void stop() {
+ if (DEBUG) Log.d(TAG, "stop");
switch (mState) {
case RECORDING_STARTED:
- mHandler.removeMessages(MESSAGE_STOP_RECORDING);
+ mHandler.removeMessages(MSG_STOP_RECORDING);
handleStopRecording();
break;
case RECORDING_STOP_REQUESTED:
@@ -305,7 +461,7 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
case SESSION_ACQUIRED:
case CONNECTION_PENDING:
case CONNECTED:
- case RECORDING_START_REQUESTED:
+ case FINISHED:
case ERROR:
case RELEASED:
default:
@@ -314,8 +470,50 @@ class RecordingTask extends TvRecordingClient.RecordingCallback
}
}
+ /**
+ * Cancels the task
+ */
+ public void cancel() {
+ if (DEBUG) Log.d(TAG, "cancel");
+ mCanceled = true;
+ stop();
+ removeRecordedProgram();
+ }
+
+ /**
+ * Clean up the task.
+ */
+ public void cleanUp() {
+ if (mState == State.RECORDING_STARTED || mState == State.RECORDING_STOP_REQUESTED) {
+ updateRecordingState(ScheduledRecording.STATE_RECORDING_FAILED);
+ }
+ release();
+ if (mHandler != null) {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+ }
+
@Override
public String toString() {
return getClass().getName() + "(" + mScheduledRecording + ")";
}
+
+ private void removeRecordedProgram() {
+ runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ if (mRecordedProgramUri != null) {
+ mDvrManager.removeRecordedProgram(mRecordedProgramUri);
+ }
+ }
+ });
+ }
+
+ private void runOnMainThread(Runnable runnable) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ runnable.run();
+ } else {
+ mMainThreadHandler.post(runnable);
+ }
+ }
}
diff --git a/src/com/android/tv/dvr/ScheduledProgramReaper.java b/src/com/android/tv/dvr/ScheduledProgramReaper.java
index 9053eaec..cd79a631 100644
--- a/src/com/android/tv/dvr/ScheduledProgramReaper.java
+++ b/src/com/android/tv/dvr/ScheduledProgramReaper.java
@@ -21,6 +21,7 @@ import android.support.annotation.VisibleForTesting;
import com.android.tv.util.Clock;
+import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -42,12 +43,25 @@ class ScheduledProgramReaper implements Runnable {
@Override
@MainThread
public void run() {
- List<ScheduledRecording> recordings = mDvrDataManager.getAllScheduledRecordings();
long cutoff = mClock.currentTimeMillis() - TimeUnit.DAYS.toMillis(DAYS);
- for (ScheduledRecording r : recordings) {
+ List<ScheduledRecording> toRemove = new ArrayList<>();
+ for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) {
+ // Do not remove the schedules if it belongs to the series recording and was finished
+ // successfully. The schedule is necessary for checking the scheduled episode of the
+ // series recording.
+ if (r.getEndTimeMs() < cutoff
+ && (r.getSeriesRecordingId() == SeriesRecording.ID_NOT_SET
+ || r.getState() != ScheduledRecording.STATE_RECORDING_FINISHED)) {
+ toRemove.add(r);
+ }
+ }
+ for (ScheduledRecording r : mDvrDataManager.getDeletedSchedules()) {
if (r.getEndTimeMs() < cutoff) {
- mDvrDataManager.removeScheduledRecording(r);
+ toRemove.add(r);
}
}
+ if (!toRemove.isEmpty()) {
+ mDvrDataManager.removeScheduledRecording(ScheduledRecording.toArray(toRemove));
+ }
}
}
diff --git a/src/com/android/tv/dvr/ScheduledRecording.java b/src/com/android/tv/dvr/ScheduledRecording.java
index 01b00459..2bda10ea 100644
--- a/src/com/android/tv/dvr/ScheduledRecording.java
+++ b/src/com/android/tv/dvr/ScheduledRecording.java
@@ -17,87 +17,169 @@
package com.android.tv.dvr;
import android.content.ContentValues;
+import android.content.Context;
import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
import android.support.annotation.IntDef;
import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
import android.util.Range;
+import com.android.tv.R;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.Channel;
import com.android.tv.data.Program;
-import com.android.tv.dvr.provider.DvrContract;
+import com.android.tv.dvr.provider.DvrContract.Schedules;
+import com.android.tv.util.CompositeComparator;
import com.android.tv.util.Utils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.Collection;
import java.util.Comparator;
+import java.util.Objects;
/**
* A data class for one recording contents.
*/
@VisibleForTesting
-public final class ScheduledRecording {
- private static final String TAG = "Recording";
+public final class ScheduledRecording implements Parcelable {
+ private static final String TAG = "ScheduledRecording";
- public static final String RECORDING_ID_EXTRA = "extra.dvr.recording.id"; //TODO(DVR) move
- public static final String PARAM_INPUT_ID = "input_id";
+ /**
+ * Indicates that the ID is not assigned yet.
+ */
+ public static final long ID_NOT_SET = 0;
- public static final long ID_NOT_SET = -1;
+ /**
+ * The default priority of the recording.
+ */
+ public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1;
- public static final Comparator<ScheduledRecording> START_TIME_COMPARATOR = new Comparator<ScheduledRecording>() {
+ /**
+ * Compares the start time in ascending order.
+ */
+ public static final Comparator<ScheduledRecording> START_TIME_COMPARATOR
+ = new Comparator<ScheduledRecording>() {
@Override
public int compare(ScheduledRecording lhs, ScheduledRecording rhs) {
return Long.compare(lhs.mStartTimeMs, rhs.mStartTimeMs);
}
};
- public static final Comparator<ScheduledRecording> PRIORITY_COMPARATOR = new Comparator<ScheduledRecording>() {
+ /**
+ * Compares the end time in ascending order.
+ */
+ public static final Comparator<ScheduledRecording> END_TIME_COMPARATOR
+ = new Comparator<ScheduledRecording>() {
@Override
public int compare(ScheduledRecording lhs, ScheduledRecording rhs) {
- int value = Long.compare(lhs.mPriority, rhs.mPriority);
- if (value == 0) {
- value = Long.compare(lhs.mId, rhs.mId);
- }
- return value;
+ return Long.compare(lhs.mEndTimeMs, rhs.mEndTimeMs);
+ }
+ };
+
+ /**
+ * Compares ID in ascending order. The schedule with the larger ID was created later.
+ */
+ public static final Comparator<ScheduledRecording> ID_COMPARATOR
+ = new Comparator<ScheduledRecording>() {
+ @Override
+ public int compare(ScheduledRecording lhs, ScheduledRecording rhs) {
+ return Long.compare(lhs.mId, rhs.mId);
}
};
- public static final Comparator<ScheduledRecording> START_TIME_THEN_PRIORITY_COMPARATOR
+ /**
+ * Compares the priority in ascending order.
+ */
+ public static final Comparator<ScheduledRecording> PRIORITY_COMPARATOR
= new Comparator<ScheduledRecording>() {
@Override
public int compare(ScheduledRecording lhs, ScheduledRecording rhs) {
- int value = START_TIME_COMPARATOR.compare(lhs, rhs);
- if (value == 0) {
- value = PRIORITY_COMPARATOR.compare(lhs, rhs);
- }
- return value;
+ return Long.compare(lhs.mPriority, rhs.mPriority);
}
};
- public static Builder builder(Program p) {
+ /**
+ * Compares start time in ascending order and then priority in descending order and then ID in
+ * descending order.
+ */
+ public static final Comparator<ScheduledRecording> START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR
+ = new CompositeComparator<>(START_TIME_COMPARATOR, PRIORITY_COMPARATOR.reversed(),
+ ID_COMPARATOR.reversed());
+
+ /**
+ * Builds scheduled recordings from programs.
+ */
+ public static Builder builder(String inputId, Program p) {
return new Builder()
- .setStartTime(p.getStartTimeUtcMillis()).setEndTime(p.getEndTimeUtcMillis())
+ .setInputId(inputId)
+ .setChannelId(p.getChannelId())
+ .setStartTimeMs(p.getStartTimeUtcMillis()).setEndTimeMs(p.getEndTimeUtcMillis())
.setProgramId(p.getId())
+ .setProgramTitle(p.getTitle())
+ .setSeasonNumber(p.getSeasonNumber())
+ .setEpisodeNumber(p.getEpisodeNumber())
+ .setEpisodeTitle(p.getEpisodeTitle())
+ .setProgramDescription(p.getDescription())
+ .setProgramLongDescription(p.getLongDescription())
+ .setProgramPosterArtUri(p.getPosterArtUri())
+ .setProgramThumbnailUri(p.getThumbnailUri())
.setType(TYPE_PROGRAM);
}
- public static Builder builder(long startTime, long endTime) {
+ public static Builder builder(String inputId, long channelId, long startTime, long endTime) {
return new Builder()
- .setStartTime(startTime)
- .setEndTime(endTime)
+ .setInputId(inputId)
+ .setChannelId(channelId)
+ .setStartTimeMs(startTime)
+ .setEndTimeMs(endTime)
.setType(TYPE_TIMED);
}
+ /**
+ * Creates a new Builder with the values set from the {@link RecordedProgram}.
+ */
+ @VisibleForTesting
+ public static Builder builder(RecordedProgram p) {
+ boolean isProgramRecording = !TextUtils.isEmpty(p.getTitle());
+ return new Builder()
+ .setInputId(p.getInputId())
+ .setChannelId(p.getChannelId())
+ .setType(isProgramRecording ? TYPE_PROGRAM : TYPE_TIMED)
+ .setStartTimeMs(p.getStartTimeUtcMillis())
+ .setEndTimeMs(p.getEndTimeUtcMillis())
+ .setProgramTitle(p.getTitle())
+ .setSeasonNumber(p.getSeasonNumber())
+ .setEpisodeNumber(p.getEpisodeNumber())
+ .setEpisodeTitle(p.getEpisodeTitle())
+ .setProgramDescription(p.getDescription())
+ .setProgramLongDescription(p.getLongDescription())
+ .setProgramPosterArtUri(p.getPosterArtUri())
+ .setProgramThumbnailUri(p.getThumbnailUri())
+ .setState(STATE_RECORDING_FINISHED);
+ }
+
public static final class Builder {
private long mId = ID_NOT_SET;
- private long mPriority = Long.MAX_VALUE;
+ private long mPriority = DvrScheduleManager.DEFAULT_PRIORITY;
+ private String mInputId;
private long mChannelId;
private long mProgramId = ID_NOT_SET;
+ private String mProgramTitle;
private @RecordingType int mType;
- private long mStartTime;
- private long mEndTime;
+ private long mStartTimeMs;
+ private long mEndTimeMs;
+ private String mSeasonNumber;
+ private String mEpisodeNumber;
+ private String mEpisodeTitle;
+ private String mProgramDescription;
+ private String mProgramLongDescription;
+ private String mProgramPosterArtUri;
+ private String mProgramThumbnailUri;
private @RecordingState int mState;
- private SeasonRecording mParentSeasonRecording;
+ private long mSeriesRecordingId = ID_NOT_SET;
private Builder() { }
@@ -111,6 +193,11 @@ public final class ScheduledRecording {
return this;
}
+ public Builder setInputId(String inputId) {
+ mInputId = inputId;
+ return this;
+ }
+
public Builder setChannelId(long channelId) {
mChannelId = channelId;
return this;
@@ -121,18 +208,58 @@ public final class ScheduledRecording {
return this;
}
+ public Builder setProgramTitle(String programTitle) {
+ mProgramTitle = programTitle;
+ return this;
+ }
+
private Builder setType(@RecordingType int type) {
mType = type;
return this;
}
- public Builder setStartTime(long startTime) {
- mStartTime = startTime;
+ public Builder setStartTimeMs(long startTimeMs) {
+ mStartTimeMs = startTimeMs;
+ return this;
+ }
+
+ public Builder setEndTimeMs(long endTimeMs) {
+ mEndTimeMs = endTimeMs;
+ return this;
+ }
+
+ public Builder setSeasonNumber(String seasonNumber) {
+ mSeasonNumber = seasonNumber;
+ return this;
+ }
+
+ public Builder setEpisodeNumber(String episodeNumber) {
+ mEpisodeNumber = episodeNumber;
return this;
}
- public Builder setEndTime(long endTime) {
- mEndTime = endTime;
+ public Builder setEpisodeTitle(String episodeTitle) {
+ mEpisodeTitle = episodeTitle;
+ return this;
+ }
+
+ public Builder setProgramDescription(String description) {
+ mProgramDescription = description;
+ return this;
+ }
+
+ public Builder setProgramLongDescription(String longDescription) {
+ mProgramLongDescription = longDescription;
+ return this;
+ }
+
+ public Builder setProgramPosterArtUri(String programPosterArtUri) {
+ mProgramPosterArtUri = programPosterArtUri;
+ return this;
+ }
+
+ public Builder setProgramThumbnailUri(String programThumbnailUri) {
+ mProgramThumbnailUri = programThumbnailUri;
return this;
}
@@ -141,14 +268,16 @@ public final class ScheduledRecording {
return this;
}
- public Builder setParentSeasonRecording(SeasonRecording parentSeasonRecording) {
- mParentSeasonRecording = parentSeasonRecording;
+ public Builder setSeriesRecordingId(long seriesRecordingId) {
+ mSeriesRecordingId = seriesRecordingId;
return this;
}
public ScheduledRecording build() {
- return new ScheduledRecording(mId, mPriority, mChannelId, mProgramId, mType, mStartTime,
- mEndTime, mState, mParentSeasonRecording);
+ return new ScheduledRecording(mId, mPriority, mInputId, mChannelId, mProgramId,
+ mProgramTitle, mType, mStartTimeMs, mEndTimeMs, mSeasonNumber, mEpisodeNumber,
+ mEpisodeTitle, mProgramDescription, mProgramLongDescription,
+ mProgramPosterArtUri, mProgramThumbnailUri, mState, mSeriesRecordingId);
}
}
@@ -157,22 +286,37 @@ public final class ScheduledRecording {
*/
public static Builder buildFrom(ScheduledRecording orig) {
return new Builder()
- .setId(orig.mId).setChannelId(orig.mChannelId)
- .setEndTime(orig.mEndTimeMs).setParentSeasonRecording(orig.mParentSeasonRecording)
+ .setId(orig.mId)
+ .setInputId(orig.mInputId)
+ .setChannelId(orig.mChannelId)
+ .setEndTimeMs(orig.mEndTimeMs)
+ .setSeriesRecordingId(orig.mSeriesRecordingId)
+ .setPriority(orig.mPriority)
.setProgramId(orig.mProgramId)
- .setStartTime(orig.mStartTimeMs).setState(orig.mState).setType(orig.mType);
+ .setProgramTitle(orig.mProgramTitle)
+ .setStartTimeMs(orig.mStartTimeMs)
+ .setSeasonNumber(orig.getSeasonNumber())
+ .setEpisodeNumber(orig.getEpisodeNumber())
+ .setEpisodeTitle(orig.getEpisodeTitle())
+ .setProgramDescription(orig.getProgramDescription())
+ .setProgramLongDescription(orig.getProgramLongDescription())
+ .setProgramPosterArtUri(orig.getProgramPosterArtUri())
+ .setProgramThumbnailUri(orig.getProgramThumbnailUri())
+ .setState(orig.mState).setType(orig.mType);
}
@Retention(RetentionPolicy.SOURCE)
- @IntDef({STATE_RECORDING_NOT_STARTED, STATE_RECORDING_IN_PROGRESS,
- STATE_RECORDING_UNEXPECTEDLY_STOPPED, STATE_RECORDING_FINISHED, STATE_RECORDING_FAILED})
+ @IntDef({STATE_RECORDING_NOT_STARTED, STATE_RECORDING_IN_PROGRESS, STATE_RECORDING_FINISHED,
+ STATE_RECORDING_FAILED, STATE_RECORDING_CLIPPED, STATE_RECORDING_DELETED,
+ STATE_RECORDING_CANCELED})
public @interface RecordingState {}
public static final int STATE_RECORDING_NOT_STARTED = 0;
public static final int STATE_RECORDING_IN_PROGRESS = 1;
- @Deprecated // It is not used.
- public static final int STATE_RECORDING_UNEXPECTEDLY_STOPPED = 2;
- public static final int STATE_RECORDING_FINISHED = 3;
- public static final int STATE_RECORDING_FAILED = 4;
+ public static final int STATE_RECORDING_FINISHED = 2;
+ public static final int STATE_RECORDING_FAILED = 3;
+ public static final int STATE_RECORDING_CLIPPED = 4;
+ public static final int STATE_RECORDING_DELETED = 5;
+ public static final int STATE_RECORDING_CANCELED = 6;
@Retention(RetentionPolicy.SOURCE)
@IntDef({TYPE_TIMED, TYPE_PROGRAM})
@@ -180,27 +324,39 @@ public final class ScheduledRecording {
/**
* Record with given time range.
*/
- static final int TYPE_TIMED = 1;
+ public static final int TYPE_TIMED = 1;
/**
* Record with a given program.
*/
- static final int TYPE_PROGRAM = 2;
+ public static final int TYPE_PROGRAM = 2;
@RecordingType private final int mType;
/**
- * Use this projection if you want to create {@link ScheduledRecording} object using {@link #fromCursor}.
+ * Use this projection if you want to create {@link ScheduledRecording} object using
+ * {@link #fromCursor}.
*/
public static final String[] PROJECTION = {
- // Columns must match what is read in Recording.fromCursor()
- DvrContract.Recordings._ID,
- DvrContract.Recordings.COLUMN_PRIORITY,
- DvrContract.Recordings.COLUMN_TYPE,
- DvrContract.Recordings.COLUMN_CHANNEL_ID,
- DvrContract.Recordings.COLUMN_PROGRAM_ID,
- DvrContract.Recordings.COLUMN_START_TIME_UTC_MILLIS,
- DvrContract.Recordings.COLUMN_END_TIME_UTC_MILLIS,
- DvrContract.Recordings.COLUMN_STATE};
+ // Columns must match what is read in #fromCursor
+ Schedules._ID,
+ Schedules.COLUMN_PRIORITY,
+ Schedules.COLUMN_TYPE,
+ Schedules.COLUMN_INPUT_ID,
+ Schedules.COLUMN_CHANNEL_ID,
+ Schedules.COLUMN_PROGRAM_ID,
+ Schedules.COLUMN_PROGRAM_TITLE,
+ Schedules.COLUMN_START_TIME_UTC_MILLIS,
+ Schedules.COLUMN_END_TIME_UTC_MILLIS,
+ Schedules.COLUMN_SEASON_NUMBER,
+ Schedules.COLUMN_EPISODE_NUMBER,
+ Schedules.COLUMN_EPISODE_TITLE,
+ Schedules.COLUMN_PROGRAM_DESCRIPTION,
+ Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION,
+ Schedules.COLUMN_PROGRAM_POST_ART_URI,
+ Schedules.COLUMN_PROGRAM_THUMBNAIL_URI,
+ Schedules.COLUMN_STATE,
+ Schedules.COLUMN_SERIES_RECORDING_ID};
+
/**
* Creates {@link ScheduledRecording} object from the given {@link Cursor}.
*/
@@ -210,65 +366,145 @@ public final class ScheduledRecording {
.setId(c.getLong(++index))
.setPriority(c.getLong(++index))
.setType(recordingType(c.getString(++index)))
+ .setInputId(c.getString(++index))
.setChannelId(c.getLong(++index))
.setProgramId(c.getLong(++index))
- .setStartTime(c.getLong(++index))
- .setEndTime(c.getLong(++index))
+ .setProgramTitle(c.getString(++index))
+ .setStartTimeMs(c.getLong(++index))
+ .setEndTimeMs(c.getLong(++index))
+ .setSeasonNumber(c.getString(++index))
+ .setEpisodeNumber(c.getString(++index))
+ .setEpisodeTitle(c.getString(++index))
+ .setProgramDescription(c.getString(++index))
+ .setProgramLongDescription(c.getString(++index))
+ .setProgramPosterArtUri(c.getString(++index))
+ .setProgramThumbnailUri(c.getString(++index))
.setState(recordingState(c.getString(++index)))
+ .setSeriesRecordingId(c.getLong(++index))
.build();
}
public static ContentValues toContentValues(ScheduledRecording r) {
ContentValues values = new ContentValues();
- values.put(DvrContract.Recordings.COLUMN_CHANNEL_ID, r.getChannelId());
- values.put(DvrContract.Recordings.COLUMN_PROGRAM_ID, r.getProgramId());
- values.put(DvrContract.Recordings.COLUMN_PRIORITY, r.getPriority());
- values.put(DvrContract.Recordings.COLUMN_START_TIME_UTC_MILLIS, r.getStartTimeMs());
- values.put(DvrContract.Recordings.COLUMN_END_TIME_UTC_MILLIS, r.getEndTimeMs());
- values.put(DvrContract.Recordings.COLUMN_STATE, r.getState());
- values.put(DvrContract.Recordings.COLUMN_TYPE, r.getType());
+ if (r.getId() != ID_NOT_SET) {
+ values.put(Schedules._ID, r.getId());
+ }
+ values.put(Schedules.COLUMN_INPUT_ID, r.getInputId());
+ values.put(Schedules.COLUMN_CHANNEL_ID, r.getChannelId());
+ values.put(Schedules.COLUMN_PROGRAM_ID, r.getProgramId());
+ values.put(Schedules.COLUMN_PROGRAM_TITLE, r.getProgramTitle());
+ values.put(Schedules.COLUMN_PRIORITY, r.getPriority());
+ values.put(Schedules.COLUMN_START_TIME_UTC_MILLIS, r.getStartTimeMs());
+ values.put(Schedules.COLUMN_END_TIME_UTC_MILLIS, r.getEndTimeMs());
+ values.put(Schedules.COLUMN_SEASON_NUMBER, r.getSeasonNumber());
+ values.put(Schedules.COLUMN_EPISODE_NUMBER, r.getEpisodeNumber());
+ values.put(Schedules.COLUMN_EPISODE_TITLE, r.getEpisodeTitle());
+ values.put(Schedules.COLUMN_PROGRAM_DESCRIPTION, r.getProgramDescription());
+ values.put(Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION, r.getProgramLongDescription());
+ values.put(Schedules.COLUMN_PROGRAM_POST_ART_URI, r.getProgramPosterArtUri());
+ values.put(Schedules.COLUMN_PROGRAM_THUMBNAIL_URI, r.getProgramThumbnailUri());
+ values.put(Schedules.COLUMN_STATE, recordingState(r.getState()));
+ values.put(Schedules.COLUMN_TYPE, recordingType(r.getType()));
+ if (r.getSeriesRecordingId() != ID_NOT_SET) {
+ values.put(Schedules.COLUMN_SERIES_RECORDING_ID, r.getSeriesRecordingId());
+ } else {
+ values.putNull(Schedules.COLUMN_SERIES_RECORDING_ID);
+ }
return values;
}
+ public static ScheduledRecording fromParcel(Parcel in) {
+ return new Builder()
+ .setId(in.readLong())
+ .setPriority(in.readLong())
+ .setInputId(in.readString())
+ .setChannelId(in.readLong())
+ .setProgramId(in.readLong())
+ .setProgramTitle(in.readString())
+ .setType(in.readInt())
+ .setStartTimeMs(in.readLong())
+ .setEndTimeMs(in.readLong())
+ .setSeasonNumber(in.readString())
+ .setEpisodeNumber(in.readString())
+ .setEpisodeTitle(in.readString())
+ .setProgramDescription(in.readString())
+ .setProgramLongDescription(in.readString())
+ .setProgramPosterArtUri(in.readString())
+ .setProgramThumbnailUri(in.readString())
+ .setState(in.readInt())
+ .setSeriesRecordingId(in.readLong())
+ .build();
+ }
+
+ public static final Parcelable.Creator<ScheduledRecording> CREATOR =
+ new Parcelable.Creator<ScheduledRecording>() {
+ @Override
+ public ScheduledRecording createFromParcel(Parcel in) {
+ return ScheduledRecording.fromParcel(in);
+ }
+
+ @Override
+ public ScheduledRecording[] newArray(int size) {
+ return new ScheduledRecording[size];
+ }
+ };
+
/**
* The ID internal to Live TV
*/
- private final long mId;
+ private long mId;
/**
* The priority of this recording.
*
- * <p> The lowest number is recorded first. If there is a tie in priority then the lower id
+ * <p> The highest number is recorded first. If there is a tie in priority then the higher id
* wins.
*/
private final long mPriority;
-
+ private final String mInputId;
private final long mChannelId;
/**
* Optional id of the associated program.
- *
*/
private final long mProgramId;
+ private final String mProgramTitle;
private final long mStartTimeMs;
private final long mEndTimeMs;
+ private final String mSeasonNumber;
+ private final String mEpisodeNumber;
+ private final String mEpisodeTitle;
+ private final String mProgramDescription;
+ private final String mProgramLongDescription;
+ private final String mProgramPosterArtUri;
+ private final String mProgramThumbnailUri;
@RecordingState private final int mState;
+ private final long mSeriesRecordingId;
- private final SeasonRecording mParentSeasonRecording;
-
- private ScheduledRecording(long id, long priority, long channelId, long programId,
- @RecordingType int type, long startTime, long endTime,
- @RecordingState int state, SeasonRecording parentSeasonRecording) {
+ private ScheduledRecording(long id, long priority, String inputId, long channelId, long programId,
+ String programTitle, @RecordingType int type, long startTime, long endTime,
+ String seasonNumber, String episodeNumber, String episodeTitle,
+ String programDescription, String programLongDescription, String programPosterArtUri,
+ String programThumbnailUri, @RecordingState int state, long seriesRecordingId) {
mId = id;
mPriority = priority;
+ mInputId = inputId;
mChannelId = channelId;
mProgramId = programId;
+ mProgramTitle = programTitle;
mType = type;
mStartTimeMs = startTime;
mEndTimeMs = endTime;
+ mSeasonNumber = seasonNumber;
+ mEpisodeNumber = episodeNumber;
+ mEpisodeTitle = episodeTitle;
+ mProgramDescription = programDescription;
+ mProgramLongDescription = programLongDescription;
+ mProgramPosterArtUri = programPosterArtUri;
+ mProgramThumbnailUri = programThumbnailUri;
mState = state;
- mParentSeasonRecording = parentSeasonRecording;
+ mSeriesRecordingId = seriesRecordingId;
}
/**
@@ -281,6 +517,13 @@ public final class ScheduledRecording {
}
/**
+ * Returns schedules' input id.
+ */
+ public String getInputId() {
+ return mInputId;
+ }
+
+ /**
* Returns recorded {@link Channel}.
*/
public long getChannelId() {
@@ -295,6 +538,13 @@ public final class ScheduledRecording {
}
/**
+ * Return the optional program Title
+ */
+ public String getProgramTitle() {
+ return mProgramTitle;
+ }
+
+ /**
* Returns started time.
*/
public long getStartTimeMs() {
@@ -309,6 +559,55 @@ public final class ScheduledRecording {
}
/**
+ * Returns the season number.
+ */
+ public String getSeasonNumber() {
+ return mSeasonNumber;
+ }
+
+ /**
+ * Returns the episode number.
+ */
+ public String getEpisodeNumber() {
+ return mEpisodeNumber;
+ }
+
+ /**
+ * Returns the episode title.
+ */
+ public String getEpisodeTitle() {
+ return mEpisodeTitle;
+ }
+
+ /**
+ * Returns the description of program.
+ */
+ public String getProgramDescription() {
+ return mProgramDescription;
+ }
+
+ /**
+ * Returns the long description of program.
+ */
+ public String getProgramLongDescription() {
+ return mProgramLongDescription;
+ }
+
+ /**
+ * Returns the poster uri of program.
+ */
+ public String getProgramPosterArtUri() {
+ return mProgramPosterArtUri;
+ }
+
+ /**
+ * Returns the thumb nail uri of program.
+ */
+ public String getProgramThumbnailUri() {
+ return mProgramThumbnailUri;
+ }
+
+ /**
* Returns duration.
*/
public long getDuration() {
@@ -316,43 +615,83 @@ public final class ScheduledRecording {
}
/**
- * Returns the state. The possible states are {@link #STATE_RECORDING_FINISHED},
- * {@link #STATE_RECORDING_IN_PROGRESS} and {@link #STATE_RECORDING_UNEXPECTEDLY_STOPPED}.
+ * Returns the state. The possible states are {@link #STATE_RECORDING_NOT_STARTED},
+ * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_FINISHED},
+ * {@link #STATE_RECORDING_FAILED}, {@link #STATE_RECORDING_CLIPPED} and
+ * {@link #STATE_RECORDING_DELETED}.
*/
@RecordingState public int getState() {
return mState;
}
/**
- * Returns {@link SeasonRecording} including this schedule.
+ * Returns the ID of the {@link SeriesRecording} including this schedule.
*/
- public SeasonRecording getParentSeasonRecording() {
- return mParentSeasonRecording;
+ public long getSeriesRecordingId() {
+ return mSeriesRecordingId;
}
public long getId() {
return mId;
}
+ /**
+ * Sets the ID;
+ */
+ public void setId(long id) {
+ mId = id;
+ }
+
public long getPriority() {
return mPriority;
}
/**
+ * Returns season number, episode number and episode title for display.
+ */
+ public String getEpisodeDisplayTitle(Context context) {
+ if (!TextUtils.isEmpty(mEpisodeNumber)) {
+ String episodeTitle = mEpisodeTitle == null ? "" : mEpisodeTitle;
+ if (TextUtils.equals(mSeasonNumber, "0")) {
+ // Do not show "S0: ".
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format_no_season_number),
+ mEpisodeNumber, episodeTitle);
+ } else {
+ return String.format(context.getResources().getString(
+ R.string.display_episode_title_format),
+ mSeasonNumber, mEpisodeNumber, episodeTitle);
+ }
+ }
+ return mEpisodeTitle;
+ }
+
+ /**
+ * Returns the program's title withe its season and episode number.
+ */
+ public String getProgramTitleWithEpisodeNumber(Context context) {
+ if (TextUtils.isEmpty(mProgramTitle)) {
+ return mProgramTitle;
+ }
+ if (TextUtils.isEmpty(mSeasonNumber) || mSeasonNumber.equals("0")) {
+ return TextUtils.isEmpty(mEpisodeNumber) ? mProgramTitle : context.getString(
+ R.string.program_title_with_episode_number_no_season, mProgramTitle,
+ mEpisodeNumber);
+ } else {
+ return context.getString(R.string.program_title_with_episode_number, mProgramTitle,
+ mSeasonNumber, mEpisodeNumber);
+ }
+ }
+
+
+ /**
* Converts a string to a @RecordingType int, defaulting to {@link #TYPE_TIMED}.
*/
private static @RecordingType int recordingType(String type) {
- int t;
- try {
- t = Integer.valueOf(type);
- } catch (NullPointerException | NumberFormatException e) {
- SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type);
- return TYPE_TIMED;
- }
- switch (t) {
- case TYPE_TIMED:
+ switch (type) {
+ case Schedules.TYPE_TIMED:
return TYPE_TIMED;
- case TYPE_PROGRAM:
+ case Schedules.TYPE_PROGRAM:
return TYPE_PROGRAM;
default:
SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type);
@@ -361,28 +700,40 @@ public final class ScheduledRecording {
}
/**
+ * Converts a @RecordingType int to a string, defaulting to {@link Schedules#TYPE_TIMED}.
+ */
+ private static String recordingType(@RecordingType int type) {
+ switch (type) {
+ case TYPE_TIMED:
+ return Schedules.TYPE_TIMED;
+ case TYPE_PROGRAM:
+ return Schedules.TYPE_PROGRAM;
+ default:
+ SoftPreconditions.checkArgument(false, TAG, "Unknown recording type " + type);
+ return Schedules.TYPE_TIMED;
+ }
+ }
+
+ /**
* Converts a string to a @RecordingState int, defaulting to
* {@link #STATE_RECORDING_NOT_STARTED}.
*/
private static @RecordingState int recordingState(String state) {
- int s;
- try {
- s = Integer.valueOf(state);
- } catch (NullPointerException | NumberFormatException e) {
- SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state);
- return STATE_RECORDING_NOT_STARTED;
- }
- switch (s) {
- case STATE_RECORDING_NOT_STARTED:
+ switch (state) {
+ case Schedules.STATE_RECORDING_NOT_STARTED:
return STATE_RECORDING_NOT_STARTED;
- case STATE_RECORDING_IN_PROGRESS:
+ case Schedules.STATE_RECORDING_IN_PROGRESS:
return STATE_RECORDING_IN_PROGRESS;
- case STATE_RECORDING_FINISHED:
+ case Schedules.STATE_RECORDING_FINISHED:
return STATE_RECORDING_FINISHED;
- case STATE_RECORDING_UNEXPECTEDLY_STOPPED:
- return STATE_RECORDING_UNEXPECTEDLY_STOPPED;
- case STATE_RECORDING_FAILED:
+ case Schedules.STATE_RECORDING_FAILED:
return STATE_RECORDING_FAILED;
+ case Schedules.STATE_RECORDING_CLIPPED:
+ return STATE_RECORDING_CLIPPED;
+ case Schedules.STATE_RECORDING_DELETED:
+ return STATE_RECORDING_DELETED;
+ case Schedules.STATE_RECORDING_CANCELED:
+ return STATE_RECORDING_CANCELED;
default:
SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state);
return STATE_RECORDING_NOT_STARTED;
@@ -390,20 +741,147 @@ public final class ScheduledRecording {
}
/**
+ * Converts a @RecordingState int to string, defaulting to
+ * {@link Schedules#STATE_RECORDING_NOT_STARTED}.
+ */
+ private static String recordingState(@RecordingState int state) {
+ switch (state) {
+ case STATE_RECORDING_NOT_STARTED:
+ return Schedules.STATE_RECORDING_NOT_STARTED;
+ case STATE_RECORDING_IN_PROGRESS:
+ return Schedules.STATE_RECORDING_IN_PROGRESS;
+ case STATE_RECORDING_FINISHED:
+ return Schedules.STATE_RECORDING_FINISHED;
+ case STATE_RECORDING_FAILED:
+ return Schedules.STATE_RECORDING_FAILED;
+ case STATE_RECORDING_CLIPPED:
+ return Schedules.STATE_RECORDING_CLIPPED;
+ case STATE_RECORDING_DELETED:
+ return Schedules.STATE_RECORDING_DELETED;
+ case STATE_RECORDING_CANCELED:
+ return Schedules.STATE_RECORDING_CANCELED;
+ default:
+ SoftPreconditions.checkArgument(false, TAG, "Unknown recording state" + state);
+ return Schedules.STATE_RECORDING_NOT_STARTED;
+ }
+ }
+
+ /**
* Checks if the {@code period} overlaps with the recording time.
*/
public boolean isOverLapping(Range<Long> period) {
- return mStartTimeMs <= period.getUpper() && mEndTimeMs >= period.getLower();
+ return mStartTimeMs < period.getUpper() && mEndTimeMs > period.getLower();
+ }
+
+ /**
+ * Checks if the {@code schedule} overlaps with this schedule.
+ */
+ public boolean isOverLapping(ScheduledRecording schedule) {
+ return mStartTimeMs < schedule.getEndTimeMs() && mEndTimeMs > schedule.getStartTimeMs();
}
@Override
public String toString() {
return "ScheduledRecording[" + mId
+ "]"
- + "(startTime=" + Utils.toIsoDateTimeString(mStartTimeMs)
- + ",endTime=" + Utils.toIsoDateTimeString(mEndTimeMs)
+ + "(inputId=" + mInputId
+ + ",channelId=" + mChannelId
+ + ",programId=" + mProgramId
+ + ",programTitle=" + mProgramTitle
+ + ",type=" + mType
+ + ",startTime=" + Utils.toIsoDateTimeString(mStartTimeMs) + "(" + mStartTimeMs + ")"
+ + ",endTime=" + Utils.toIsoDateTimeString(mEndTimeMs) + "(" + mEndTimeMs + ")"
+ + ",seasonNumber=" + mSeasonNumber
+ + ",episodeNumber=" + mEpisodeNumber
+ + ",episodeTitle=" + mEpisodeTitle
+ + ",programDescription=" + mProgramDescription
+ + ",programLongDescription=" + mProgramLongDescription
+ + ",programPosterArtUri=" + mProgramPosterArtUri
+ + ",programThumbnailUri=" + mProgramThumbnailUri
+ ",state=" + mState
+ ",priority=" + mPriority
+ + ",seriesRecordingId=" + mSeriesRecordingId
+ ")";
}
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int paramInt) {
+ out.writeLong(mId);
+ out.writeLong(mPriority);
+ out.writeString(mInputId);
+ out.writeLong(mChannelId);
+ out.writeLong(mProgramId);
+ out.writeString(mProgramTitle);
+ out.writeInt(mType);
+ out.writeLong(mStartTimeMs);
+ out.writeLong(mEndTimeMs);
+ out.writeString(mSeasonNumber);
+ out.writeString(mEpisodeNumber);
+ out.writeString(mEpisodeTitle);
+ out.writeString(mProgramDescription);
+ out.writeString(mProgramLongDescription);
+ out.writeString(mProgramPosterArtUri);
+ out.writeString(mProgramThumbnailUri);
+ out.writeInt(mState);
+ out.writeLong(mSeriesRecordingId);
+ }
+
+ /**
+ * Returns {@code true} if the recording is not started yet, otherwise @{code false}.
+ */
+ public boolean isNotStarted() {
+ return mState == STATE_RECORDING_NOT_STARTED;
+ }
+
+ /**
+ * Returns {@code true} if the recording is in progress, otherwise @{code false}.
+ */
+ public boolean isInProgress() {
+ return mState == STATE_RECORDING_IN_PROGRESS;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof ScheduledRecording)) {
+ return false;
+ }
+ ScheduledRecording r = (ScheduledRecording) obj;
+ return mId == r.mId
+ && mPriority == r.mPriority
+ && mChannelId == r.mChannelId
+ && mProgramId == r.mProgramId
+ && Objects.equals(mProgramTitle, r.mProgramTitle)
+ && mType == r.mType
+ && mStartTimeMs == r.mStartTimeMs
+ && mEndTimeMs == r.mEndTimeMs
+ && Objects.equals(mSeasonNumber, r.mSeasonNumber)
+ && Objects.equals(mEpisodeNumber, r.mEpisodeNumber)
+ && Objects.equals(mEpisodeTitle, r.mEpisodeTitle)
+ && Objects.equals(mProgramDescription, r.getProgramDescription())
+ && Objects.equals(mProgramLongDescription, r.getProgramLongDescription())
+ && Objects.equals(mProgramPosterArtUri, r.getProgramPosterArtUri())
+ && Objects.equals(mProgramThumbnailUri, r.getProgramThumbnailUri())
+ && mState == r.mState
+ && mSeriesRecordingId == r.mSeriesRecordingId;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mId, mPriority, mChannelId, mProgramId, mProgramTitle, mType,
+ mStartTimeMs, mEndTimeMs, mSeasonNumber, mEpisodeNumber, mEpisodeTitle,
+ mProgramDescription, mProgramLongDescription, mProgramPosterArtUri,
+ mProgramThumbnailUri, mState, mSeriesRecordingId);
+ }
+
+ /**
+ * Returns an array containing all of the elements in the list.
+ */
+ public static ScheduledRecording[] toArray(Collection<ScheduledRecording> schedules) {
+ return schedules.toArray(new ScheduledRecording[schedules.size()]);
+ }
}
diff --git a/src/com/android/tv/dvr/Scheduler.java b/src/com/android/tv/dvr/Scheduler.java
index ff9bde68..ce78e1be 100644
--- a/src/com/android/tv/dvr/Scheduler.java
+++ b/src/com/android/tv/dvr/Scheduler.java
@@ -20,86 +20,121 @@ import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
-import android.os.Handler;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager.TvInputCallback;
import android.os.Looper;
-import android.os.Message;
+import android.support.annotation.MainThread;
import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
import android.util.Log;
-import android.util.LongSparseArray;
import android.util.Range;
-import com.android.tv.data.Channel;
+import com.android.tv.InputSessionManager;
import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.ChannelDataManager.Listener;
+import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
import com.android.tv.util.Clock;
+import com.android.tv.util.TvInputManagerHelper;
+import com.android.tv.util.Utils;
+import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* The core class to manage schedule and run actual recording.
*/
-@VisibleForTesting
-public class Scheduler implements DvrDataManager.ScheduledRecordingListener {
+@MainThread
+public class Scheduler extends TvInputCallback implements ScheduledRecordingListener {
private static final String TAG = "Scheduler";
private static final boolean DEBUG = false;
private final static long SOON_DURATION_IN_MS = TimeUnit.MINUTES.toMillis(5);
@VisibleForTesting final static long MS_TO_WAKE_BEFORE_START = TimeUnit.MINUTES.toMillis(1);
- /**
- * Wraps a {@link RecordingTask} removing it from {@link #mPendingRecordings} when it is done.
- */
- public final class HandlerWrapper extends Handler {
- public static final int MESSAGE_REMOVE = 999;
- private final long mId;
-
- HandlerWrapper(Looper looper, ScheduledRecording scheduledRecording, RecordingTask recordingTask) {
- super(looper, recordingTask);
- mId = scheduledRecording.getId();
- }
-
- @Override
- public void handleMessage(Message msg) {
- // The RecordingTask gets a chance first.
- // It must return false to pass this message to here.
- if (msg.what == MESSAGE_REMOVE) {
- if (DEBUG) Log.d(TAG, "done " + mId);
- mPendingRecordings.remove(mId);
- }
- removeCallbacksAndMessages(null);
- super.handleMessage(msg);
- }
- }
-
- private final LongSparseArray<HandlerWrapper> mPendingRecordings = new LongSparseArray<>();
private final Looper mLooper;
- private final DvrSessionManager mSessionManager;
+ private final InputSessionManager mSessionManager;
private final WritableDvrDataManager mDataManager;
private final DvrManager mDvrManager;
private final ChannelDataManager mChannelDataManager;
+ private final TvInputManagerHelper mInputManager;
private final Context mContext;
private final Clock mClock;
private final AlarmManager mAlarmManager;
- public Scheduler(Looper looper, DvrManager dvrManager, DvrSessionManager sessionManager,
+ private final Map<String, InputTaskScheduler> mInputSchedulerMap = new ArrayMap<>();
+ private long mLastStartTimePendingMs;
+
+ public Scheduler(Looper looper, DvrManager dvrManager, InputSessionManager sessionManager,
WritableDvrDataManager dataManager, ChannelDataManager channelDataManager,
- Context context, Clock clock,
+ TvInputManagerHelper inputManager, Context context, Clock clock,
AlarmManager alarmManager) {
mLooper = looper;
mDvrManager = dvrManager;
mSessionManager = sessionManager;
mDataManager = dataManager;
mChannelDataManager = channelDataManager;
+ mInputManager = inputManager;
mContext = context;
mClock = clock;
mAlarmManager = alarmManager;
}
+ /**
+ * Starts the scheduler.
+ */
+ public void start() {
+ mDataManager.addScheduledRecordingListener(this);
+ mInputManager.addCallback(this);
+ if (mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished()) {
+ updateInternal();
+ } else {
+ if (!mDataManager.isDvrScheduleLoadFinished()) {
+ mDataManager.addDvrScheduleLoadFinishedListener(
+ new OnDvrScheduleLoadFinishedListener() {
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ mDataManager.removeDvrScheduleLoadFinishedListener(this);
+ updateInternal();
+ }
+ });
+ }
+ if (!mChannelDataManager.isDbLoadFinished()) {
+ mChannelDataManager.addListener(new Listener() {
+ @Override
+ public void onLoadFinished() {
+ mChannelDataManager.removeListener(this);
+ updateInternal();
+ }
+
+ @Override
+ public void onChannelListUpdated() { }
+
+ @Override
+ public void onChannelBrowsableChanged() { }
+ });
+ }
+ }
+ }
+
+ /**
+ * Stops the scheduler.
+ */
+ public void stop() {
+ for (InputTaskScheduler inputTaskScheduler : mInputSchedulerMap.values()) {
+ inputTaskScheduler.stop();
+ }
+ mInputManager.removeCallback(this);
+ mDataManager.removeScheduledRecordingListener(this);
+ }
+
private void updatePendingRecordings() {
- List<ScheduledRecording> scheduledRecordings = mDataManager.getRecordingsThatOverlapWith(
- new Range(mClock.currentTimeMillis(),
- mClock.currentTimeMillis() + SOON_DURATION_IN_MS));
- // TODO(DVR): handle removing and updating exiting recordings.
+ List<ScheduledRecording> scheduledRecordings = mDataManager
+ .getScheduledRecordings(new Range<>(mLastStartTimePendingMs,
+ mClock.currentTimeMillis() + SOON_DURATION_IN_MS),
+ ScheduledRecording.STATE_RECORDING_NOT_STARTED);
for (ScheduledRecording r : scheduledRecordings) {
scheduleRecordingSoon(r);
}
@@ -110,70 +145,139 @@ public class Scheduler implements DvrDataManager.ScheduledRecordingListener {
*/
public void update() {
if (DEBUG) Log.d(TAG, "update");
- updatePendingRecordings();
- updateNextAlarm();
+ updateInternal();
}
- @Override
- public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) {
- if (DEBUG) Log.d(TAG, "added " + scheduledRecording);
- if (startsWithin(scheduledRecording, SOON_DURATION_IN_MS)) {
- scheduleRecordingSoon(scheduledRecording);
- } else {
+ private void updateInternal() {
+ if (isInitialized()) {
+ updatePendingRecordings();
updateNextAlarm();
}
}
+ private boolean isInitialized() {
+ return mDataManager.isDvrScheduleLoadFinished() && mChannelDataManager.isDbLoadFinished();
+ }
+
@Override
- public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) {
- long id = scheduledRecording.getId();
- HandlerWrapper wrapper = mPendingRecordings.get(id);
- if (wrapper != null) {
- wrapper.removeCallbacksAndMessages(null);
- mPendingRecordings.remove(id);
- } else {
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "added " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ handleScheduleChange(schedules);
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "removed " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ boolean needToUpdateAlarm = false;
+ for (ScheduledRecording schedule : schedules) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId());
+ if (scheduler != null) {
+ scheduler.removeSchedule(schedule);
+ needToUpdateAlarm = true;
+ }
+ }
+ if (needToUpdateAlarm) {
updateNextAlarm();
}
}
@Override
- public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) {
- //TODO(DVR): implement
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ if (DEBUG) Log.d(TAG, "state changed " + Arrays.asList(schedules));
+ if (!isInitialized()) {
+ return;
+ }
+ // Update the recordings.
+ for (ScheduledRecording schedule : schedules) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(schedule.getInputId());
+ if (scheduler != null) {
+ scheduler.updateSchedule(schedule);
+ }
+ }
+ handleScheduleChange(schedules);
+ }
+
+ private void handleScheduleChange(ScheduledRecording... schedules) {
+ boolean needToUpdateAlarm = false;
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ if (startsWithin(schedule, SOON_DURATION_IN_MS)) {
+ scheduleRecordingSoon(schedule);
+ } else {
+ needToUpdateAlarm = true;
+ }
+ }
+ }
+ if (needToUpdateAlarm) {
+ updateNextAlarm();
+ }
}
- private void scheduleRecordingSoon(ScheduledRecording scheduledRecording) {
- Channel channel = mChannelDataManager.getChannel(scheduledRecording.getChannelId());
- RecordingTask recordingTask = new RecordingTask(scheduledRecording, channel, mDvrManager,
- mSessionManager, mDataManager, mClock);
- HandlerWrapper handlerWrapper = new HandlerWrapper(mLooper, scheduledRecording,
- recordingTask);
- recordingTask.setHandler(handlerWrapper);
- mPendingRecordings.put(scheduledRecording.getId(), handlerWrapper);
- handlerWrapper.sendEmptyMessage(RecordingTask.MESSAGE_INIT);
+ private void scheduleRecordingSoon(ScheduledRecording schedule) {
+ TvInputInfo input = Utils.getTvInputInfoForInputId(mContext, schedule.getInputId());
+ if (input == null) {
+ Log.e(TAG, "Can't find input for " + schedule);
+ mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED);
+ return;
+ }
+ if (!input.canRecord() || input.getTunerCount() <= 0) {
+ Log.e(TAG, "TV input doesn't support recording: " + input);
+ mDataManager.changeState(schedule, ScheduledRecording.STATE_RECORDING_FAILED);
+ return;
+ }
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId());
+ if (scheduler == null) {
+ scheduler = new InputTaskScheduler(mContext, input, mLooper, mChannelDataManager,
+ mDvrManager, mDataManager, mSessionManager, mClock);
+ mInputSchedulerMap.put(input.getId(), scheduler);
+ }
+ scheduler.addSchedule(schedule);
+ if (mLastStartTimePendingMs < schedule.getStartTimeMs()) {
+ mLastStartTimePendingMs = schedule.getStartTimeMs();
+ }
}
private void updateNextAlarm() {
- long lastStartTimePending = getLastStartTimePending();
- long nextStartTime = mDataManager.getNextScheduledStartTimeAfter(lastStartTimePending);
+ long nextStartTime = mDataManager.getNextScheduledStartTimeAfter(
+ Math.max(mLastStartTimePendingMs, mClock.currentTimeMillis()));
if (nextStartTime != DvrDataManager.NEXT_START_TIME_NOT_FOUND) {
long wakeAt = nextStartTime - MS_TO_WAKE_BEFORE_START;
if (DEBUG) Log.d(TAG, "Set alarm to record at " + wakeAt);
Intent intent = new Intent(mContext, DvrStartRecordingReceiver.class);
PendingIntent alarmIntent = PendingIntent.getBroadcast(mContext, 0, intent, 0);
- //This will cancel the previous alarm.
- mAlarmManager.set(AlarmManager.RTC_WAKEUP, wakeAt, alarmIntent);
+ // This will cancel the previous alarm.
+ mAlarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, wakeAt, alarmIntent);
} else {
if (DEBUG) Log.d(TAG, "No future recording, alarm not set");
}
}
- private long getLastStartTimePending() {
- // TODO(DVR): implement
- return mClock.currentTimeMillis();
- }
-
@VisibleForTesting
boolean startsWithin(ScheduledRecording scheduledRecording, long durationInMs) {
return mClock.currentTimeMillis() >= scheduledRecording.getStartTimeMs() - durationInMs;
}
+
+ // No need to remove input task scheduler when the input is removed. If the input is removed
+ // temporarily, the scheduler should keep the non-started schedules.
+ @Override
+ public void onInputUpdated(String inputId) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(inputId);
+ if (scheduler != null) {
+ scheduler.updateTvInputInfo(Utils.getTvInputInfoForInputId(mContext, inputId));
+ }
+ }
+
+ @Override
+ public void onTvInputInfoUpdated(TvInputInfo input) {
+ InputTaskScheduler scheduler = mInputSchedulerMap.get(input.getId());
+ if (scheduler != null) {
+ scheduler.updateTvInputInfo(input);
+ }
+ }
}
diff --git a/src/com/android/tv/dvr/SeriesInfo.java b/src/com/android/tv/dvr/SeriesInfo.java
new file mode 100644
index 00000000..30256dc5
--- /dev/null
+++ b/src/com/android/tv/dvr/SeriesInfo.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr;
+
+/**
+ * Series information.
+ */
+public class SeriesInfo {
+ private final String mId;
+ private final String mTitle;
+ private final String mDescription;
+ private final String mLongDescription;
+ private final int[] mCanonicalGenreIds;
+ private final String mPosterUri;
+ private final String mPhotoUri;
+
+ public SeriesInfo(String id, String title, String description, String longDescription,
+ int[] canonicalGenreIds, String posterUri, String photoUri) {
+ this.mId = id;
+ this.mTitle = title;
+ this.mDescription = description;
+ this.mLongDescription = longDescription;
+ this.mCanonicalGenreIds = canonicalGenreIds;
+ this.mPosterUri = posterUri;
+ this.mPhotoUri = photoUri;
+ }
+
+ /** Returns the ID. **/
+ public String getId() {
+ return mId;
+ }
+
+ /** Returns the title. **/
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /** Returns the description. **/
+ public String getDescription() {
+ return mDescription;
+ }
+
+ /** Returns the description. **/
+ public String getLongDescription() {
+ return mLongDescription;
+ }
+
+ /** Returns the canonical genre IDs. **/
+ public int[] getCanonicalGenreIds() {
+ return mCanonicalGenreIds;
+ }
+
+ /** Returns the poster URI. **/
+ public String getPosterUri() {
+ return mPosterUri;
+ }
+
+ /** Returns the photo URI. **/
+ public String getPhotoUri() {
+ return mPhotoUri;
+ }
+}
diff --git a/src/com/android/tv/dvr/SeriesRecording.java b/src/com/android/tv/dvr/SeriesRecording.java
new file mode 100644
index 00000000..f0690f5f
--- /dev/null
+++ b/src/com/android/tv/dvr/SeriesRecording.java
@@ -0,0 +1,755 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.IntDef;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+
+import com.android.tv.data.BaseProgram;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.provider.DvrContract.SeriesRecordings;
+import com.android.tv.util.Utils;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Objects;
+
+/**
+ * Schedules the recording of a Series of Programs.
+ *
+ * <p>
+ * Contains the data needed to create new ScheduleRecordings as the programs become available in
+ * the EPG.
+ */
+public class SeriesRecording implements Parcelable {
+ /**
+ * Indicates that the ID is not assigned yet.
+ */
+ public static final long ID_NOT_SET = 0;
+
+ /**
+ * The default priority of this recording.
+ */
+ public static final long DEFAULT_PRIORITY = Long.MAX_VALUE >> 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true,
+ value = {OPTION_CHANNEL_ONE, OPTION_CHANNEL_ALL})
+ public @interface ChannelOption {}
+ /**
+ * An option which indicates that the episodes in one channel are recorded.
+ */
+ public static final int OPTION_CHANNEL_ONE = 0;
+ /**
+ * An option which indicates that the episodes in all the channels are recorded.
+ */
+ public static final int OPTION_CHANNEL_ALL = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true,
+ value = {STATE_SERIES_NORMAL, STATE_SERIES_STOPPED})
+ public @interface SeriesState {}
+
+ /**
+ * The state indicates that the series recording is a normal one.
+ */
+ public static final int STATE_SERIES_NORMAL = 0;
+
+ /**
+ * The state indicates that the series recording is stopped.
+ */
+ public static final int STATE_SERIES_STOPPED = 1;
+
+ /**
+ * Compare priority in descending order.
+ */
+ public static final Comparator<SeriesRecording> PRIORITY_COMPARATOR =
+ new Comparator<SeriesRecording>() {
+ @Override
+ public int compare(SeriesRecording lhs, SeriesRecording rhs) {
+ int value = Long.compare(rhs.mPriority, lhs.mPriority);
+ if (value == 0) {
+ // New recording has the higher priority.
+ value = Long.compare(rhs.mId, lhs.mId);
+ }
+ return value;
+ }
+ };
+
+ /**
+ * Compare ID in ascending order.
+ */
+ public static final Comparator<SeriesRecording> ID_COMPARATOR =
+ new Comparator<SeriesRecording>() {
+ @Override
+ public int compare(SeriesRecording lhs, SeriesRecording rhs) {
+ return Long.compare(lhs.mId, rhs.mId);
+ }
+ };
+
+ /**
+ * Creates a new Builder with the values set from the series information of {@link BaseProgram}.
+ */
+ public static Builder builder(String inputId, BaseProgram p) {
+ return new Builder()
+ .setInputId(inputId)
+ .setSeriesId(p.getSeriesId())
+ .setChannelId(p.getChannelId())
+ .setTitle(p.getTitle())
+ .setDescription(p.getDescription())
+ .setLongDescription(p.getLongDescription())
+ .setCanonicalGenreIds(p.getCanonicalGenreIds())
+ .setPosterUri(p.getPosterArtUri())
+ .setPhotoUri(p.getThumbnailUri());
+ }
+
+ /**
+ * Creates a new Builder with the values set from an existing {@link SeriesRecording}.
+ */
+ @VisibleForTesting
+ public static Builder buildFrom(SeriesRecording r) {
+ return new Builder()
+ .setId(r.mId)
+ .setInputId(r.getInputId())
+ .setChannelId(r.getChannelId())
+ .setPriority(r.getPriority())
+ .setTitle(r.getTitle())
+ .setDescription(r.getDescription())
+ .setLongDescription(r.getLongDescription())
+ .setSeriesId(r.getSeriesId())
+ .setStartFromEpisode(r.getStartFromEpisode())
+ .setStartFromSeason(r.getStartFromSeason())
+ .setChannelOption(r.getChannelOption())
+ .setCanonicalGenreIds(r.getCanonicalGenreIds())
+ .setPosterUri(r.getPosterUri())
+ .setPhotoUri(r.getPhotoUri())
+ .setState(r.getState());
+ }
+
+ /**
+ * Use this projection if you want to create {@link SeriesRecording} object using
+ * {@link #fromCursor}.
+ */
+ public static final String[] PROJECTION = {
+ // Columns must match what is read in fromCursor()
+ SeriesRecordings._ID,
+ SeriesRecordings.COLUMN_INPUT_ID,
+ SeriesRecordings.COLUMN_CHANNEL_ID,
+ SeriesRecordings.COLUMN_PRIORITY,
+ SeriesRecordings.COLUMN_TITLE,
+ SeriesRecordings.COLUMN_SHORT_DESCRIPTION,
+ SeriesRecordings.COLUMN_LONG_DESCRIPTION,
+ SeriesRecordings.COLUMN_SERIES_ID,
+ SeriesRecordings.COLUMN_START_FROM_EPISODE,
+ SeriesRecordings.COLUMN_START_FROM_SEASON,
+ SeriesRecordings.COLUMN_CHANNEL_OPTION,
+ SeriesRecordings.COLUMN_CANONICAL_GENRE,
+ SeriesRecordings.COLUMN_POSTER_URI,
+ SeriesRecordings.COLUMN_PHOTO_URI,
+ SeriesRecordings.COLUMN_STATE
+ };
+ /**
+ * Creates {@link SeriesRecording} object from the given {@link Cursor}.
+ */
+ public static SeriesRecording fromCursor(Cursor c) {
+ int index = -1;
+ return new Builder()
+ .setId(c.getLong(++index))
+ .setInputId(c.getString(++index))
+ .setChannelId(c.getLong(++index))
+ .setPriority(c.getLong(++index))
+ .setTitle(c.getString(++index))
+ .setDescription(c.getString(++index))
+ .setLongDescription(c.getString(++index))
+ .setSeriesId(c.getString(++index))
+ .setStartFromEpisode(c.getInt(++index))
+ .setStartFromSeason(c.getInt(++index))
+ .setChannelOption(channelOption(c.getString(++index)))
+ .setCanonicalGenreIds(c.getString(++index))
+ .setPosterUri(c.getString(++index))
+ .setPhotoUri(c.getString(++index))
+ .setState(seriesRecordingState(c.getString(++index)))
+ .build();
+ }
+
+ /**
+ * Returns the ContentValues with keys as the columns specified in {@link SeriesRecordings}
+ * and the values from {@code r}.
+ */
+ public static ContentValues toContentValues(SeriesRecording r) {
+ ContentValues values = new ContentValues();
+ if (r.getId() != ID_NOT_SET) {
+ values.put(SeriesRecordings._ID, r.getId());
+ } else {
+ values.putNull(SeriesRecordings._ID);
+ }
+ values.put(SeriesRecordings.COLUMN_INPUT_ID, r.getInputId());
+ values.put(SeriesRecordings.COLUMN_CHANNEL_ID, r.getChannelId());
+ values.put(SeriesRecordings.COLUMN_PRIORITY, r.getPriority());
+ values.put(SeriesRecordings.COLUMN_TITLE, r.getTitle());
+ values.put(SeriesRecordings.COLUMN_SHORT_DESCRIPTION, r.getDescription());
+ values.put(SeriesRecordings.COLUMN_LONG_DESCRIPTION, r.getLongDescription());
+ values.put(SeriesRecordings.COLUMN_SERIES_ID, r.getSeriesId());
+ values.put(SeriesRecordings.COLUMN_START_FROM_EPISODE, r.getStartFromEpisode());
+ values.put(SeriesRecordings.COLUMN_START_FROM_SEASON, r.getStartFromSeason());
+ values.put(SeriesRecordings.COLUMN_CHANNEL_OPTION,
+ channelOption(r.getChannelOption()));
+ values.put(SeriesRecordings.COLUMN_CANONICAL_GENRE,
+ Utils.getCanonicalGenre(r.getCanonicalGenreIds()));
+ values.put(SeriesRecordings.COLUMN_POSTER_URI, r.getPosterUri());
+ values.put(SeriesRecordings.COLUMN_PHOTO_URI, r.getPhotoUri());
+ values.put(SeriesRecordings.COLUMN_STATE, seriesRecordingState(r.getState()));
+ return values;
+ }
+
+ private static String channelOption(@ChannelOption int option) {
+ switch (option) {
+ case OPTION_CHANNEL_ONE:
+ return SeriesRecordings.OPTION_CHANNEL_ONE;
+ case OPTION_CHANNEL_ALL:
+ return SeriesRecordings.OPTION_CHANNEL_ALL;
+ }
+ return SeriesRecordings.OPTION_CHANNEL_ONE;
+ }
+
+ @ChannelOption private static int channelOption(String option) {
+ switch (option) {
+ case SeriesRecordings.OPTION_CHANNEL_ONE:
+ return OPTION_CHANNEL_ONE;
+ case SeriesRecordings.OPTION_CHANNEL_ALL:
+ return OPTION_CHANNEL_ALL;
+ }
+ return OPTION_CHANNEL_ONE;
+ }
+
+ private static String seriesRecordingState(@SeriesState int state) {
+ switch (state) {
+ case STATE_SERIES_NORMAL:
+ return SeriesRecordings.STATE_SERIES_NORMAL;
+ case STATE_SERIES_STOPPED:
+ return SeriesRecordings.STATE_SERIES_STOPPED;
+ }
+ return SeriesRecordings.STATE_SERIES_NORMAL;
+ }
+
+ @SeriesState private static int seriesRecordingState(String state) {
+ switch (state) {
+ case SeriesRecordings.STATE_SERIES_NORMAL:
+ return STATE_SERIES_NORMAL;
+ case SeriesRecordings.STATE_SERIES_STOPPED:
+ return STATE_SERIES_STOPPED;
+ }
+ return STATE_SERIES_NORMAL;
+ }
+
+ /**
+ * Builder for {@link SeriesRecording}.
+ */
+ public static class Builder {
+ private long mId = ID_NOT_SET;
+ private long mPriority = DvrScheduleManager.DEFAULT_SERIES_PRIORITY;
+ private String mTitle;
+ private String mDescription;
+ private String mLongDescription;
+ private String mInputId;
+ private long mChannelId;
+ private String mSeriesId;
+ private int mStartFromSeason = SeriesRecordings.THE_BEGINNING;
+ private int mStartFromEpisode = SeriesRecordings.THE_BEGINNING;
+ private int mChannelOption = OPTION_CHANNEL_ONE;
+ private int[] mCanonicalGenreIds;
+ private String mPosterUri;
+ private String mPhotoUri;
+ private int mState = SeriesRecording.STATE_SERIES_NORMAL;
+
+ /**
+ * @see #getId()
+ */
+ public Builder setId(long id) {
+ mId = id;
+ return this;
+ }
+
+ /**
+ * @see #getPriority() ()
+ */
+ public Builder setPriority(long priority) {
+ mPriority = priority;
+ return this;
+ }
+
+ /**
+ * @see #getTitle()
+ */
+ public Builder setTitle(String title) {
+ mTitle = title;
+ return this;
+ }
+
+ /**
+ * @see #getDescription()
+ */
+ public Builder setDescription(String description) {
+ mDescription = description;
+ return this;
+ }
+
+ /**
+ * @see #getLongDescription()
+ */
+ public Builder setLongDescription(String longDescription) {
+ mLongDescription = longDescription;
+ return this;
+ }
+
+ /**
+ * @see #getInputId()
+ */
+ public Builder setInputId(String inputId) {
+ mInputId = inputId;
+ return this;
+ }
+
+ /**
+ * @see #getChannelId()
+ */
+ public Builder setChannelId(long channelId) {
+ mChannelId = channelId;
+ return this;
+ }
+
+ /**
+ * @see #getSeriesId()
+ */
+ public Builder setSeriesId(String seriesId) {
+ mSeriesId = seriesId;
+ return this;
+ }
+
+ /**
+ * @see #getStartFromSeason()
+ */
+ public Builder setStartFromSeason(int startFromSeason) {
+ mStartFromSeason = startFromSeason;
+ return this;
+ }
+
+ /**
+ * @see #getChannelOption()
+ */
+ public Builder setChannelOption(@ChannelOption int option) {
+ mChannelOption = option;
+ return this;
+ }
+
+ /**
+ * @see #getStartFromEpisode()
+ */
+ public Builder setStartFromEpisode(int startFromEpisode) {
+ mStartFromEpisode = startFromEpisode;
+ return this;
+ }
+
+ /**
+ * @see #getCanonicalGenreIds()
+ */
+ public Builder setCanonicalGenreIds(String genres) {
+ mCanonicalGenreIds = Utils.getCanonicalGenreIds(genres);
+ return this;
+ }
+
+ /**
+ * @see #getCanonicalGenreIds()
+ */
+ public Builder setCanonicalGenreIds(int[] canonicalGenreIds) {
+ mCanonicalGenreIds = canonicalGenreIds;
+ return this;
+ }
+
+ /**
+ * @see #getPosterUri()
+ */
+ public Builder setPosterUri(String posterUri) {
+ mPosterUri = posterUri;
+ return this;
+ }
+
+ /**
+ * @see #getPhotoUri()
+ */
+ public Builder setPhotoUri(String photoUri) {
+ mPhotoUri = photoUri;
+ return this;
+ }
+
+ /**
+ * @see #getState()
+ */
+ public Builder setState(@SeriesState int state) {
+ mState = state;
+ return this;
+ }
+
+ /**
+ * Creates a new {@link SeriesRecording}.
+ */
+ public SeriesRecording build() {
+ return new SeriesRecording(mId, mPriority, mTitle, mDescription, mLongDescription,
+ mInputId, mChannelId, mSeriesId, mStartFromSeason, mStartFromEpisode,
+ mChannelOption, mCanonicalGenreIds, mPosterUri, mPhotoUri, mState);
+ }
+ }
+
+ public static SeriesRecording fromParcel(Parcel in) {
+ return new Builder()
+ .setId(in.readLong())
+ .setPriority(in.readLong())
+ .setTitle(in.readString())
+ .setDescription(in.readString())
+ .setLongDescription(in.readString())
+ .setInputId(in.readString())
+ .setChannelId(in.readLong())
+ .setSeriesId(in.readString())
+ .setStartFromSeason(in.readInt())
+ .setStartFromEpisode(in.readInt())
+ .setChannelOption(in.readInt())
+ .setCanonicalGenreIds(in.createIntArray())
+ .setPosterUri(in.readString())
+ .setPhotoUri(in.readString())
+ .setState(in.readInt())
+ .build();
+ }
+
+ public static final Parcelable.Creator<SeriesRecording> CREATOR =
+ new Parcelable.Creator<SeriesRecording>() {
+ @Override
+ public SeriesRecording createFromParcel(Parcel in) {
+ return SeriesRecording.fromParcel(in);
+ }
+
+ @Override
+ public SeriesRecording[] newArray(int size) {
+ return new SeriesRecording[size];
+ }
+ };
+
+ private long mId;
+ private final long mPriority;
+ private final String mTitle;
+ private final String mDescription;
+ private final String mLongDescription;
+ private final String mInputId;
+ private final long mChannelId;
+ private final String mSeriesId;
+ private final int mStartFromSeason;
+ private final int mStartFromEpisode;
+ @ChannelOption private final int mChannelOption;
+ private final int[] mCanonicalGenreIds;
+ private final String mPosterUri;
+ private final String mPhotoUri;
+ @SeriesState private int mState;
+
+ /**
+ * The input id of this SeriesRecording.
+ */
+ public String getInputId() {
+ return mInputId;
+ }
+
+ /**
+ * The channelId to match. The channel ID might not be valid when the channel option is "ALL".
+ */
+ public long getChannelId() {
+ return mChannelId;
+ }
+
+ /**
+ * The id of this SeriesRecording.
+ */
+ public long getId() {
+ return mId;
+ }
+
+ /**
+ * Sets the ID.
+ */
+ public void setId(long id) {
+ mId = id;
+ }
+
+ /**
+ * The priority of this recording.
+ *
+ * <p> The highest number is recorded first. If there is a tie in mPriority then the higher mId
+ * wins.
+ */
+ public long getPriority() {
+ return mPriority;
+ }
+
+ /**
+ * The series title.
+ */
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /**
+ * The series description.
+ */
+ public String getDescription() {
+ return mDescription;
+ }
+
+ /**
+ * The long series description.
+ */
+ public String getLongDescription() {
+ return mLongDescription;
+ }
+
+ /**
+ * SeriesId when not null is used to match programs instead of using title and channelId.
+ *
+ * <p>SeriesId is an opaque but stable string.
+ */
+ public String getSeriesId() {
+ return mSeriesId;
+ }
+
+ /**
+ * If not == {@link SeriesRecordings#THE_BEGINNING} and seasonNumber == startFromSeason then
+ * only record episodes with a episodeNumber >= this
+ */
+ public int getStartFromEpisode() {
+ return mStartFromEpisode;
+ }
+
+ /**
+ * If not == {@link SeriesRecordings#THE_BEGINNING} then only record episodes with a
+ * seasonNumber >= this
+ */
+ public int getStartFromSeason() {
+ return mStartFromSeason;
+ }
+
+ /**
+ * Returns the channel recording option.
+ */
+ @ChannelOption public int getChannelOption() {
+ return mChannelOption;
+ }
+
+ /**
+ * Returns the canonical genre ID's.
+ */
+ public int[] getCanonicalGenreIds() {
+ return mCanonicalGenreIds;
+ }
+
+ /**
+ * Returns the poster URI.
+ */
+ public String getPosterUri() {
+ return mPosterUri;
+ }
+
+ /**
+ * Returns the photo URI.
+ */
+ public String getPhotoUri() {
+ return mPhotoUri;
+ }
+
+ /**
+ * Returns the state of series recording.
+ */
+ @SeriesState public int getState() {
+ return mState;
+ }
+
+ /**
+ * Checks whether the series recording is stopped or not.
+ */
+ public boolean isStopped() {
+ return mState == STATE_SERIES_STOPPED;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SeriesRecording)) return false;
+ SeriesRecording that = (SeriesRecording) o;
+ return mPriority == that.mPriority
+ && mChannelId == that.mChannelId
+ && mStartFromSeason == that.mStartFromSeason
+ && mStartFromEpisode == that.mStartFromEpisode
+ && Objects.equals(mId, that.mId)
+ && Objects.equals(mTitle, that.mTitle)
+ && Objects.equals(mDescription, that.mDescription)
+ && Objects.equals(mLongDescription, that.mLongDescription)
+ && Objects.equals(mSeriesId, that.mSeriesId)
+ && mChannelOption == that.mChannelOption
+ && Arrays.equals(mCanonicalGenreIds, that.mCanonicalGenreIds)
+ && Objects.equals(mPosterUri, that.mPosterUri)
+ && Objects.equals(mPhotoUri, that.mPhotoUri)
+ && mState == that.mState;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mPriority, mChannelId, mStartFromSeason, mStartFromEpisode, mId,
+ mTitle, mDescription, mLongDescription, mSeriesId, mChannelOption,
+ mCanonicalGenreIds, mPosterUri, mPhotoUri, mState);
+ }
+
+ @Override
+ public String toString() {
+ return "SeriesRecording{" +
+ "inputId=" + mInputId +
+ ", channelId=" + mChannelId +
+ ", id='" + mId + '\'' +
+ ", priority=" + mPriority +
+ ", title='" + mTitle + '\'' +
+ ", description='" + mDescription + '\'' +
+ ", longDescription='" + mLongDescription + '\'' +
+ ", startFromSeason=" + mStartFromSeason +
+ ", startFromEpisode=" + mStartFromEpisode +
+ ", channelOption=" + mChannelOption +
+ ", canonicalGenreIds=" + Arrays.toString(mCanonicalGenreIds) +
+ ", posterUri=" + mPosterUri +
+ ", photoUri=" + mPhotoUri +
+ ", state=" + mState +
+ '}';
+ }
+
+ private SeriesRecording(long id, long priority, String title, String description,
+ String longDescription, String inputId, long channelId, String seriesId,
+ int startFromSeason, int startFromEpisode, int channelOption, int[] canonicalGenreIds,
+ String posterUri, String photoUri, int state) {
+ this.mId = id;
+ this.mPriority = priority;
+ this.mTitle = title;
+ this.mDescription = description;
+ this.mLongDescription = longDescription;
+ this.mInputId = inputId;
+ this.mChannelId = channelId;
+ this.mSeriesId = seriesId;
+ this.mStartFromSeason = startFromSeason;
+ this.mStartFromEpisode = startFromEpisode;
+ this.mChannelOption = channelOption;
+ this.mCanonicalGenreIds = canonicalGenreIds;
+ this.mPosterUri = posterUri;
+ this.mPhotoUri = photoUri;
+ this.mState = state;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int paramInt) {
+ out.writeLong(mId);
+ out.writeLong(mPriority);
+ out.writeString(mTitle);
+ out.writeString(mDescription);
+ out.writeString(mLongDescription);
+ out.writeString(mInputId);
+ out.writeLong(mChannelId);
+ out.writeString(mSeriesId);
+ out.writeInt(mStartFromSeason);
+ out.writeInt(mStartFromEpisode);
+ out.writeInt(mChannelOption);
+ out.writeIntArray(mCanonicalGenreIds);
+ out.writeString(mPosterUri);
+ out.writeString(mPhotoUri);
+ out.writeInt(mState);
+ }
+
+ /**
+ * Returns an array containing all of the elements in the list.
+ */
+ public static SeriesRecording[] toArray(Collection<SeriesRecording> series) {
+ return series.toArray(new SeriesRecording[series.size()]);
+ }
+
+ /**
+ * Returns {@code true} if the {@code program} is part of the series and meets the season and
+ * episode constraints.
+ */
+ public boolean matchProgram(Program program) {
+ return matchProgram(program, mChannelOption);
+ }
+
+ /**
+ * Returns {@code true} if the {@code program} is part of the series and meets the season and
+ * episode constraints. It checks the channel option only if {@code checkChannelOption} is
+ * {@code true}.
+ */
+ public boolean matchProgram(Program program, @ChannelOption int channelOption) {
+ String seriesId = program.getSeriesId();
+ long channelId = program.getChannelId();
+ String seasonNumber = program.getSeasonNumber();
+ String episodeNumber = program.getEpisodeNumber();
+ if (!mSeriesId.equals(seriesId) || (channelOption == SeriesRecording.OPTION_CHANNEL_ONE
+ && mChannelId != channelId)) {
+ return false;
+ }
+ // Season number and episode number matches if
+ // start_season_number < program_season_number
+ // || (start_season_number == program_season_number
+ // && start_episode_number <= program_episode_number).
+ if (mStartFromSeason == SeriesRecordings.THE_BEGINNING
+ || TextUtils.isEmpty(seasonNumber)) {
+ return true;
+ } else {
+ int intSeasonNumber;
+ try {
+ intSeasonNumber = Integer.valueOf(seasonNumber);
+ } catch (NumberFormatException e) {
+ return true;
+ }
+ if (intSeasonNumber > mStartFromSeason) {
+ return true;
+ } else if (intSeasonNumber < mStartFromSeason) {
+ return false;
+ }
+ }
+ if (mStartFromEpisode == SeriesRecordings.THE_BEGINNING
+ || TextUtils.isEmpty(episodeNumber)) {
+ return true;
+ } else {
+ int intEpisodeNumber;
+ try {
+ intEpisodeNumber = Integer.valueOf(episodeNumber);
+ } catch (NumberFormatException e) {
+ return true;
+ }
+ return intEpisodeNumber >= mStartFromEpisode;
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/SeriesRecordingScheduler.java b/src/com/android/tv/dvr/SeriesRecordingScheduler.java
new file mode 100644
index 00000000..5ed12ce8
--- /dev/null
+++ b/src/com/android/tv/dvr/SeriesRecordingScheduler.java
@@ -0,0 +1,579 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.support.annotation.MainThread;
+import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.LongSparseArray;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.TvApplication;
+import com.android.tv.common.CollectionUtils;
+import com.android.tv.common.SharedPreferencesUtils;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
+import com.android.tv.dvr.EpisodicProgramLoadTask.ScheduledEpisode;
+import com.android.tv.experiments.Experiments;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.Set;
+
+/**
+ * Creates the {@link ScheduledRecording}s for the {@link SeriesRecording}.
+ * <p>
+ * The current implementation assumes that the series recordings are scheduled only for one channel.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class SeriesRecordingScheduler {
+ private static final String TAG = "SeriesRecordingSchd";
+ private static final boolean DEBUG = false;
+
+ private static final String KEY_FETCHED_SERIES_IDS =
+ "SeriesRecordingScheduler.fetched_series_ids";
+
+ @SuppressLint("StaticFieldLeak")
+ private static SeriesRecordingScheduler sInstance;
+
+ /**
+ * Creates and returns the {@link SeriesRecordingScheduler}.
+ */
+ public static synchronized SeriesRecordingScheduler getInstance(Context context) {
+ if (sInstance == null) {
+ sInstance = new SeriesRecordingScheduler(context);
+ }
+ return sInstance;
+ }
+
+ private final Context mContext;
+ private final DvrManager mDvrManager;
+ private final WritableDvrDataManager mDataManager;
+ private final List<SeriesRecordingUpdateTask> mScheduleTasks = new ArrayList<>();
+ private final List<FetchSeriesInfoTask> mFetchSeriesInfoTasks = new ArrayList<>();
+ private final Set<String> mFetchedSeriesIds = new ArraySet<>();
+ private final SharedPreferences mSharedPreferences;
+ private boolean mStarted;
+ private boolean mPaused;
+ private final Set<Long> mPendingSeriesRecordings = new ArraySet<>();
+ private final Set<OnSeriesRecordingUpdatedListener> mOnSeriesRecordingUpdatedListeners =
+ new CopyOnWriteArraySet<>();
+
+
+ private final SeriesRecordingListener mSeriesRecordingListener = new SeriesRecordingListener() {
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ executeFetchSeriesInfoTask(seriesRecording);
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ // Cancel the update.
+ for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
+ iter.hasNext(); ) {
+ SeriesRecordingUpdateTask task = iter.next();
+ if (CollectionUtils.subtract(task.getSeriesRecordings(), seriesRecordings,
+ SeriesRecording.ID_COMPARATOR).isEmpty()) {
+ task.cancel(true);
+ iter.remove();
+ }
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ List<SeriesRecording> stopped = new ArrayList<>();
+ List<SeriesRecording> normal = new ArrayList<>();
+ for (SeriesRecording r : seriesRecordings) {
+ if (r.isStopped()) {
+ stopped.add(r);
+ } else {
+ normal.add(r);
+ }
+ }
+ if (!stopped.isEmpty()) {
+ onSeriesRecordingRemoved(SeriesRecording.toArray(stopped));
+ }
+ if (!normal.isEmpty()) {
+ updateSchedules(normal);
+ }
+ }
+ };
+
+ private final ScheduledRecordingListener mScheduledRecordingListener =
+ new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) {
+ // No need to update series recordings when the new schedule is added.
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ handleScheduledRecordingChange(Arrays.asList(schedules));
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ List<ScheduledRecording> schedulesForUpdate = new ArrayList<>();
+ for (ScheduledRecording r : schedules) {
+ if ((r.getState() == ScheduledRecording.STATE_RECORDING_FAILED
+ || r.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED)
+ && r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET
+ && !TextUtils.isEmpty(r.getSeasonNumber())
+ && !TextUtils.isEmpty(r.getEpisodeNumber())) {
+ schedulesForUpdate.add(r);
+ }
+ }
+ if (!schedulesForUpdate.isEmpty()) {
+ handleScheduledRecordingChange(schedulesForUpdate);
+ }
+ }
+
+ private void handleScheduledRecordingChange(List<ScheduledRecording> schedules) {
+ if (schedules.isEmpty()) {
+ return;
+ }
+ Set<Long> seriesRecordingIds = new HashSet<>();
+ for (ScheduledRecording r : schedules) {
+ if (r.getSeriesRecordingId() != SeriesRecording.ID_NOT_SET) {
+ seriesRecordingIds.add(r.getSeriesRecordingId());
+ }
+ }
+ if (!seriesRecordingIds.isEmpty()) {
+ List<SeriesRecording> seriesRecordings = new ArrayList<>();
+ for (Long id : seriesRecordingIds) {
+ SeriesRecording seriesRecording = mDataManager.getSeriesRecording(id);
+ if (seriesRecording != null) {
+ seriesRecordings.add(seriesRecording);
+ }
+ }
+ if (!seriesRecordings.isEmpty()) {
+ updateSchedules(seriesRecordings);
+ }
+ }
+ }
+ };
+
+ private SeriesRecordingScheduler(Context context) {
+ mContext = context.getApplicationContext();
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ mDvrManager = appSingletons.getDvrManager();
+ mDataManager = (WritableDvrDataManager) appSingletons.getDvrDataManager();
+ mSharedPreferences = context.getSharedPreferences(
+ SharedPreferencesUtils.SHARED_PREF_SERIES_RECORDINGS, Context.MODE_PRIVATE);
+ mFetchedSeriesIds.addAll(mSharedPreferences.getStringSet(KEY_FETCHED_SERIES_IDS,
+ Collections.emptySet()));
+ }
+
+ /**
+ * Starts the scheduler.
+ */
+ @MainThread
+ public void start() {
+ SoftPreconditions.checkState(mDataManager.isInitialized());
+ if (mStarted) {
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "start");
+ mStarted = true;
+ mDataManager.addSeriesRecordingListener(mSeriesRecordingListener);
+ mDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ startFetchingSeriesInfo();
+ updateSchedules(mDataManager.getSeriesRecordings());
+ }
+
+ @MainThread
+ public void stop() {
+ if (!mStarted) {
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "stop");
+ mStarted = false;
+ for (FetchSeriesInfoTask task : mFetchSeriesInfoTasks) {
+ task.cancel(true);
+ }
+ mFetchSeriesInfoTasks.clear();
+ for (SeriesRecordingUpdateTask task : mScheduleTasks) {
+ task.cancel(true);
+ }
+ mScheduleTasks.clear();
+ mDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ mDataManager.removeSeriesRecordingListener(mSeriesRecordingListener);
+ }
+
+ private void startFetchingSeriesInfo() {
+ for (SeriesRecording seriesRecording : mDataManager.getSeriesRecordings()) {
+ if (!mFetchedSeriesIds.contains(seriesRecording.getSeriesId())) {
+ executeFetchSeriesInfoTask(seriesRecording);
+ }
+ }
+ }
+
+ private void executeFetchSeriesInfoTask(SeriesRecording seriesRecording) {
+ if (Experiments.CLOUD_EPG.get()) {
+ FetchSeriesInfoTask task = new FetchSeriesInfoTask(seriesRecording);
+ task.execute();
+ mFetchSeriesInfoTasks.add(task);
+ }
+ }
+
+ /**
+ * Pauses the updates of the series recordings.
+ */
+ public void pauseUpdate() {
+ if (DEBUG) Log.d(TAG, "Schedule paused");
+ if (mPaused) {
+ return;
+ }
+ mPaused = true;
+ if (!mStarted) {
+ return;
+ }
+ for (SeriesRecordingUpdateTask task : mScheduleTasks) {
+ for (SeriesRecording r : task.getSeriesRecordings()) {
+ mPendingSeriesRecordings.add(r.getId());
+ }
+ task.cancel(true);
+ }
+ }
+
+ /**
+ * Resumes the updates of the series recordings.
+ */
+ public void resumeUpdate() {
+ if (DEBUG) Log.d(TAG, "Schedule resumed");
+ if (!mPaused) {
+ return;
+ }
+ mPaused = false;
+ if (!mStarted) {
+ return;
+ }
+ if (!mPendingSeriesRecordings.isEmpty()) {
+ List<SeriesRecording> seriesRecordings = new ArrayList<>();
+ for (long seriesRecordingId : mPendingSeriesRecordings) {
+ SeriesRecording seriesRecording =
+ mDataManager.getSeriesRecording(seriesRecordingId);
+ if (seriesRecording != null) {
+ seriesRecordings.add(seriesRecording);
+ }
+ }
+ if (!seriesRecordings.isEmpty()) {
+ updateSchedules(seriesRecordings);
+ }
+ }
+ }
+
+ /**
+ * Update schedules for the given series recordings. If it's paused, the update will be done
+ * after it's resumed.
+ */
+ public void updateSchedules(Collection<SeriesRecording> seriesRecordings) {
+ if (DEBUG) Log.d(TAG, "updateSchedules:" + seriesRecordings);
+ if (!mStarted) {
+ if (DEBUG) Log.d(TAG, "Not started yet.");
+ return;
+ }
+ if (mPaused) {
+ for (SeriesRecording r : seriesRecordings) {
+ mPendingSeriesRecordings.add(r.getId());
+ }
+ if (DEBUG) {
+ Log.d(TAG, "The scheduler has been paused. Adding to the pending list. size="
+ + mPendingSeriesRecordings.size());
+ }
+ return;
+ }
+ Set<SeriesRecording> previousSeriesRecordings = new HashSet<>();
+ for (Iterator<SeriesRecordingUpdateTask> iter = mScheduleTasks.iterator();
+ iter.hasNext(); ) {
+ SeriesRecordingUpdateTask task = iter.next();
+ if (CollectionUtils.containsAny(task.getSeriesRecordings(), seriesRecordings,
+ SeriesRecording.ID_COMPARATOR)) {
+ // The task is affected by the seriesRecordings
+ task.cancel(true);
+ previousSeriesRecordings.addAll(task.getSeriesRecordings());
+ iter.remove();
+ }
+ }
+ List<SeriesRecording> seriesRecordingsToUpdate = CollectionUtils.union(seriesRecordings,
+ previousSeriesRecordings, SeriesRecording.ID_COMPARATOR);
+ for (Iterator<SeriesRecording> iter = seriesRecordingsToUpdate.iterator();
+ iter.hasNext(); ) {
+ SeriesRecording seriesRecording = mDataManager.getSeriesRecording(iter.next().getId());
+ if (seriesRecording == null || seriesRecording.isStopped()) {
+ // Series recording has been removed or stopped.
+ iter.remove();
+ }
+ }
+ if (seriesRecordingsToUpdate.isEmpty()) {
+ return;
+ }
+ if (needToReadAllChannels(seriesRecordingsToUpdate)) {
+ SeriesRecordingUpdateTask task =
+ new SeriesRecordingUpdateTask(seriesRecordingsToUpdate);
+ mScheduleTasks.add(task);
+ if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
+ task.execute();
+ } else {
+ for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
+ SeriesRecordingUpdateTask task = new SeriesRecordingUpdateTask(
+ Collections.singletonList(seriesRecording));
+ mScheduleTasks.add(task);
+ if (DEBUG) Log.d(TAG, "Added schedule task: " + task);
+ task.execute();
+ }
+ }
+ }
+
+ /**
+ * Adds {@link OnSeriesRecordingUpdatedListener}.
+ */
+ public void addOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) {
+ mOnSeriesRecordingUpdatedListeners.add(listener);
+ }
+
+ /**
+ * Removes {@link OnSeriesRecordingUpdatedListener}.
+ */
+ public void removeOnSeriesRecordingUpdatedListener(OnSeriesRecordingUpdatedListener listener) {
+ mOnSeriesRecordingUpdatedListeners.remove(listener);
+ }
+
+ private boolean needToReadAllChannels(List<SeriesRecording> seriesRecordingsToUpdate) {
+ for (SeriesRecording seriesRecording : seriesRecordingsToUpdate) {
+ if (seriesRecording.getChannelOption() == SeriesRecording.OPTION_CHANNEL_ALL) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Pick one program per an episode.
+ *
+ * <p>Note that the programs which has been already scheduled have the highest priority, and all
+ * of them are added even though they are the same episodes. That's because the schedules
+ * should be added to the series recording.
+ * <p>If there are no existing schedules for an episode, one program which starts earlier is
+ * picked.
+ */
+ private LongSparseArray<List<Program>> pickOneProgramPerEpisode(
+ List<SeriesRecording> seriesRecordings, List<Program> programs) {
+ return pickOneProgramPerEpisode(mDataManager, seriesRecordings, programs);
+ }
+
+ /**
+ * @see #pickOneProgramPerEpisode(List, List)
+ */
+ @VisibleForTesting
+ static LongSparseArray<List<Program>> pickOneProgramPerEpisode(
+ DvrDataManager dataManager, List<SeriesRecording> seriesRecordings,
+ List<Program> programs) {
+ // Initialize.
+ LongSparseArray<List<Program>> result = new LongSparseArray<>();
+ Map<String, Long> seriesRecordingIds = new HashMap<>();
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ result.put(seriesRecording.getId(), new ArrayList<>());
+ seriesRecordingIds.put(seriesRecording.getSeriesId(), seriesRecording.getId());
+ }
+ // Group programs by the episode.
+ Map<ScheduledEpisode, List<Program>> programsForEpisodeMap = new HashMap<>();
+ for (Program program : programs) {
+ long seriesRecordingId = seriesRecordingIds.get(program.getSeriesId());
+ if (TextUtils.isEmpty(program.getSeasonNumber())
+ || TextUtils.isEmpty(program.getEpisodeNumber())) {
+ // Add all the programs if it doesn't have season number or episode number.
+ result.get(seriesRecordingId).add(program);
+ continue;
+ }
+ ScheduledEpisode episode = new ScheduledEpisode(seriesRecordingId,
+ program.getSeasonNumber(), program.getEpisodeNumber());
+ List<Program> programsForEpisode = programsForEpisodeMap.get(episode);
+ if (programsForEpisode == null) {
+ programsForEpisode = new ArrayList<>();
+ programsForEpisodeMap.put(episode, programsForEpisode);
+ }
+ programsForEpisode.add(program);
+ }
+ // Pick one program.
+ for (Entry<ScheduledEpisode, List<Program>> entry : programsForEpisodeMap.entrySet()) {
+ List<Program> programsForEpisode = entry.getValue();
+ Collections.sort(programsForEpisode, new Comparator<Program>() {
+ @Override
+ public int compare(Program lhs, Program rhs) {
+ // Place the existing schedule first.
+ boolean lhsScheduled = isProgramScheduled(dataManager, lhs);
+ boolean rhsScheduled = isProgramScheduled(dataManager, rhs);
+ if (lhsScheduled && !rhsScheduled) {
+ return -1;
+ }
+ if (!lhsScheduled && rhsScheduled) {
+ return 1;
+ }
+ // Sort by the start time in ascending order.
+ return lhs.compareTo(rhs);
+ }
+ });
+ boolean added = false;
+ // Add all the scheduled programs
+ List<Program> programsForSeries = result.get(entry.getKey().seriesRecordingId);
+ for (Program program : programsForEpisode) {
+ if (isProgramScheduled(dataManager, program)) {
+ programsForSeries.add(program);
+ added = true;
+ } else if (!added) {
+ programsForSeries.add(program);
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private static boolean isProgramScheduled(DvrDataManager dataManager, Program program) {
+ ScheduledRecording schedule =
+ dataManager.getScheduledRecordingForProgramId(program.getId());
+ return schedule != null && schedule.getState()
+ == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
+ }
+
+ private void updateFetchedSeries() {
+ mSharedPreferences.edit().putStringSet(KEY_FETCHED_SERIES_IDS, mFetchedSeriesIds).apply();
+ }
+
+ /**
+ * This works only for the existing series recordings. Do not use this task for the
+ * "adding series recording" UI.
+ */
+ private class SeriesRecordingUpdateTask extends EpisodicProgramLoadTask {
+ SeriesRecordingUpdateTask(List<SeriesRecording> seriesRecordings) {
+ super(mContext, seriesRecordings);
+ }
+
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ if (DEBUG) Log.d(TAG, "onPostExecute: updating schedules with programs:" + programs);
+ mScheduleTasks.remove(this);
+ if (programs == null) {
+ Log.e(TAG, "Creating schedules for series recording failed: "
+ + getSeriesRecordings());
+ return;
+ }
+ LongSparseArray<List<Program>> seriesProgramMap = pickOneProgramPerEpisode(
+ getSeriesRecordings(), programs);
+ for (SeriesRecording seriesRecording : getSeriesRecordings()) {
+ // Check the series recording is still valid.
+ SeriesRecording actualSeriesRecording = mDataManager.getSeriesRecording(
+ seriesRecording.getId());
+ if (actualSeriesRecording == null || actualSeriesRecording.isStopped()) {
+ continue;
+ }
+ List<Program> programsToSchedule = seriesProgramMap.get(seriesRecording.getId());
+ if (mDataManager.getSeriesRecording(seriesRecording.getId()) != null
+ && !programsToSchedule.isEmpty()) {
+ mDvrManager.addScheduleToSeriesRecording(seriesRecording, programsToSchedule);
+ }
+ }
+ if (!mOnSeriesRecordingUpdatedListeners.isEmpty()) {
+ for (OnSeriesRecordingUpdatedListener listener
+ : mOnSeriesRecordingUpdatedListeners) {
+ listener.onSeriesRecordingUpdated(
+ SeriesRecording.toArray(getSeriesRecordings()));
+ }
+ }
+ }
+
+ @Override
+ protected void onCancelled(List<Program> programs) {
+ mScheduleTasks.remove(this);
+ }
+
+ @Override
+ public String toString() {
+ return "SeriesRecordingUpdateTask:{"
+ + "series_recordings=" + getSeriesRecordings()
+ + "}";
+ }
+ }
+
+ private class FetchSeriesInfoTask extends AsyncTask<Void, Void, SeriesInfo> {
+ private SeriesRecording mSeriesRecording;
+
+ FetchSeriesInfoTask(SeriesRecording seriesRecording) {
+ mSeriesRecording = seriesRecording;
+ }
+
+ @Override
+ protected SeriesInfo doInBackground(Void... voids) {
+ return EpgFetcher.createEpgReader(mContext)
+ .getSeriesInfo(mSeriesRecording.getSeriesId());
+ }
+
+ @Override
+ protected void onPostExecute(SeriesInfo seriesInfo) {
+ if (seriesInfo != null) {
+ mDataManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording)
+ .setTitle(seriesInfo.getTitle())
+ .setDescription(seriesInfo.getDescription())
+ .setLongDescription(seriesInfo.getLongDescription())
+ .setCanonicalGenreIds(seriesInfo.getCanonicalGenreIds())
+ .setPosterUri(seriesInfo.getPosterUri())
+ .setPhotoUri(seriesInfo.getPhotoUri())
+ .build());
+ mFetchedSeriesIds.add(seriesInfo.getId());
+ updateFetchedSeries();
+ }
+ mFetchSeriesInfoTasks.remove(this);
+ }
+
+ @Override
+ protected void onCancelled(SeriesInfo seriesInfo) {
+ mFetchSeriesInfoTasks.remove(this);
+ }
+ }
+
+ /**
+ * A listener to notify when series recording are updated.
+ */
+ public interface OnSeriesRecordingUpdatedListener {
+ void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings);
+ }
+}
diff --git a/src/com/android/tv/dvr/WritableDvrDataManager.java b/src/com/android/tv/dvr/WritableDvrDataManager.java
index 0b8a4c99..bf72d912 100644
--- a/src/com/android/tv/dvr/WritableDvrDataManager.java
+++ b/src/com/android/tv/dvr/WritableDvrDataManager.java
@@ -18,6 +18,8 @@ package com.android.tv.dvr;
import android.support.annotation.MainThread;
+import com.android.tv.dvr.ScheduledRecording.RecordingState;
+
/**
* Full data manager.
*
@@ -27,27 +29,50 @@ import android.support.annotation.MainThread;
@MainThread
interface WritableDvrDataManager extends DvrDataManager {
/**
- * Add a new recording.
+ * Adds new recordings.
+ */
+ void addScheduledRecording(ScheduledRecording... scheduledRecordings);
+
+ /**
+ * Adds new series recordings.
+ */
+ void addSeriesRecording(SeriesRecording... seriesRecordings);
+
+ /**
+ * Removes recordings.
+ */
+ void removeScheduledRecording(ScheduledRecording... scheduledRecordings);
+
+ /**
+ * Removes recordings. If {@code forceRemove} is {@code true}, the schedule will be permanently
+ * removed instead of changing the state to DELETED.
+ */
+ void removeScheduledRecording(boolean forceRemove, ScheduledRecording... scheduledRecordings);
+
+ /**
+ * Removes series recordings.
*/
- void addScheduledRecording(ScheduledRecording scheduledRecording);
+ void removeSeriesRecording(SeriesRecording... seasonSchedules);
/**
- * Add a season recording/
+ * Updates existing recordings.
*/
- void addSeasonRecording(SeasonRecording seasonRecording);
+ void updateScheduledRecording(ScheduledRecording... scheduledRecordings);
/**
- * Remove a recording.
+ * Updates existing series recordings.
*/
- void removeScheduledRecording(ScheduledRecording ScheduledRecording);
+ void updateSeriesRecording(SeriesRecording... seriesRecordings);
/**
- * Remove a season schedule.
+ * Changes the state of the recording.
*/
- void removeSeasonSchedule(SeasonRecording seasonSchedule);
+ void changeState(ScheduledRecording scheduledRecording, @RecordingState int newState);
/**
- * Update an existing recording.
+ * Remove all the records related to the input.
+ * <p>
+ * Note that this should be called after the input was removed.
*/
- void updateScheduledRecording(ScheduledRecording r);
+ void forgetStorage(String inputId);
}
diff --git a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java
index 6058aa54..1a12fb23 100644
--- a/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java
+++ b/src/com/android/tv/dvr/provider/AsyncDvrDbTask.java
@@ -22,7 +22,9 @@ import android.os.AsyncTask;
import android.support.annotation.Nullable;
import com.android.tv.dvr.ScheduledRecording;
-import com.android.tv.dvr.provider.DvrContract.Recordings;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.provider.DvrContract.Schedules;
+import com.android.tv.dvr.provider.DvrContract.SeriesRecordings;
import com.android.tv.util.NamedThreadFactory;
import java.util.ArrayList;
@@ -76,61 +78,59 @@ public abstract class AsyncDvrDbTask<Params, Progress, Result>
protected abstract Result doInDvrBackground(Params... params);
/**
- * Inserts recordings returning the list of recordings with id set.
- * The id will be -1 if there was an error.
+ * Inserts schedules.
*/
- public abstract static class AsyncAddRecordingTask
- extends AsyncDvrDbTask<ScheduledRecording, Void, List<ScheduledRecording>> {
-
- public AsyncAddRecordingTask(Context context) {
+ public static class AsyncAddScheduleTask
+ extends AsyncDvrDbTask<ScheduledRecording, Void, Void> {
+ public AsyncAddScheduleTask(Context context) {
super(context);
}
@Override
- protected final List<ScheduledRecording> doInDvrBackground(ScheduledRecording... params) {
- return sDbHelper.insertRecordings(params);
+ protected final Void doInDvrBackground(ScheduledRecording... params) {
+ sDbHelper.insertSchedules(params);
+ return null;
}
}
/**
- * Update recordings.
- *
- * @return list of row update counts. The count will be -1 if there was an error or 0
- * if no match was found. The count is expected to be exactly 1 for each recording.
+ * Update schedules.
*/
- public abstract static class AsyncUpdateRecordingTask
- extends AsyncDvrDbTask<ScheduledRecording, Void, List<Integer>> {
- public AsyncUpdateRecordingTask(Context context) {
+ public static class AsyncUpdateScheduleTask
+ extends AsyncDvrDbTask<ScheduledRecording, Void, Void> {
+ public AsyncUpdateScheduleTask(Context context) {
super(context);
}
@Override
- protected final List<Integer> doInDvrBackground(ScheduledRecording... params) {
- return sDbHelper.updateRecordings(params);
+ protected final Void doInDvrBackground(ScheduledRecording... params) {
+ sDbHelper.updateSchedules(params);
+ return null;
}
}
/**
- * Delete recordings.
- *
- * @return list of row delete counts. The count will be -1 if there was an error or 0
- * if no match was found. The count is expected to be exactly 1 for each recording.
+ * Delete schedules.
*/
- public abstract static class AsyncDeleteRecordingTask
- extends AsyncDvrDbTask<ScheduledRecording, Void, List<Integer>> {
- public AsyncDeleteRecordingTask(Context context) {
+ public static class AsyncDeleteScheduleTask
+ extends AsyncDvrDbTask<ScheduledRecording, Void, Void> {
+ public AsyncDeleteScheduleTask(Context context) {
super(context);
}
@Override
- protected final List<Integer> doInDvrBackground(ScheduledRecording... params) {
- return sDbHelper.deleteRecordings(params);
+ protected final Void doInDvrBackground(ScheduledRecording... params) {
+ sDbHelper.deleteSchedules(params);
+ return null;
}
}
- public abstract static class AsyncDvrQueryTask
+ /**
+ * Returns all {@link ScheduledRecording}s.
+ */
+ public abstract static class AsyncDvrQueryScheduleTask
extends AsyncDvrDbTask<Void, Void, List<ScheduledRecording>> {
- public AsyncDvrQueryTask(Context context) {
+ public AsyncDvrQueryScheduleTask(Context context) {
super(context);
}
@@ -140,17 +140,84 @@ public abstract class AsyncDvrDbTask<Params, Progress, Result>
if (isCancelled()) {
return null;
}
-
- if (isCancelled()) {
- return null;
+ List<ScheduledRecording> scheduledRecordings = new ArrayList<>();
+ try (Cursor c = sDbHelper.query(Schedules.TABLE_NAME, ScheduledRecording.PROJECTION)) {
+ while (c.moveToNext() && !isCancelled()) {
+ scheduledRecordings.add(ScheduledRecording.fromCursor(c));
+ }
}
+ return scheduledRecordings;
+ }
+ }
+
+ /**
+ * Inserts series recordings.
+ */
+ public static class AsyncAddSeriesRecordingTask
+ extends AsyncDvrDbTask<SeriesRecording, Void, Void> {
+ public AsyncAddSeriesRecordingTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected final Void doInDvrBackground(SeriesRecording... params) {
+ sDbHelper.insertSeriesRecordings(params);
+ return null;
+ }
+ }
+
+ /**
+ * Update series recordings.
+ */
+ public static class AsyncUpdateSeriesRecordingTask
+ extends AsyncDvrDbTask<SeriesRecording, Void, Void> {
+ public AsyncUpdateSeriesRecordingTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected final Void doInDvrBackground(SeriesRecording... params) {
+ sDbHelper.updateSeriesRecordings(params);
+ return null;
+ }
+ }
+
+ /**
+ * Delete series recordings.
+ */
+ public static class AsyncDeleteSeriesRecordingTask
+ extends AsyncDvrDbTask<SeriesRecording, Void, Void> {
+ public AsyncDeleteSeriesRecordingTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected final Void doInDvrBackground(SeriesRecording... params) {
+ sDbHelper.deleteSeriesRecordings(params);
+ return null;
+ }
+ }
+
+ /**
+ * Returns all {@link SeriesRecording}s.
+ */
+ public abstract static class AsyncDvrQuerySeriesRecordingTask
+ extends AsyncDvrDbTask<Void, Void, List<SeriesRecording>> {
+ public AsyncDvrQuerySeriesRecordingTask(Context context) {
+ super(context);
+ }
+
+ @Override
+ @Nullable
+ protected final List<SeriesRecording> doInDvrBackground(Void... params) {
if (isCancelled()) {
return null;
}
- List<ScheduledRecording> scheduledRecordings = new ArrayList<>();
- try (Cursor c = sDbHelper.query(Recordings.TABLE_NAME, ScheduledRecording.PROJECTION)) {
+ List<SeriesRecording> scheduledRecordings = new ArrayList<>();
+ try (Cursor c = sDbHelper.query(SeriesRecordings.TABLE_NAME,
+ SeriesRecording.PROJECTION)) {
while (c.moveToNext() && !isCancelled()) {
- scheduledRecordings.add(ScheduledRecording.fromCursor(c));
+ scheduledRecordings.add(SeriesRecording.fromCursor(c));
}
}
return scheduledRecordings;
diff --git a/src/com/android/tv/dvr/provider/DvrContract.java b/src/com/android/tv/dvr/provider/DvrContract.java
index 192cc17b..f0aca18e 100644
--- a/src/com/android/tv/dvr/provider/DvrContract.java
+++ b/src/com/android/tv/dvr/provider/DvrContract.java
@@ -23,10 +23,10 @@ import android.provider.BaseColumns;
* columns. It's for the internal use in Live TV.
*/
public final class DvrContract {
- /** Column definition for Recording table. */
- public static final class Recordings implements BaseColumns {
+ /** Column definition for Schedules table. */
+ public static final class Schedules implements BaseColumns {
/** The table name. */
- public static final String TABLE_NAME = "recording";
+ public static final String TABLE_NAME = "schedules";
/** The recording type for program recording. */
public static final String TYPE_PROGRAM = "TYPE_PROGRAM";
@@ -34,22 +34,27 @@ public final class DvrContract {
/** The recording type for timed recording. */
public static final String TYPE_TIMED = "TYPE_TIMED";
- /** The recording type for season recording. */
- public static final String TYPE_SEASON_RECORDING = "TYPE_SEASON_RECORDING";
-
/** The recording has not been started yet. */
public static final String STATE_RECORDING_NOT_STARTED = "STATE_RECORDING_NOT_STARTED";
/** The recording is in progress. */
public static final String STATE_RECORDING_IN_PROGRESS = "STATE_RECORDING_IN_PROGRESS";
- /** The recording was unexpectedly stopped. */
- public static final String STATE_RECORDING_UNEXPECTEDLY_STOPPED =
- "STATE_RECORDING_UNEXPECTEDLY_STOPPED";
-
/** The recording is finished. */
public static final String STATE_RECORDING_FINISHED = "STATE_RECORDING_FINISHED";
+ /** The recording failed. */
+ public static final String STATE_RECORDING_FAILED = "STATE_RECORDING_FAILED";
+
+ /** The recording finished and clipping. */
+ public static final String STATE_RECORDING_CLIPPED = "STATE_RECORDING_CLIPPED";
+
+ /** The recording marked as deleted. */
+ public static final String STATE_RECORDING_DELETED = "STATE_RECORDING_DELETED";
+
+ /** The recording marked as canceled. */
+ public static final String STATE_RECORDING_CANCELED = "STATE_RECORDING_CANCELED";
+
/**
* The priority of this recording.
*
@@ -63,16 +68,25 @@ public final class DvrContract {
/**
* The type of this recording.
*
- * <p>This value should be one of the followings: {@link #TYPE_PROGRAM},
- * {@link #TYPE_TIMED}, and {@link #TYPE_SEASON_RECORDING}.
+ * <p>This value should be one of the followings: {@link #TYPE_PROGRAM} and
+ * {@link #TYPE_TIMED}.
*
* <p>This is a required field.
*
- * <p>Type: String
+ * <p>Type: TEXT
*/
public static final String COLUMN_TYPE = "type";
/**
+ * The input id of recording.
+ *
+ * <p>This is a required field.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_INPUT_ID = "input_id";
+
+ /**
* The ID of the channel for recording.
*
* <p>This is a required field.
@@ -81,9 +95,8 @@ public final class DvrContract {
*/
public static final String COLUMN_CHANNEL_ID = "channel_id";
-
/**
- * The ID of the associated program for recording.
+ * The ID of the associated program for recording.
*
* <p>This is an optional field.
*
@@ -92,6 +105,15 @@ public final class DvrContract {
public static final String COLUMN_PROGRAM_ID = "program_id";
/**
+ * The title of the associated program for recording.
+ *
+ * <p>This is an optional field.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PROGRAM_TITLE = "program_title";
+
+ /**
* The start time of this recording, in milliseconds since the epoch.
*
* <p>This is a required field.
@@ -110,19 +132,261 @@ public final class DvrContract {
public static final String COLUMN_END_TIME_UTC_MILLIS = "end_time_utc_millis";
/**
+ * The season number of this program for episodic TV shows.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_SEASON_NUMBER = "season_number";
+
+ /**
+ * The episode number of this program for episodic TV shows.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_EPISODE_NUMBER = "episode_number";
+
+ /**
+ * The episode title of this program for episodic TV shows.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_EPISODE_TITLE = "episode_title";
+
+ /**
+ * The description of program.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PROGRAM_DESCRIPTION = "program_description";
+
+ /**
+ * The long description of program.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PROGRAM_LONG_DESCRIPTION = "program_long_description";
+
+ /**
+ * The poster art uri of program.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PROGRAM_POST_ART_URI = "program_poster_art_uri";
+
+ /**
+ * The thumbnail uri of program.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PROGRAM_THUMBNAIL_URI = "program_thumbnail_uri";
+
+ /**
* The state of this recording.
*
* <p>This value should be one of the followings: {@link #STATE_RECORDING_NOT_STARTED},
- * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_UNEXPECTEDLY_STOPPED},
- * and {@link #STATE_RECORDING_FINISHED}.
+ * {@link #STATE_RECORDING_IN_PROGRESS}, {@link #STATE_RECORDING_FINISHED},
+ * {@link #STATE_RECORDING_FAILED}, {@link #STATE_RECORDING_CLIPPED} and
+ * {@link #STATE_RECORDING_DELETED}.
*
* <p>This is a required field.
*
- * <p>Type: String
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_STATE = "state";
+
+ /**
+ * The ID of the parent series recording.
+ *
+ * <p>Type: INTEGER (long)
+ */
+ public static final String COLUMN_SERIES_RECORDING_ID = "series_recording_id";
+
+ private Schedules() { }
+ }
+
+ /** Column definition for Recording table. */
+ public static final class SeriesRecordings implements BaseColumns {
+ /** The table name. */
+ public static final String TABLE_NAME = "series_recording";
+
+ /**
+ * This value is used for {@link #COLUMN_START_FROM_SEASON} and
+ * {@link #COLUMN_START_FROM_EPISODE} to mean record all seasons or episodes.
+ */
+ public static final int THE_BEGINNING = -1;
+
+ /**
+ * The series recording option which indicates that the episodes in one channel are
+ * recorded.
+ */
+ public static final String OPTION_CHANNEL_ONE = "OPTION_CHANNEL_ONE";
+
+ /**
+ * The series recording option which indicates that the episodes in all the channels are
+ * recorded.
+ */
+ public static final String OPTION_CHANNEL_ALL = "OPTION_CHANNEL_ALL";
+
+ /**
+ * The state indicates that it is a normal one.
+ */
+ public static final String STATE_SERIES_NORMAL = "STATE_SERIES_NORMAL";
+
+ /**
+ * The state indicates that it is stopped.
+ */
+ public static final String STATE_SERIES_STOPPED = "STATE_SERIES_STOPPED";
+
+ /**
+ * The priority of this recording.
+ *
+ * <p> The lowest number is recorded first. If there is a tie in priority then the lower id
+ * wins. Defaults to {@value Long#MAX_VALUE}
+ *
+ * <p>Type: INTEGER (long)
+ */
+ public static final String COLUMN_PRIORITY = "priority";
+
+ /**
+ * The input id of recording.
+ *
+ * <p>This is a required field.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_INPUT_ID = "input_id";
+
+ /**
+ * The ID of the channel for recording.
+ *
+ * <p>This is a required field.
+ *
+ * <p>Type: INTEGER (long)
+ */
+ public static final String COLUMN_CHANNEL_ID = "channel_id";
+
+ /**
+ * The ID of the associated series to record.
+ *
+ * <p>The id is an opaque but stable string.
+ *
+ * <p>This is an optional field.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_SERIES_ID = "series_id";
+
+ /**
+ * The title of the series.
+ *
+ * <p>This is a required field.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_TITLE = "title";
+
+ /**
+ * The short description of the series.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_SHORT_DESCRIPTION = "short_description";
+
+ /**
+ * The long description of the series.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_LONG_DESCRIPTION = "long_description";
+
+ /**
+ * The number of the earliest season to record. The
+ * value {@link #THE_BEGINNING} means record all seasons.
+ *
+ * <p>Default value is {@value #THE_BEGINNING} {@link #THE_BEGINNING}.
+ *
+ * <p>Type: INTEGER (int)
+ */
+ public static final String COLUMN_START_FROM_SEASON = "start_from_season";
+
+ /**
+ * The number of the earliest episode to record in {@link #COLUMN_START_FROM_SEASON}. The
+ * value {@link #THE_BEGINNING} means record all episodes.
+ *
+ * <p>Default value is {@value #THE_BEGINNING} {@link #THE_BEGINNING}.
+ *
+ * <p>Type: INTEGER (int)
+ */
+ public static final String COLUMN_START_FROM_EPISODE = "start_from_episode";
+
+ /**
+ * The series recording option which indicates the channels to record.
+ *
+ * <p>This value should be one of the followings: {@link #OPTION_CHANNEL_ONE} and
+ * {@link #OPTION_CHANNEL_ALL}. The default value is OPTION_CHANNEL_ONE.
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_CHANNEL_OPTION = "channel_option";
+
+ /**
+ * The comma-separated canonical genre string of this series.
+ *
+ * <p>Canonical genres are defined in {@link android.media.tv.TvContract.Programs.Genres}.
+ * Use {@link android.media.tv.TvContract.Programs.Genres#encode} to create a text that can
+ * be stored in this column. Use {@link android.media.tv.TvContract.Programs.Genres#decode}
+ * to get the canonical genre strings from the text stored in the column.
+ *
+ * <p>Type: TEXT
+ * @see android.media.tv.TvContract.Programs.Genres
+ * @see android.media.tv.TvContract.Programs.Genres#encode
+ * @see android.media.tv.TvContract.Programs.Genres#decode
+ */
+ public static final String COLUMN_CANONICAL_GENRE = "canonical_genre";
+
+ /**
+ * The URI for the poster of this TV series.
+ *
+ * <p>The data in the column must be a URL, or a URI in one of the following formats:
+ *
+ * <ul>
+ * <li>content ({@link android.content.ContentResolver#SCHEME_CONTENT})</li>
+ * <li>android.resource ({@link android.content.ContentResolver#SCHEME_ANDROID_RESOURCE})
+ * </li>
+ * <li>file ({@link android.content.ContentResolver#SCHEME_FILE})</li>
+ * </ul>
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_POSTER_URI = "poster_uri";
+
+ /**
+ * The URI for the photo of this TV program.
+ *
+ * <p>The data in the column must be a URL, or a URI in one of the following formats:
+ *
+ * <ul>
+ * <li>content ({@link android.content.ContentResolver#SCHEME_CONTENT})</li>
+ * <li>android.resource ({@link android.content.ContentResolver#SCHEME_ANDROID_RESOURCE})
+ * </li>
+ * <li>file ({@link android.content.ContentResolver#SCHEME_FILE})</li>
+ * </ul>
+ *
+ * <p>Type: TEXT
+ */
+ public static final String COLUMN_PHOTO_URI = "photo_uri";
+
+ /**
+ * The state of whether the series recording be canceled or not.
+ *
+ * <p>This value should be one of the followings: {@link #STATE_SERIES_NORMAL} and
+ * {@link #STATE_SERIES_STOPPED}. The default value is STATE_SERIES_NORMAL.
+ *
+ * <p>Type: TEXT
*/
public static final String COLUMN_STATE = "state";
- private Recordings() { }
+ private SeriesRecordings() { }
}
private DvrContract() { }
diff --git a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java
index bdba8ac3..2f16ba5d 100644
--- a/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java
+++ b/src/com/android/tv/dvr/provider/DvrDatabaseHelper.java
@@ -22,13 +22,15 @@ import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
+import android.provider.BaseColumns;
+import android.text.TextUtils;
import android.util.Log;
import com.android.tv.dvr.ScheduledRecording;
-import com.android.tv.dvr.provider.DvrContract.Recordings;
-
-import java.util.ArrayList;
-import java.util.List;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.provider.DvrContract.Schedules;
+import com.android.tv.dvr.provider.DvrContract.SeriesRecordings;
/**
* A data class for one recorded contents.
@@ -37,24 +39,153 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper {
private static final String TAG = "DvrDatabaseHelper";
private static final boolean DEBUG = true;
- private static final int DATABASE_VERSION = 4;
+ private static final int DATABASE_VERSION = 17;
private static final String DB_NAME = "dvr.db";
- private static final String SQL_CREATE_RECORDINGS =
- "CREATE TABLE " + Recordings.TABLE_NAME + "("
- + Recordings._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
- + Recordings.COLUMN_PRIORITY + " INTEGER DEFAULT " + Long.MAX_VALUE + ","
- + Recordings.COLUMN_TYPE + " TEXT NOT NULL,"
- + Recordings.COLUMN_CHANNEL_ID + " INTEGER NOT NULL,"
- + Recordings.COLUMN_PROGRAM_ID + " INTEGER ,"
- + Recordings.COLUMN_START_TIME_UTC_MILLIS + " INTEGER NOT NULL,"
- + Recordings.COLUMN_END_TIME_UTC_MILLIS + " INTEGER NOT NULL,"
- + Recordings.COLUMN_STATE + " TEXT NOT NULL)";
-
- private static final String SQL_DROP_RECORDINGS = "DROP TABLE IF EXISTS "
- + Recordings.TABLE_NAME;
- public static final String WHERE_RECORDING_ID_EQUALS = Recordings._ID + " = ?";
+ private static final String SQL_CREATE_SCHEDULES =
+ "CREATE TABLE " + Schedules.TABLE_NAME + "("
+ + Schedules._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + Schedules.COLUMN_PRIORITY + " INTEGER DEFAULT "
+ + ScheduledRecording.DEFAULT_PRIORITY + ","
+ + Schedules.COLUMN_TYPE + " TEXT NOT NULL,"
+ + Schedules.COLUMN_INPUT_ID + " TEXT NOT NULL,"
+ + Schedules.COLUMN_CHANNEL_ID + " INTEGER NOT NULL,"
+ + Schedules.COLUMN_PROGRAM_ID + " INTEGER,"
+ + Schedules.COLUMN_PROGRAM_TITLE + " TEXT,"
+ + Schedules.COLUMN_START_TIME_UTC_MILLIS + " INTEGER NOT NULL,"
+ + Schedules.COLUMN_END_TIME_UTC_MILLIS + " INTEGER NOT NULL,"
+ + Schedules.COLUMN_SEASON_NUMBER + " TEXT,"
+ + Schedules.COLUMN_EPISODE_NUMBER + " TEXT,"
+ + Schedules.COLUMN_EPISODE_TITLE + " TEXT,"
+ + Schedules.COLUMN_PROGRAM_DESCRIPTION + " TEXT,"
+ + Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION + " TEXT,"
+ + Schedules.COLUMN_PROGRAM_POST_ART_URI + " TEXT,"
+ + Schedules.COLUMN_PROGRAM_THUMBNAIL_URI + " TEXT,"
+ + Schedules.COLUMN_STATE + " TEXT NOT NULL,"
+ + Schedules.COLUMN_SERIES_RECORDING_ID + " INTEGER,"
+ + "FOREIGN KEY(" + Schedules.COLUMN_SERIES_RECORDING_ID + ") "
+ + "REFERENCES " + SeriesRecordings.TABLE_NAME
+ + "(" + SeriesRecordings._ID + ") "
+ + "ON UPDATE CASCADE ON DELETE SET NULL);";
+
+ private static final String SQL_DROP_SCHEDULES = "DROP TABLE IF EXISTS " + Schedules.TABLE_NAME;
+
+ private static final String SQL_CREATE_SERIES_RECORDINGS =
+ "CREATE TABLE " + SeriesRecordings.TABLE_NAME + "("
+ + SeriesRecordings._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + SeriesRecordings.COLUMN_PRIORITY + " INTEGER DEFAULT "
+ + SeriesRecording.DEFAULT_PRIORITY + ","
+ + SeriesRecordings.COLUMN_TITLE + " TEXT NOT NULL,"
+ + SeriesRecordings.COLUMN_SHORT_DESCRIPTION + " TEXT,"
+ + SeriesRecordings.COLUMN_LONG_DESCRIPTION + " TEXT,"
+ + SeriesRecordings.COLUMN_INPUT_ID + " TEXT NOT NULL,"
+ + SeriesRecordings.COLUMN_CHANNEL_ID + " INTEGER NOT NULL,"
+ + SeriesRecordings.COLUMN_SERIES_ID + " TEXT NOT NULL,"
+ + SeriesRecordings.COLUMN_START_FROM_SEASON + " INTEGER DEFAULT "
+ + SeriesRecordings.THE_BEGINNING + ","
+ + SeriesRecordings.COLUMN_START_FROM_EPISODE + " INTEGER DEFAULT "
+ + SeriesRecordings.THE_BEGINNING + ","
+ + SeriesRecordings.COLUMN_CHANNEL_OPTION + " TEXT DEFAULT "
+ + SeriesRecordings.OPTION_CHANNEL_ONE + ","
+ + SeriesRecordings.COLUMN_CANONICAL_GENRE + " TEXT,"
+ + SeriesRecordings.COLUMN_POSTER_URI + " TEXT,"
+ + SeriesRecordings.COLUMN_PHOTO_URI + " TEXT,"
+ + SeriesRecordings.COLUMN_STATE + " TEXT)";
+
+ private static final String SQL_DROP_SERIES_RECORDINGS = "DROP TABLE IF EXISTS " +
+ SeriesRecordings.TABLE_NAME;
+
+ private static final int SQL_DATA_TYPE_LONG = 0;
+ private static final int SQL_DATA_TYPE_INT = 1;
+ private static final int SQL_DATA_TYPE_STRING = 2;
+
+ private static final ColumnInfo[] COLUMNS_SCHEDULES = new ColumnInfo[] {
+ new ColumnInfo(Schedules._ID, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_PRIORITY, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_TYPE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_INPUT_ID, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_CHANNEL_ID, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_ID, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_TITLE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_START_TIME_UTC_MILLIS, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_END_TIME_UTC_MILLIS, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(Schedules.COLUMN_SEASON_NUMBER, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_EPISODE_NUMBER, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_EPISODE_TITLE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_DESCRIPTION, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_LONG_DESCRIPTION, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_POST_ART_URI, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_PROGRAM_THUMBNAIL_URI, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_STATE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(Schedules.COLUMN_SERIES_RECORDING_ID, SQL_DATA_TYPE_LONG)};
+ private static final String SQL_INSERT_SCHEDULES =
+ buildInsertSql(Schedules.TABLE_NAME, COLUMNS_SCHEDULES);
+ private static final String SQL_UPDATE_SCHEDULES =
+ buildUpdateSql(Schedules.TABLE_NAME, COLUMNS_SCHEDULES);
+ private static final String SQL_DELETE_SCHEDULES = buildDeleteSql(Schedules.TABLE_NAME);
+
+ private static final ColumnInfo[] COLUMNS_SERIES_RECORDINGS = new ColumnInfo[] {
+ new ColumnInfo(SeriesRecordings._ID, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(SeriesRecordings.COLUMN_PRIORITY, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(SeriesRecordings.COLUMN_INPUT_ID, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_CHANNEL_ID, SQL_DATA_TYPE_LONG),
+ new ColumnInfo(SeriesRecordings.COLUMN_SERIES_ID, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_TITLE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_SHORT_DESCRIPTION, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_LONG_DESCRIPTION, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_START_FROM_SEASON, SQL_DATA_TYPE_INT),
+ new ColumnInfo(SeriesRecordings.COLUMN_START_FROM_EPISODE, SQL_DATA_TYPE_INT),
+ new ColumnInfo(SeriesRecordings.COLUMN_CHANNEL_OPTION, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_CANONICAL_GENRE, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_POSTER_URI, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_PHOTO_URI, SQL_DATA_TYPE_STRING),
+ new ColumnInfo(SeriesRecordings.COLUMN_STATE, SQL_DATA_TYPE_STRING)};
+
+ private static final String SQL_INSERT_SERIES_RECORDINGS =
+ buildInsertSql(SeriesRecordings.TABLE_NAME, COLUMNS_SERIES_RECORDINGS);
+ private static final String SQL_UPDATE_SERIES_RECORDINGS =
+ buildUpdateSql(SeriesRecordings.TABLE_NAME, COLUMNS_SERIES_RECORDINGS);
+ private static final String SQL_DELETE_SERIES_RECORDINGS =
+ buildDeleteSql(SeriesRecordings.TABLE_NAME);
+
+ private static String buildInsertSql(String tableName, ColumnInfo[] columns) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("INSERT INTO ").append(tableName).append(" (");
+ boolean appendComma = false;
+ for (ColumnInfo columnInfo : columns) {
+ if (appendComma) {
+ sb.append(",");
+ }
+ appendComma = true;
+ sb.append(columnInfo.name);
+ }
+ sb.append(") VALUES (?");
+ for (int i = 1; i < columns.length; ++i) {
+ sb.append(",?");
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ private static String buildUpdateSql(String tableName, ColumnInfo[] columns) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("UPDATE ").append(tableName).append(" SET ");
+ boolean appendComma = false;
+ for (ColumnInfo columnInfo : columns) {
+ if (appendComma) {
+ sb.append(",");
+ }
+ appendComma = true;
+ sb.append(columnInfo.name).append("=?");
+ }
+ sb.append(" WHERE ").append(BaseColumns._ID).append("=?");
+ return sb.toString();
+ }
+
+ private static String buildDeleteSql(String tableName) {
+ return "DELETE FROM " + tableName + " WHERE " + BaseColumns._ID + "=?";
+ }
public DvrDatabaseHelper(Context context) {
super(context.getApplicationContext(), DB_NAME, null, DATABASE_VERSION);
}
@@ -66,14 +197,18 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper {
@Override
public void onCreate(SQLiteDatabase db) {
- if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_RECORDINGS);
- db.execSQL(SQL_CREATE_RECORDINGS);
+ if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_SCHEDULES);
+ db.execSQL(SQL_CREATE_SCHEDULES);
+ if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_CREATE_SERIES_RECORDINGS);
+ db.execSQL(SQL_CREATE_SERIES_RECORDINGS);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
- if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_RECORDINGS);
- db.execSQL(SQL_DROP_RECORDINGS);
+ if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_SCHEDULES);
+ db.execSQL(SQL_DROP_SCHEDULES);
+ if (DEBUG) Log.d(TAG, "Executing SQL: " + SQL_DROP_SERIES_RECORDINGS);
+ db.execSQL(SQL_DROP_SERIES_RECORDINGS);
onCreate(db);
}
@@ -88,61 +223,164 @@ public class DvrDatabaseHelper extends SQLiteOpenHelper {
}
/**
- * Inserts recordings.
- *
- * @return The list of recordings with id set. The id will be -1 if there was an error.
+ * Inserts schedules.
*/
- public List<ScheduledRecording> insertRecordings(ScheduledRecording... scheduledRecordings) {
- updateChannelsFromRecordings(scheduledRecordings);
+ public void insertSchedules(ScheduledRecording... scheduledRecordings) {
+ SQLiteDatabase db = getWritableDatabase();
+ SQLiteStatement statement = db.compileStatement(SQL_INSERT_SCHEDULES);
+ db.beginTransaction();
+ try {
+ for (ScheduledRecording r : scheduledRecordings) {
+ statement.clearBindings();
+ ContentValues values = ScheduledRecording.toContentValues(r);
+ bindColumns(statement, COLUMNS_SCHEDULES, values);
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
- SQLiteDatabase db = getReadableDatabase();
- List<ScheduledRecording> results = new ArrayList<>();
- for (ScheduledRecording r : scheduledRecordings) {
- ContentValues values = ScheduledRecording.toContentValues(r);
- long id = db.insert(Recordings.TABLE_NAME, null, values);
- results.add(ScheduledRecording.buildFrom(r).setId(id).build());
+ /**
+ * Update schedules.
+ */
+ public void updateSchedules(ScheduledRecording... scheduledRecordings) {
+ SQLiteDatabase db = getWritableDatabase();
+ SQLiteStatement statement = db.compileStatement(SQL_UPDATE_SCHEDULES);
+ db.beginTransaction();
+ try {
+ for (ScheduledRecording r : scheduledRecordings) {
+ statement.clearBindings();
+ ContentValues values = ScheduledRecording.toContentValues(r);
+ bindColumns(statement, COLUMNS_SCHEDULES, values);
+ statement.bindLong(COLUMNS_SCHEDULES.length + 1, r.getId());
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
+ * Delete schedules.
+ */
+ public void deleteSchedules(ScheduledRecording... scheduledRecordings) {
+ SQLiteDatabase db = getWritableDatabase();
+ SQLiteStatement statement = db.compileStatement(SQL_DELETE_SCHEDULES);
+ db.beginTransaction();
+ try {
+ for (ScheduledRecording r : scheduledRecordings) {
+ statement.clearBindings();
+ statement.bindLong(1, r.getId());
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
}
- return results;
}
/**
- * Update recordings.
- *
- * @return The list of row update counts. The count will be -1 if there was an error or 0
- * if no match was found. The count is expected to be exactly 1 for each recording.
+ * Inserts series recordings.
*/
- public List<Integer> updateRecordings(ScheduledRecording[] scheduledRecordings) {
- updateChannelsFromRecordings(scheduledRecordings);
+ public void insertSeriesRecordings(SeriesRecording... seriesRecordings) {
SQLiteDatabase db = getWritableDatabase();
- List<Integer> results = new ArrayList<>();
- for (ScheduledRecording r : scheduledRecordings) {
- ContentValues values = ScheduledRecording.toContentValues(r);
- int updated = db.update(Recordings.TABLE_NAME, values, Recordings._ID + " = ?",
- new String[] {String.valueOf(r.getId())});
- results.add(updated);
+ SQLiteStatement statement = db.compileStatement(SQL_INSERT_SERIES_RECORDINGS);
+ db.beginTransaction();
+ try {
+ for (SeriesRecording r : seriesRecordings) {
+ statement.clearBindings();
+ ContentValues values = SeriesRecording.toContentValues(r);
+ bindColumns(statement, COLUMNS_SERIES_RECORDINGS, values);
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
}
- return results;
}
- private void updateChannelsFromRecordings(ScheduledRecording[] scheduledRecordings) {
- // TODO(DVR) implement/
- // TODO(DVR) consider not deleting channels instead of keeping a separate table.
+ /**
+ * Update series recordings.
+ */
+ public void updateSeriesRecordings(SeriesRecording... seriesRecordings) {
+ SQLiteDatabase db = getWritableDatabase();
+ SQLiteStatement statement = db.compileStatement(SQL_UPDATE_SERIES_RECORDINGS);
+ db.beginTransaction();
+ try {
+ for (SeriesRecording r : seriesRecordings) {
+ statement.clearBindings();
+ ContentValues values = SeriesRecording.toContentValues(r);
+ bindColumns(statement, COLUMNS_SERIES_RECORDINGS, values);
+ statement.bindLong(COLUMNS_SERIES_RECORDINGS.length + 1, r.getId());
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
}
/**
- * Delete recordings.
- *
- * @return The list of row update counts. The count will be -1 if there was an error or 0
- * if no match was found. The count is expected to be exactly 1 for each recording.
+ * Delete series recordings.
*/
- public List<Integer> deleteRecordings(ScheduledRecording[] scheduledRecordings) {
+ public void deleteSeriesRecordings(SeriesRecording... seriesRecordings) {
SQLiteDatabase db = getWritableDatabase();
- List<Integer> results = new ArrayList<>();
- for (ScheduledRecording r : scheduledRecordings) {
- int deleted = db.delete(Recordings.TABLE_NAME, WHERE_RECORDING_ID_EQUALS,
- new String[] {String.valueOf(r.getId())});
- results.add(deleted);
+ SQLiteStatement statement = db.compileStatement(SQL_DELETE_SERIES_RECORDINGS);
+ db.beginTransaction();
+ try {
+ for (SeriesRecording r : seriesRecordings) {
+ statement.clearBindings();
+ statement.bindLong(1, r.getId());
+ statement.execute();
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ private void bindColumns(SQLiteStatement statement, ColumnInfo[] columns,
+ ContentValues values) {
+ for (int i = 0; i < columns.length; ++i) {
+ ColumnInfo columnInfo = columns[i];
+ Object value = values.get(columnInfo.name);
+ switch (columnInfo.type) {
+ case SQL_DATA_TYPE_LONG:
+ if (value == null) {
+ statement.bindNull(i + 1);
+ } else {
+ statement.bindLong(i + 1, (Long) value);
+ }
+ break;
+ case SQL_DATA_TYPE_INT:
+ if (value == null) {
+ statement.bindNull(i + 1);
+ } else {
+ statement.bindLong(i + 1, (Integer) value);
+ }
+ break;
+ case SQL_DATA_TYPE_STRING: {
+ if (TextUtils.isEmpty((String) value)) {
+ statement.bindNull(i + 1);
+ } else {
+ statement.bindString(i + 1, (String) value);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ private static class ColumnInfo {
+ final String name;
+ final int type;
+
+ ColumnInfo(String name, int type) {
+ this.name = name;
+ this.type = type;
}
- return results;
}
}
diff --git a/src/com/android/tv/dvr/ui/ActionPresenterSelector.java b/src/com/android/tv/dvr/ui/ActionPresenterSelector.java
new file mode 100644
index 00000000..8b8cd5c5
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/ActionPresenterSelector.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.graphics.drawable.Drawable;
+import android.support.v17.leanback.R;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.Presenter;
+import android.support.v17.leanback.widget.PresenterSelector;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+// This class is adapted from Leanback's library, which does not support action icon with one-line
+// label. This class modified its getPresenter method to support the above situation.
+class ActionPresenterSelector extends PresenterSelector {
+ private final Presenter mOneLineActionPresenter = new OneLineActionPresenter();
+ private final Presenter mTwoLineActionPresenter = new TwoLineActionPresenter();
+ private final Presenter[] mPresenters = new Presenter[] {
+ mOneLineActionPresenter, mTwoLineActionPresenter};
+
+ @Override
+ public Presenter getPresenter(Object item) {
+ Action action = (Action) item;
+ if (TextUtils.isEmpty(action.getLabel2()) && action.getIcon() == null) {
+ return mOneLineActionPresenter;
+ } else {
+ return mTwoLineActionPresenter;
+ }
+ }
+
+ @Override
+ public Presenter[] getPresenters() {
+ return mPresenters;
+ }
+
+ static class ActionViewHolder extends Presenter.ViewHolder {
+ Action mAction;
+ Button mButton;
+ int mLayoutDirection;
+
+ public ActionViewHolder(View view, int layoutDirection) {
+ super(view);
+ mButton = (Button) view.findViewById(R.id.lb_action_button);
+ mLayoutDirection = layoutDirection;
+ }
+ }
+
+ class OneLineActionPresenter extends Presenter {
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.lb_action_1_line, parent, false);
+ return new ActionViewHolder(v, parent.getLayoutDirection());
+ }
+
+ @Override
+ public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
+ Action action = (Action) item;
+ ActionViewHolder vh = (ActionViewHolder) viewHolder;
+ vh.mAction = action;
+ vh.mButton.setText(action.getLabel1());
+ }
+
+ @Override
+ public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) {
+ ((ActionViewHolder) viewHolder).mAction = null;
+ }
+ }
+
+ class TwoLineActionPresenter extends Presenter {
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.lb_action_2_lines, parent, false);
+ return new ActionViewHolder(v, parent.getLayoutDirection());
+ }
+
+ @Override
+ public void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
+ Action action = (Action) item;
+ ActionViewHolder vh = (ActionViewHolder) viewHolder;
+ Drawable icon = action.getIcon();
+ vh.mAction = action;
+
+ if (icon != null) {
+ final int startPadding = vh.view.getResources()
+ .getDimensionPixelSize(R.dimen.lb_action_with_icon_padding_start);
+ final int endPadding = vh.view.getResources()
+ .getDimensionPixelSize(R.dimen.lb_action_with_icon_padding_end);
+ vh.view.setPaddingRelative(startPadding, 0, endPadding, 0);
+ } else {
+ final int padding = vh.view.getResources()
+ .getDimensionPixelSize(R.dimen.lb_action_padding_horizontal);
+ vh.view.setPaddingRelative(padding, 0, padding, 0);
+ }
+ if (vh.mLayoutDirection == View.LAYOUT_DIRECTION_RTL) {
+ vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, icon, null);
+ } else {
+ vh.mButton.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
+ }
+
+ CharSequence line1 = action.getLabel1();
+ CharSequence line2 = action.getLabel2();
+ if (TextUtils.isEmpty(line1)) {
+ vh.mButton.setText(line2);
+ } else if (TextUtils.isEmpty(line2)) {
+ vh.mButton.setText(line1);
+ } else {
+ vh.mButton.setText(line1 + "\n" + line2);
+ }
+ }
+
+ @Override
+ public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) {
+ ActionViewHolder vh = (ActionViewHolder) viewHolder;
+ vh.mButton.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
+ vh.view.setPadding(0, 0, 0, 0);
+ vh.mAction = null;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java
new file mode 100644
index 00000000..5d8e20ff
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/CurrentRecordingDetailsFragment.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.res.Resources;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.SparseArrayObjectAdapter;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrManager;
+
+/**
+ * {@link RecordingDetailsFragment} for current recording in DVR.
+ */
+public class CurrentRecordingDetailsFragment extends RecordingDetailsFragment {
+ private static final int ACTION_STOP_RECORDING = 1;
+
+ @Override
+ protected SparseArrayObjectAdapter onCreateActionsAdapter() {
+ SparseArrayObjectAdapter adapter =
+ new SparseArrayObjectAdapter(new ActionPresenterSelector());
+ Resources res = getResources();
+ adapter.set(ACTION_STOP_RECORDING, new Action(ACTION_STOP_RECORDING,
+ res.getString(R.string.epg_dvr_dialog_message_stop_recording), null,
+ res.getDrawable(R.drawable.lb_ic_stop)));
+ return adapter;
+ }
+
+ @Override
+ protected OnActionClickedListener onCreateOnActionClickedListener() {
+ return new OnActionClickedListener() {
+ @Override
+ public void onActionClicked(Action action) {
+ if (action.getId() == ACTION_STOP_RECORDING) {
+ DvrManager dvrManager = TvApplication.getSingletons(getActivity())
+ .getDvrManager();
+ dvrManager.stopRecording(getRecording());
+ }
+ getActivity().finish();
+ }
+ };
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DetailsContent.java b/src/com/android/tv/dvr/ui/DetailsContent.java
new file mode 100644
index 00000000..19521fca
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DetailsContent.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.media.tv.TvContract;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.android.tv.data.BaseProgram;
+import com.android.tv.data.Channel;
+
+/**
+ * A class for details content.
+ */
+public class DetailsContent {
+ /** Constant for invalid time. */
+ public static final long INVALID_TIME = -1;
+
+ private CharSequence mTitle;
+ private long mStartTimeUtcMillis;
+ private long mEndTimeUtcMillis;
+ private String mDescription;
+ private String mLogoImageUri;
+ private String mBackgroundImageUri;
+
+ private DetailsContent() { }
+
+ /**
+ * Returns title.
+ */
+ public CharSequence getTitle() {
+ return mTitle;
+ }
+
+ /**
+ * Returns start time.
+ */
+ public long getStartTimeUtcMillis() {
+ return mStartTimeUtcMillis;
+ }
+
+ /**
+ * Returns end time.
+ */
+ public long getEndTimeUtcMillis() {
+ return mEndTimeUtcMillis;
+ }
+
+ /**
+ * Returns description.
+ */
+ public String getDescription() {
+ return mDescription;
+ }
+
+ /**
+ * Returns Logo image URI as a String.
+ */
+ public String getLogoImageUri() {
+ return mLogoImageUri;
+ }
+
+ /**
+ * Returns background image URI as a String.
+ */
+ public String getBackgroundImageUri() {
+ return mBackgroundImageUri;
+ }
+
+ /**
+ * Copies other details content.
+ */
+ public void copyFrom(DetailsContent other) {
+ if (this == other) {
+ return;
+ }
+ mTitle = other.mTitle;
+ mStartTimeUtcMillis = other.mStartTimeUtcMillis;
+ mEndTimeUtcMillis = other.mEndTimeUtcMillis;
+ mDescription = other.mDescription;
+ mLogoImageUri = other.mLogoImageUri;
+ mBackgroundImageUri = other.mBackgroundImageUri;
+ }
+
+ /**
+ * A class for building details content.
+ */
+ public static final class Builder {
+ private final DetailsContent mDetailsContent;
+
+ public Builder() {
+ mDetailsContent = new DetailsContent();
+ mDetailsContent.mStartTimeUtcMillis = INVALID_TIME;
+ mDetailsContent.mEndTimeUtcMillis = INVALID_TIME;
+ }
+
+ /**
+ * Sets title.
+ */
+ public Builder setTitle(CharSequence title) {
+ mDetailsContent.mTitle = title;
+ return this;
+ }
+
+ /**
+ * Sets start time.
+ */
+ public Builder setStartTimeUtcMillis(long startTimeUtcMillis) {
+ mDetailsContent.mStartTimeUtcMillis = startTimeUtcMillis;
+ return this;
+ }
+
+ /**
+ * Sets end time.
+ */
+ public Builder setEndTimeUtcMillis(long endTimeUtcMillis) {
+ mDetailsContent.mEndTimeUtcMillis = endTimeUtcMillis;
+ return this;
+ }
+
+ /**
+ * Sets description.
+ */
+ public Builder setDescription(String description) {
+ mDetailsContent.mDescription = description;
+ return this;
+ }
+
+ /**
+ * Sets logo image URI as a String.
+ */
+ public Builder setLogoImageUri(String logoImageUri) {
+ mDetailsContent.mLogoImageUri = logoImageUri;
+ return this;
+ }
+
+ /**
+ * Sets background image URI as a String.
+ */
+ public Builder setBackgroundImageUri(String backgroundImageUri) {
+ mDetailsContent.mBackgroundImageUri = backgroundImageUri;
+ return this;
+ }
+
+ /**
+ * Sets background image and logo image URI from program and channel.
+ */
+ public Builder setImageUris(@Nullable BaseProgram program, @Nullable Channel channel) {
+ if (program != null) {
+ return setImageUris(program.getPosterArtUri(), program.getThumbnailUri(), channel);
+ } else {
+ return setImageUris(null, null, channel);
+ }
+ }
+
+ /**
+ * Sets background image and logo image URI and channel is used for fallback images.
+ */
+ public Builder setImageUris(@Nullable String posterArtUri,
+ @Nullable String thumbnailUri, @Nullable Channel channel) {
+ mDetailsContent.mLogoImageUri = null;
+ mDetailsContent.mBackgroundImageUri = null;
+ if (!TextUtils.isEmpty(posterArtUri) && !TextUtils.isEmpty(thumbnailUri)) {
+ mDetailsContent.mLogoImageUri = posterArtUri;
+ mDetailsContent.mBackgroundImageUri = thumbnailUri;
+ } else if (!TextUtils.isEmpty(posterArtUri)) {
+ // thumbnailUri is empty
+ mDetailsContent.mLogoImageUri = posterArtUri;
+ mDetailsContent.mBackgroundImageUri = posterArtUri;
+ } else if (!TextUtils.isEmpty(thumbnailUri)) {
+ // posterArtUri is empty
+ mDetailsContent.mLogoImageUri = thumbnailUri;
+ mDetailsContent.mBackgroundImageUri = thumbnailUri;
+ }
+ if (TextUtils.isEmpty(mDetailsContent.mLogoImageUri) && channel != null) {
+ String channelLogoUri = TvContract.buildChannelLogoUri(channel.getId())
+ .toString();
+ mDetailsContent.mLogoImageUri = channelLogoUri;
+ mDetailsContent.mBackgroundImageUri = channelLogoUri;
+ }
+ return this;
+ }
+
+ /**
+ * Builds details content.
+ */
+ public DetailsContent build() {
+ DetailsContent detailsContent = new DetailsContent();
+ detailsContent.copyFrom(mDetailsContent);
+ return detailsContent;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DetailsContentPresenter.java b/src/com/android/tv/dvr/ui/DetailsContentPresenter.java
new file mode 100644
index 00000000..175f05bc
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DetailsContentPresenter.java
@@ -0,0 +1,300 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.graphics.Paint;
+import android.graphics.Paint.FontMetricsInt;
+import android.support.v17.leanback.widget.Presenter;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.tv.R;
+import com.android.tv.ui.ViewUtils;
+import com.android.tv.util.Utils;
+
+/**
+ * An {@link Presenter} for rendering a detailed description of an DVR item.
+ * Typically this Presenter will be used in a {@link DetailsOverviewRowPresenter}.
+ * Most codes of this class is originated from
+ * {@link android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter}.
+ * The latter class are re-used to provide a customized version of
+ * {@link android.support.v17.leanback.widget.DetailsOverviewRow}.
+ */
+public class DetailsContentPresenter extends Presenter {
+ /**
+ * The ViewHolder for the {@link DetailsContentPresenter}.
+ */
+ public static class ViewHolder extends Presenter.ViewHolder {
+ final TextView mTitle;
+ final TextView mSubtitle;
+ final LinearLayout mDescriptionContainer;
+ final TextView mBody;
+ final TextView mReadMoreView;
+ final int mTitleMargin;
+ final int mUnderTitleBaselineMargin;
+ final int mUnderSubtitleBaselineMargin;
+ final int mTitleLineSpacing;
+ final int mBodyLineSpacing;
+ final int mBodyMaxLines;
+ final int mBodyMinLines;
+ final FontMetricsInt mTitleFontMetricsInt;
+ final FontMetricsInt mSubtitleFontMetricsInt;
+ final FontMetricsInt mBodyFontMetricsInt;
+ final int mTitleMaxLines;
+
+ private Activity mActivity;
+ private boolean mFullTextMode;
+ private int mFullTextAnimationDuration;
+ private boolean mIsListeningToPreDraw;
+
+ private ViewTreeObserver.OnPreDrawListener mPreDrawListener =
+ new ViewTreeObserver.OnPreDrawListener() {
+ @Override
+ public boolean onPreDraw() {
+ if (mSubtitle.getVisibility() == View.VISIBLE
+ && mSubtitle.getTop() > view.getHeight()
+ && mTitle.getLineCount() > 1) {
+ mTitle.setMaxLines(mTitle.getLineCount() - 1);
+ return false;
+ }
+ final int bodyLines = mBody.getLineCount();
+ final int maxLines = mFullTextMode ? bodyLines :
+ (mTitle.getLineCount() > 1 ? mBodyMinLines : mBodyMaxLines);
+ if (bodyLines > maxLines) {
+ mReadMoreView.setVisibility(View.VISIBLE);
+ mDescriptionContainer.setFocusable(true);
+ mDescriptionContainer.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mFullTextMode = true;
+ mReadMoreView.setVisibility(View.GONE);
+ mDescriptionContainer.setFocusable(false);
+ mDescriptionContainer.setOnClickListener(null);
+ mBody.setMaxLines(bodyLines);
+ // Minus 1 from line difference to eliminate the space
+ // originally occupied by "READ MORE"
+ showFullText((bodyLines - maxLines - 1) * mBodyLineSpacing);
+ }
+ });
+ }
+ if (mBody.getMaxLines() != maxLines) {
+ mBody.setMaxLines(maxLines);
+ return false;
+ } else {
+ removePreDrawListener();
+ return true;
+ }
+ }
+ };
+
+ public ViewHolder(final View view) {
+ super(view);
+ mTitle = (TextView) view.findViewById(R.id.dvr_details_description_title);
+ mSubtitle = (TextView) view.findViewById(R.id.dvr_details_description_subtitle);
+ mBody = (TextView) view.findViewById(R.id.dvr_details_description_body);
+ mDescriptionContainer =
+ (LinearLayout) view.findViewById(R.id.dvr_details_description_container);
+ mReadMoreView = (TextView) view.findViewById(R.id.dvr_details_description_read_more);
+
+ FontMetricsInt titleFontMetricsInt = getFontMetricsInt(mTitle);
+ final int titleAscent = view.getResources().getDimensionPixelSize(
+ R.dimen.lb_details_description_title_baseline);
+ // Ascent is negative
+ mTitleMargin = titleAscent + titleFontMetricsInt.ascent;
+
+ mUnderTitleBaselineMargin = view.getResources().getDimensionPixelSize(
+ R.dimen.lb_details_description_under_title_baseline_margin);
+ mUnderSubtitleBaselineMargin = view.getResources().getDimensionPixelSize(
+ R.dimen.lb_details_description_under_subtitle_baseline_margin);
+
+ mTitleLineSpacing = view.getResources().getDimensionPixelSize(
+ R.dimen.lb_details_description_title_line_spacing);
+ mBodyLineSpacing = view.getResources().getDimensionPixelSize(
+ R.dimen.lb_details_description_body_line_spacing);
+
+ mBodyMaxLines = view.getResources().getInteger(
+ R.integer.lb_details_description_body_max_lines);
+ mBodyMinLines = view.getResources().getInteger(
+ R.integer.lb_details_description_body_min_lines);
+ mTitleMaxLines = mTitle.getMaxLines();
+
+ mTitleFontMetricsInt = getFontMetricsInt(mTitle);
+ mSubtitleFontMetricsInt = getFontMetricsInt(mSubtitle);
+ mBodyFontMetricsInt = getFontMetricsInt(mBody);
+ }
+
+ void addPreDrawListener() {
+ if (!mIsListeningToPreDraw) {
+ mIsListeningToPreDraw = true;
+ view.getViewTreeObserver().addOnPreDrawListener(mPreDrawListener);
+ }
+ }
+
+ void removePreDrawListener() {
+ if (mIsListeningToPreDraw) {
+ view.getViewTreeObserver().removeOnPreDrawListener(mPreDrawListener);
+ mIsListeningToPreDraw = false;
+ }
+ }
+
+ public TextView getTitle() {
+ return mTitle;
+ }
+
+ public TextView getSubtitle() {
+ return mSubtitle;
+ }
+
+ public TextView getBody() {
+ return mBody;
+ }
+
+ private FontMetricsInt getFontMetricsInt(TextView textView) {
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setTextSize(textView.getTextSize());
+ paint.setTypeface(textView.getTypeface());
+ return paint.getFontMetricsInt();
+ }
+
+ private void showFullText(int heightDiff) {
+ final ViewGroup detailsFrame = (ViewGroup) mActivity.findViewById(R.id.details_frame);
+ int nowHeight = ViewUtils.getLayoutHeight(detailsFrame);
+ Animator expandAnimator = ViewUtils.createHeightAnimator(
+ detailsFrame, nowHeight, nowHeight + heightDiff);
+ expandAnimator.setDuration(mFullTextAnimationDuration);
+ Animator shiftAnimator = ObjectAnimator.ofPropertyValuesHolder(detailsFrame,
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y,
+ 0f, -(heightDiff / 2)));
+ shiftAnimator.setDuration(mFullTextAnimationDuration);
+ AnimatorSet fullTextAnimator = new AnimatorSet();
+ fullTextAnimator.playTogether(expandAnimator, shiftAnimator);
+ fullTextAnimator.start();
+ }
+ }
+
+ private final Activity mActivity;
+ private final int mFullTextAnimationDuration;
+
+ public DetailsContentPresenter(Activity activity) {
+ super();
+ mActivity = activity;
+ mFullTextAnimationDuration = mActivity.getResources()
+ .getInteger(R.integer.dvr_details_full_text_animation_duration);
+ }
+
+ @Override
+ public final ViewHolder onCreateViewHolder(ViewGroup parent) {
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.dvr_details_description, parent, false);
+ return new ViewHolder(v);
+ }
+
+ @Override
+ public final void onBindViewHolder(Presenter.ViewHolder viewHolder, Object item) {
+ final ViewHolder vh = (ViewHolder) viewHolder;
+ final DetailsContent detailsContent = (DetailsContent) item;
+
+ vh.mActivity = mActivity;
+ vh.mFullTextAnimationDuration = mFullTextAnimationDuration;
+
+ boolean hasTitle = true;
+ if (TextUtils.isEmpty(detailsContent.getTitle())) {
+ vh.mTitle.setVisibility(View.GONE);
+ hasTitle = false;
+ } else {
+ vh.mTitle.setText(detailsContent.getTitle());
+ vh.mTitle.setVisibility(View.VISIBLE);
+ vh.mTitle.setLineSpacing(vh.mTitleLineSpacing - vh.mTitle.getLineHeight()
+ + vh.mTitle.getLineSpacingExtra(), vh.mTitle.getLineSpacingMultiplier());
+ vh.mTitle.setMaxLines(vh.mTitleMaxLines);
+ }
+ setTopMargin(vh.mTitle, vh.mTitleMargin);
+
+ boolean hasSubtitle = true;
+ if (detailsContent.getStartTimeUtcMillis() != DetailsContent.INVALID_TIME
+ && detailsContent.getEndTimeUtcMillis() != DetailsContent.INVALID_TIME) {
+ vh.mSubtitle.setText(Utils.getDurationString(viewHolder.view.getContext(),
+ detailsContent.getStartTimeUtcMillis(),
+ detailsContent.getEndTimeUtcMillis(), false));
+ vh.mSubtitle.setVisibility(View.VISIBLE);
+ if (hasTitle) {
+ setTopMargin(vh.mSubtitle, vh.mUnderTitleBaselineMargin
+ + vh.mSubtitleFontMetricsInt.ascent - vh.mTitleFontMetricsInt.descent);
+ } else {
+ setTopMargin(vh.mSubtitle, 0);
+ }
+ } else {
+ vh.mSubtitle.setVisibility(View.GONE);
+ hasSubtitle = false;
+ }
+
+ if (TextUtils.isEmpty(detailsContent.getDescription())) {
+ vh.mBody.setVisibility(View.GONE);
+ } else {
+ vh.mBody.setText(detailsContent.getDescription());
+ vh.mBody.setVisibility(View.VISIBLE);
+ vh.mBody.setLineSpacing(vh.mBodyLineSpacing - vh.mBody.getLineHeight()
+ + vh.mBody.getLineSpacingExtra(), vh.mBody.getLineSpacingMultiplier());
+ if (hasSubtitle) {
+ setTopMargin(vh.mDescriptionContainer, vh.mUnderSubtitleBaselineMargin
+ + vh.mBodyFontMetricsInt.ascent - vh.mSubtitleFontMetricsInt.descent
+ - vh.mBody.getPaddingTop());
+ } else if (hasTitle) {
+ setTopMargin(vh.mDescriptionContainer, vh.mUnderTitleBaselineMargin
+ + vh.mBodyFontMetricsInt.ascent - vh.mTitleFontMetricsInt.descent
+ - vh.mBody.getPaddingTop());
+ } else {
+ setTopMargin(vh.mDescriptionContainer, 0);
+ }
+ }
+ }
+
+ @Override
+ public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { }
+
+ @Override
+ public void onViewAttachedToWindow(Presenter.ViewHolder holder) {
+ // In case predraw listener was removed in detach, make sure
+ // we have the proper layout.
+ ViewHolder vh = (ViewHolder) holder;
+ vh.addPreDrawListener();
+ super.onViewAttachedToWindow(holder);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(Presenter.ViewHolder holder) {
+ ViewHolder vh = (ViewHolder) holder;
+ vh.removePreDrawListener();
+ super.onViewDetachedFromWindow(holder);
+ }
+
+ private void setTopMargin(View view, int topMargin) {
+ ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
+ lp.topMargin = topMargin;
+ view.setLayoutParams(lp);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java b/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java
new file mode 100644
index 00000000..6714ecd3
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DetailsViewBackgroundHelper.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.support.v17.leanback.app.BackgroundManager;
+
+/**
+ * The Background Helper.
+ */
+public class DetailsViewBackgroundHelper {
+ // Background delay serves to avoid kicking off expensive bitmap loading
+ // in case multiple backgrounds are set in quick succession.
+ private static final int SET_BACKGROUND_DELAY_MS = 100;
+
+ private final BackgroundManager mBackgroundManager;
+
+ class LoadBackgroundRunnable implements Runnable {
+ final Drawable mBackGround;
+
+ LoadBackgroundRunnable(Drawable background) {
+ mBackGround = background;
+ }
+
+ @Override
+ public void run() {
+ if (!mBackgroundManager.isAttached()) {
+ return;
+ }
+ if (mBackGround instanceof BitmapDrawable) {
+ mBackgroundManager.setBitmap(((BitmapDrawable) mBackGround).getBitmap());
+ }
+ mRunnable = null;
+ }
+ }
+
+ private LoadBackgroundRunnable mRunnable;
+
+ private final Handler mHandler = new Handler();
+
+ public DetailsViewBackgroundHelper(Activity activity) {
+ mBackgroundManager = BackgroundManager.getInstance(activity);
+ mBackgroundManager.attach(activity.getWindow());
+ }
+
+ /**
+ * Sets the given image to background.
+ */
+ public void setBackground(Drawable background) {
+ if (mRunnable != null) {
+ mHandler.removeCallbacks(mRunnable);
+ }
+ mRunnable = new LoadBackgroundRunnable(background);
+ mHandler.postDelayed(mRunnable, SET_BACKGROUND_DELAY_MS);
+ }
+
+ /**
+ * Sets the background color.
+ */
+ public void setBackgroundColor(int color) {
+ if (mBackgroundManager.isAttached()) {
+ mBackgroundManager.setColor(color);
+ }
+ }
+
+ /**
+ * Sets the background scrim.
+ */
+ public void setScrim(int color) {
+ if (mBackgroundManager.isAttached()) {
+ mBackgroundManager.setDimLayer(new ColorDrawable(color));
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrActivity.java b/src/com/android/tv/dvr/ui/DvrActivity.java
index 01f3fb9c..45fb1cf1 100644
--- a/src/com/android/tv/dvr/ui/DvrActivity.java
+++ b/src/com/android/tv/dvr/ui/DvrActivity.java
@@ -20,6 +20,7 @@ import android.app.Activity;
import android.os.Bundle;
import com.android.tv.R;
+import com.android.tv.TvApplication;
/**
* {@link android.app.Activity} for DVR UI.
@@ -27,6 +28,7 @@ import com.android.tv.R;
public class DvrActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
super.onCreate(savedInstanceState);
setContentView(R.layout.dvr_main);
}
diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java
new file mode 100644
index 00000000..9df228d1
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrAlreadyRecordedFragment.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.util.Utils;
+
+import java.util.List;
+
+/**
+ * A fragment which notifies the user that the same episode has already been scheduled.
+ *
+ * <p>Note that the schedule has not been created yet.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrAlreadyRecordedFragment extends DvrGuidedStepFragment {
+ private static final int ACTION_RECORD_ANYWAY = 1;
+ private static final int ACTION_WATCH = 2;
+ private static final int ACTION_CANCEL = 3;
+
+ private Program mProgram;
+ private RecordedProgram mDuplicate;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM);
+ DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager();
+ mDuplicate = dvrManager.getRecordedProgram(mProgram.getTitle(),
+ mProgram.getSeasonNumber(), mProgram.getEpisodeNumber());
+ if (mDuplicate == null) {
+ dvrManager.addSchedule(mProgram);
+ DvrUiHelper.showAddScheduleToast(context, mProgram.getTitle(),
+ mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis());
+ dismissDialog();
+ }
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_already_recorded_dialog_title);
+ String description = getString(R.string.dvr_already_recorded_dialog_description);
+ Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null);
+ return new Guidance(title, description, null, image);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Context context = getContext();
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_RECORD_ANYWAY)
+ .title(R.string.dvr_action_record_anyway)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_WATCH)
+ .title(R.string.dvr_action_watch_now)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_CANCEL)
+ .title(R.string.dvr_action_record_cancel)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_RECORD_ANYWAY) {
+ getDvrManager().addSchedule(mProgram);
+ } else if (action.getId() == ACTION_WATCH) {
+ DvrUiHelper.startDetailsActivity(getActivity(), mDuplicate, null, false);
+ }
+ dismissDialog();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java
new file mode 100644
index 00000000..78f21784
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrAlreadyScheduledFragment.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.text.format.DateUtils;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.util.Utils;
+
+import java.util.List;
+
+/**
+ * A fragment which notifies the user that the same episode has already been scheduled.
+ *
+ * <p>Note that the schedule has not been created yet.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrAlreadyScheduledFragment extends DvrGuidedStepFragment {
+ private static final int ACTION_RECORD_ANYWAY = 1;
+ private static final int ACTION_RECORD_INSTEAD = 2;
+ private static final int ACTION_CANCEL = 3;
+
+ private Program mProgram;
+ private ScheduledRecording mDuplicate;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mProgram = getArguments().getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM);
+ DvrManager dvrManager = TvApplication.getSingletons(context).getDvrManager();
+ mDuplicate = dvrManager.getScheduledRecording(mProgram.getTitle(),
+ mProgram.getSeasonNumber(), mProgram.getEpisodeNumber());
+ if (mDuplicate == null) {
+ dvrManager.addSchedule(mProgram);
+ DvrUiHelper.showAddScheduleToast(context, mProgram.getTitle(),
+ mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis());
+ dismissDialog();
+ }
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_already_scheduled_dialog_title);
+ String description = getString(R.string.dvr_already_scheduled_dialog_description,
+ DateUtils.formatDateTime(getContext(), mDuplicate.getStartTimeMs(),
+ DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_DATE));
+ Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null);
+ return new Guidance(title, description, null, image);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Context context = getContext();
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_RECORD_ANYWAY)
+ .title(R.string.dvr_action_record_anyway)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_RECORD_INSTEAD)
+ .title(R.string.dvr_action_record_instead)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_CANCEL)
+ .title(R.string.dvr_action_record_cancel)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_RECORD_ANYWAY) {
+ getDvrManager().addSchedule(mProgram);
+ } else if (action.getId() == ACTION_RECORD_INSTEAD) {
+ getDvrManager().addSchedule(mProgram);
+ getDvrManager().removeScheduledRecording(mDuplicate);
+ }
+ dismissDialog();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java b/src/com/android/tv/dvr/ui/DvrBrowseFragment.java
index 70e71cab..a6dd31d1 100644
--- a/src/com/android/tv/dvr/ui/DvrBrowseFragment.java
+++ b/src/com/android/tv/dvr/ui/DvrBrowseFragment.java
@@ -16,140 +16,586 @@
package com.android.tv.dvr.ui;
+import android.content.Context;
+import android.media.tv.TvInputManager.TvInputCallback;
import android.os.Bundle;
-import android.support.annotation.IntDef;
+import android.os.Handler;
import android.support.v17.leanback.app.BrowseFragment;
import android.support.v17.leanback.widget.ArrayObjectAdapter;
import android.support.v17.leanback.widget.ClassPresenterSelector;
import android.support.v17.leanback.widget.HeaderItem;
import android.support.v17.leanback.widget.ListRow;
import android.support.v17.leanback.widget.ListRowPresenter;
-import android.support.v17.leanback.widget.ObjectAdapter;
+import android.support.v17.leanback.widget.Presenter;
+import android.support.v17.leanback.widget.TitleViewAdapter;
+import android.text.TextUtils;
import android.util.Log;
+import com.android.tv.ApplicationSingletons;
import com.android.tv.R;
import com.android.tv.TvApplication;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.data.GenreItems;
import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.OnRecordedProgramLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.RecordedProgramListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.RecordedProgram;
import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.util.LinkedHashMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
/**
* {@link BrowseFragment} for DVR functions.
*/
-public class DvrBrowseFragment extends BrowseFragment {
+public class DvrBrowseFragment extends BrowseFragment implements
+ RecordedProgramListener, ScheduledRecordingListener, SeriesRecordingListener,
+ OnDvrScheduleLoadFinishedListener, OnRecordedProgramLoadFinishedListener {
private static final String TAG = "DvrBrowseFragment";
private static final boolean DEBUG = false;
- private ScheduledRecordingsAdapter mRecordingsInProgressAdapter;
- private ScheduledRecordingsAdapter mRecordingsNotStatedAdapter;
- private RecordedProgramsAdapter mRecordedProgramsAdapter;
-
- @IntDef({DVR_CURRENT_RECORDINGS, DVR_SCHEDULED_RECORDINGS, DVR_RECORDED_PROGRAMS, DVR_SETTINGS})
- @Retention(RetentionPolicy.SOURCE)
- public @interface DVR_HEADERS_MODE {}
- public static final int DVR_CURRENT_RECORDINGS = 0;
- public static final int DVR_SCHEDULED_RECORDINGS = 1;
- public static final int DVR_RECORDED_PROGRAMS = 2;
- public static final int DVR_SETTINGS = 3;
-
- private static final LinkedHashMap<Integer, Integer> sHeaders =
- new LinkedHashMap<Integer, Integer>() {{
- put(DVR_CURRENT_RECORDINGS, R.string.dvr_main_current_recordings);
- put(DVR_SCHEDULED_RECORDINGS, R.string.dvr_main_scheduled_recordings);
- put(DVR_RECORDED_PROGRAMS, R.string.dvr_main_recorded_programs);
- /* put(DVR_SETTINGS, R.string.dvr_main_settings); */ // TODO: Temporarily remove it for DP.
- }};
+ private static final int MAX_RECENT_ITEM_COUNT = 10;
+ private static final int MAX_SCHEDULED_ITEM_COUNT = 4;
+ private RecordedProgramAdapter mRecentAdapter;
+ private ScheduleAdapter mScheduleAdapter;
+ private SeriesAdapter mSeriesAdapter;
+ private RecordedProgramAdapter[] mGenreAdapters =
+ new RecordedProgramAdapter[GenreItems.getGenreCount() + 1];
+ private ListRow mRecentRow;
+ private ListRow mSeriesRow;
+ private ListRow[] mGenreRows = new ListRow[GenreItems.getGenreCount() + 1];
+ private List<String> mGenreLabels;
private DvrDataManager mDvrDataManager;
+ private DvrScheduleManager mDvrScheudleManager;
private ArrayObjectAdapter mRowsAdapter;
+ private ClassPresenterSelector mPresenterSelector;
+ private final HashMap<String, RecordedProgram> mSeriesId2LatestProgram = new HashMap<>();
+ private final Handler mHandler = new Handler();
+
+ private final Comparator<Object> RECORDED_PROGRAM_COMPARATOR = new Comparator<Object>() {
+ @Override
+ public int compare(Object lhs, Object rhs) {
+ if (lhs instanceof SeriesRecording) {
+ lhs = mSeriesId2LatestProgram.get(((SeriesRecording) lhs).getSeriesId());
+ }
+ if (rhs instanceof SeriesRecording) {
+ rhs = mSeriesId2LatestProgram.get(((SeriesRecording) rhs).getSeriesId());
+ }
+ if (lhs instanceof RecordedProgram) {
+ if (rhs instanceof RecordedProgram) {
+ return RecordedProgram.START_TIME_THEN_ID_COMPARATOR.reversed()
+ .compare((RecordedProgram) lhs, (RecordedProgram) rhs);
+ } else {
+ return -1;
+ }
+ } else if (rhs instanceof RecordedProgram) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ };
+
+ private final Comparator<Object> SCHEDULE_COMPARATOR = new Comparator<Object>() {
+ @Override
+ public int compare(Object lhs, Object rhs) {
+ if (lhs instanceof ScheduledRecording) {
+ if (rhs instanceof ScheduledRecording) {
+ return ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR
+ .compare((ScheduledRecording) lhs, (ScheduledRecording) rhs);
+ } else {
+ return -1;
+ }
+ } else if (rhs instanceof ScheduledRecording) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ };
+
+ private final DvrScheduleManager.OnConflictStateChangeListener mOnConflictStateChangeListener =
+ new DvrScheduleManager.OnConflictStateChangeListener() {
+ @Override
+ public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) {
+ if (mScheduleAdapter != null) {
+ for (ScheduledRecording schedule : schedules) {
+ onScheduledRecordingStatusChanged(schedule);
+ }
+ }
+ }
+ };
+
+ private final Runnable mUpdateRowsRunnable = new Runnable() {
+ @Override
+ public void run() {
+ updateRows();
+ }
+ };
@Override
public void onCreate(Bundle savedInstanceState) {
if (DEBUG) Log.d(TAG, "onCreate");
super.onCreate(savedInstanceState);
- mDvrDataManager = TvApplication.getSingletons(getContext()).getDvrDataManager();
+ Context context = getContext();
+ ApplicationSingletons singletons = TvApplication.getSingletons(context);
+ mDvrDataManager = singletons.getDvrDataManager();
+ mDvrScheudleManager = singletons.getDvrScheduleManager();
+ mPresenterSelector = new ClassPresenterSelector()
+ .addClassPresenter(ScheduledRecording.class,
+ new ScheduledRecordingPresenter(context))
+ .addClassPresenter(RecordedProgram.class, new RecordedProgramPresenter(context))
+ .addClassPresenter(SeriesRecording.class, new SeriesRecordingPresenter(context))
+ .addClassPresenter(FullScheduleCardHolder.class, new FullSchedulesCardPresenter());
+ mGenreLabels = new ArrayList<>(Arrays.asList(GenreItems.getLabels(context)));
+ mGenreLabels.add(getString(R.string.dvr_main_others));
setupUiElements();
setupAdapters();
- mRecordingsInProgressAdapter.start();
- mRecordingsNotStatedAdapter.start();
- mRecordedProgramsAdapter.start();
- initRows();
+ mDvrScheudleManager.addOnConflictStateChangeListener(mOnConflictStateChangeListener);
prepareEntranceTransition();
- startEntranceTransition();
- }
-
- @Override
- public void onStart() {
- if (DEBUG) Log.d(TAG, "onStart");
- super.onStart();
- // TODO: It's a workaround for a bug that a progress bar isn't hidden.
- // We need to remove it later.
- getProgressBarManager().disableProgressBar();
+ if (mDvrDataManager.isInitialized()) {
+ startEntranceTransition();
+ } else {
+ if (!mDvrDataManager.isDvrScheduleLoadFinished()) {
+ mDvrDataManager.addDvrScheduleLoadFinishedListener(this);
+ }
+ if (!mDvrDataManager.isRecordedProgramLoadFinished()) {
+ mDvrDataManager.addRecordedProgramLoadFinishedListener(this);
+ }
+ }
}
@Override
public void onDestroy() {
if (DEBUG) Log.d(TAG, "onDestroy");
- mRecordingsInProgressAdapter.stop();
- mRecordingsNotStatedAdapter.stop();
- mRecordedProgramsAdapter.stop();
+ mHandler.removeCallbacks(mUpdateRowsRunnable);
+ mDvrScheudleManager.removeOnConflictStateChangeListener(mOnConflictStateChangeListener);
+ mDvrDataManager.removeRecordedProgramListener(this);
+ mDvrDataManager.removeScheduledRecordingListener(this);
+ mDvrDataManager.removeSeriesRecordingListener(this);
+ mDvrDataManager.removeDvrScheduleLoadFinishedListener(this);
+ mDvrDataManager.removeRecordedProgramLoadFinishedListener(this);
+ mRowsAdapter.clear();
+ mSeriesId2LatestProgram.clear();
+ for (Presenter presenter : mPresenterSelector.getPresenters()) {
+ if (presenter instanceof DvrItemPresenter) {
+ ((DvrItemPresenter) presenter).unbindAllViewHolders();
+ }
+ }
super.onDestroy();
}
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ List<ScheduledRecording> scheduledRecordings = mDvrDataManager.getAllScheduledRecordings();
+ onScheduledRecordingAdded(ScheduledRecording.toArray(scheduledRecordings));
+ List<SeriesRecording> seriesRecordings = mDvrDataManager.getSeriesRecordings();
+ onSeriesRecordingAdded(SeriesRecording.toArray(seriesRecordings));
+ if (mDvrDataManager.isInitialized()) {
+ startEntranceTransition();
+ }
+ mDvrDataManager.removeDvrScheduleLoadFinishedListener(this);
+ }
+
+ @Override
+ public void onRecordedProgramLoadFinished() {
+ for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) {
+ handleRecordedProgramAdded(recordedProgram, true);
+ }
+ updateRows();
+ if (mDvrDataManager.isInitialized()) {
+ startEntranceTransition();
+ }
+ mDvrDataManager.removeRecordedProgramLoadFinishedListener(this);
+ }
+
+ @Override
+ public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ handleRecordedProgramAdded(recordedProgram, true);
+ }
+ postUpdateRows();
+ }
+
+ @Override
+ public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ handleRecordedProgramChanged(recordedProgram);
+ }
+ postUpdateRows();
+ }
+
+ @Override
+ public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ handleRecordedProgramRemoved(recordedProgram);
+ }
+ postUpdateRows();
+ }
+
+ // No need to call updateRows() during ScheduledRecordings' change because
+ // the row for ScheduledRecordings is always displayed.
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording scheduleRecording : scheduledRecordings) {
+ if (needToShowScheduledRecording(scheduleRecording)) {
+ mScheduleAdapter.add(scheduleRecording);
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording scheduleRecording : scheduledRecordings) {
+ mScheduleAdapter.remove(scheduleRecording);
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording scheduleRecording : scheduledRecordings) {
+ if (needToShowScheduledRecording(scheduleRecording)) {
+ mScheduleAdapter.change(scheduleRecording);
+ } else {
+ mScheduleAdapter.removeWithId(scheduleRecording);
+ }
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) {
+ handleSeriesRecordingsAdded(Arrays.asList(seriesRecordings));
+ postUpdateRows();
+ }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ handleSeriesRecordingsRemoved(Arrays.asList(seriesRecordings));
+ postUpdateRows();
+ }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ handleSeriesRecordingsChanged(Arrays.asList(seriesRecordings));
+ postUpdateRows();
+ }
+
+ // Workaround of b/29108300
+ @Override
+ public void showTitle(int flags) {
+ flags &= ~TitleViewAdapter.SEARCH_VIEW_VISIBLE;
+ super.showTitle(flags);
+ }
+
private void setupUiElements() {
+ setBadgeDrawable(getActivity().getDrawable(R.drawable.ic_dvr_badge));
setHeadersState(HEADERS_ENABLED);
setHeadersTransitionOnBackEnabled(false);
+ setBrandColor(getResources().getColor(R.color.program_guide_side_panel_background, null));
}
private void setupAdapters() {
+ mRecentAdapter = new RecordedProgramAdapter(MAX_RECENT_ITEM_COUNT);
+ mScheduleAdapter = new ScheduleAdapter(MAX_SCHEDULED_ITEM_COUNT);
+ mSeriesAdapter = new SeriesAdapter();
+ for (int i = 0; i < mGenreAdapters.length; i++) {
+ mGenreAdapters[i] = new RecordedProgramAdapter();
+ }
+ // Schedule Recordings.
+ List<ScheduledRecording> schedules = mDvrDataManager.getAllScheduledRecordings();
+ onScheduledRecordingAdded(ScheduledRecording.toArray(schedules));
+ mScheduleAdapter.addExtraItem(FullScheduleCardHolder.FULL_SCHEDULE_CARD_HOLDER);
+ // Recorded Programs.
+ for (RecordedProgram recordedProgram : mDvrDataManager.getRecordedPrograms()) {
+ handleRecordedProgramAdded(recordedProgram, false);
+ }
+ // Series Recordings. Series recordings should be added after recorded programs, because
+ // we build series recordings' latest program information while adding recorded programs.
+ List<SeriesRecording> recordings = mDvrDataManager.getSeriesRecordings();
+ handleSeriesRecordingsAdded(recordings);
mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter());
+ mRecentRow = new ListRow(new HeaderItem(
+ getString(R.string.dvr_main_recent)), mRecentAdapter);
+ mRowsAdapter.add(new ListRow(new HeaderItem(
+ getString(R.string.dvr_main_scheduled)), mScheduleAdapter));
+ mSeriesRow = new ListRow(new HeaderItem(
+ getString(R.string.dvr_main_series)), mSeriesAdapter);
+ updateRows();
+ mDvrDataManager.addRecordedProgramListener(this);
+ mDvrDataManager.addScheduledRecordingListener(this);
+ mDvrDataManager.addSeriesRecordingListener(this);
setAdapter(mRowsAdapter);
- ClassPresenterSelector presenterSelector = new ClassPresenterSelector();
- EmptyItemPresenter emptyItemPresenter = new EmptyItemPresenter(this);
- ScheduledRecordingPresenter scheduledRecordingPresenter = new ScheduledRecordingPresenter(
- getContext());
- RecordedProgramPresenter recordedProgramPresenter = new RecordedProgramPresenter(
- getContext());
- presenterSelector.addClassPresenter(ScheduledRecording.class, scheduledRecordingPresenter);
- presenterSelector.addClassPresenter(RecordedProgram.class, recordedProgramPresenter);
- presenterSelector.addClassPresenter(EmptyHolder.class, emptyItemPresenter);
- mRecordingsInProgressAdapter = new ScheduledRecordingsAdapter(mDvrDataManager,
- ScheduledRecording.STATE_RECORDING_IN_PROGRESS, presenterSelector);
- mRecordingsNotStatedAdapter = new ScheduledRecordingsAdapter(mDvrDataManager,
- ScheduledRecording.STATE_RECORDING_NOT_STARTED, presenterSelector);
- mRecordedProgramsAdapter = new RecordedProgramsAdapter(mDvrDataManager, presenterSelector);
- }
-
- private void initRows() {
- mRowsAdapter.clear();
- for (@DVR_HEADERS_MODE int i : sHeaders.keySet()) {
- HeaderItem gridHeader = new HeaderItem(i, getContext().getString(sHeaders.get(i)));
- ObjectAdapter gridRowAdapter = null;
- switch (i) {
- case DVR_CURRENT_RECORDINGS: {
- gridRowAdapter = mRecordingsInProgressAdapter;
- break;
+ }
+
+ private void handleRecordedProgramAdded(RecordedProgram recordedProgram,
+ boolean updateSeriesRecording) {
+ mRecentAdapter.add(recordedProgram);
+ String seriesId = recordedProgram.getSeriesId();
+ SeriesRecording seriesRecording = null;
+ if (seriesId != null) {
+ seriesRecording = mDvrDataManager.getSeriesRecording(seriesId);
+ RecordedProgram latestProgram = mSeriesId2LatestProgram.get(seriesId);
+ if (latestProgram == null || RecordedProgram.START_TIME_THEN_ID_COMPARATOR
+ .compare(latestProgram, recordedProgram) < 0) {
+ mSeriesId2LatestProgram.put(seriesId, recordedProgram);
+ if (updateSeriesRecording && seriesRecording != null) {
+ onSeriesRecordingChanged(seriesRecording);
}
- case DVR_SCHEDULED_RECORDINGS: {
- gridRowAdapter = mRecordingsNotStatedAdapter;
+ }
+ }
+ if (seriesRecording == null) {
+ for (RecordedProgramAdapter adapter
+ : getGenreAdapters(recordedProgram.getCanonicalGenres())) {
+ adapter.add(recordedProgram);
+ }
+ }
+ }
+
+ private void handleRecordedProgramRemoved(RecordedProgram recordedProgram) {
+ mRecentAdapter.remove(recordedProgram);
+ String seriesId = recordedProgram.getSeriesId();
+ if (seriesId != null) {
+ SeriesRecording seriesRecording = mDvrDataManager.getSeriesRecording(seriesId);
+ RecordedProgram latestProgram =
+ mSeriesId2LatestProgram.get(recordedProgram.getSeriesId());
+ if (latestProgram != null && latestProgram.getId() == recordedProgram.getId()) {
+ if (seriesRecording != null) {
+ updateLatestRecordedProgram(seriesRecording);
+ onSeriesRecordingChanged(seriesRecording);
+ }
+ }
+ }
+ for (RecordedProgramAdapter adapter
+ : getGenreAdapters(recordedProgram.getCanonicalGenres())) {
+ adapter.remove(recordedProgram);
+ }
+ }
+
+ private void handleRecordedProgramChanged(RecordedProgram recordedProgram) {
+ mRecentAdapter.change(recordedProgram);
+ String seriesId = recordedProgram.getSeriesId();
+ SeriesRecording seriesRecording = null;
+ if (seriesId != null) {
+ seriesRecording = mDvrDataManager.getSeriesRecording(seriesId);
+ RecordedProgram latestProgram = mSeriesId2LatestProgram.get(seriesId);
+ if (latestProgram == null || RecordedProgram.START_TIME_THEN_ID_COMPARATOR
+ .compare(latestProgram, recordedProgram) <= 0) {
+ mSeriesId2LatestProgram.put(seriesId, recordedProgram);
+ if (seriesRecording != null) {
+ onSeriesRecordingChanged(seriesRecording);
+ }
+ } else if (latestProgram.getId() == recordedProgram.getId()) {
+ if (seriesRecording != null) {
+ updateLatestRecordedProgram(seriesRecording);
+ onSeriesRecordingChanged(seriesRecording);
+ }
+ }
+ }
+ if (seriesRecording == null) {
+ updateGenreAdapters(getGenreAdapters(
+ recordedProgram.getCanonicalGenres()), recordedProgram);
+ } else {
+ updateGenreAdapters(new ArrayList<>(), recordedProgram);
+ }
+ }
+
+ private void handleSeriesRecordingsAdded(List<SeriesRecording> seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ mSeriesAdapter.add(seriesRecording);
+ if (mSeriesId2LatestProgram.get(seriesRecording.getSeriesId()) != null) {
+ for (RecordedProgramAdapter adapter
+ : getGenreAdapters(seriesRecording.getCanonicalGenreIds())) {
+ adapter.add(seriesRecording);
}
- break;
- case DVR_RECORDED_PROGRAMS: {
- gridRowAdapter = mRecordedProgramsAdapter;
+ }
+ }
+ }
+
+ private void handleSeriesRecordingsRemoved(List<SeriesRecording> seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ mSeriesAdapter.remove(seriesRecording);
+ for (RecordedProgramAdapter adapter
+ : getGenreAdapters(seriesRecording.getCanonicalGenreIds())) {
+ adapter.remove(seriesRecording);
+ }
+ }
+ }
+
+ private void handleSeriesRecordingsChanged(List<SeriesRecording> seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ mSeriesAdapter.change(seriesRecording);
+ if (mSeriesId2LatestProgram.get(seriesRecording.getSeriesId()) != null) {
+ updateGenreAdapters(getGenreAdapters(
+ seriesRecording.getCanonicalGenreIds()), seriesRecording);
+ } else {
+ // Remove series recording from all genre rows if it has no recorded program
+ updateGenreAdapters(new ArrayList<>(), seriesRecording);
+ }
+ }
+ }
+
+ private List<RecordedProgramAdapter> getGenreAdapters(String[] genres) {
+ List<RecordedProgramAdapter> result = new ArrayList<>();
+ if (genres == null || genres.length == 0) {
+ result.add(mGenreAdapters[mGenreAdapters.length - 1]);
+ } else {
+ for (String genre : genres) {
+ int genreId = GenreItems.getId(genre);
+ if(genreId >= mGenreAdapters.length) {
+ Log.d(TAG, "Wrong Genre ID: " + genreId);
+ } else {
+ result.add(mGenreAdapters[genreId]);
+ }
+ }
+ }
+ return result;
+ }
+
+ private List<RecordedProgramAdapter> getGenreAdapters(int[] genreIds) {
+ List<RecordedProgramAdapter> result = new ArrayList<>();
+ if (genreIds == null || genreIds.length == 0) {
+ result.add(mGenreAdapters[mGenreAdapters.length - 1]);
+ } else {
+ for (int genreId : genreIds) {
+ if(genreId >= mGenreAdapters.length) {
+ Log.d(TAG, "Wrong Genre ID: " + genreId);
+ } else {
+ result.add(mGenreAdapters[genreId]);
+ }
+ }
+ }
+ return result;
+ }
+
+ private void updateGenreAdapters(List<RecordedProgramAdapter> adapters, Object r) {
+ for (RecordedProgramAdapter adapter : mGenreAdapters) {
+ if (adapters.contains(adapter)) {
+ adapter.change(r);
+ } else {
+ adapter.remove(r);
+ }
+ }
+ }
+
+ private void postUpdateRows() {
+ mHandler.removeCallbacks(mUpdateRowsRunnable);
+ mHandler.post(mUpdateRowsRunnable);
+ }
+
+ private void updateRows() {
+ int visibleRowsCount = 1; // Schedule's Row will never be empty
+ if (mRecentAdapter.isEmpty()) {
+ mRowsAdapter.remove(mRecentRow);
+ } else {
+ if (mRowsAdapter.indexOf(mRecentRow) < 0) {
+ mRowsAdapter.add(0, mRecentRow);
+ }
+ visibleRowsCount++;
+ }
+ if (mSeriesAdapter.isEmpty()) {
+ mRowsAdapter.remove(mSeriesRow);
+ } else {
+ if (mRowsAdapter.indexOf(mSeriesRow) < 0) {
+ mRowsAdapter.add(visibleRowsCount, mSeriesRow);
+ }
+ visibleRowsCount++;
+ }
+ for (int i = 0; i < mGenreAdapters.length; i++) {
+ RecordedProgramAdapter adapter = mGenreAdapters[i];
+ if (adapter != null) {
+ if (adapter.isEmpty()) {
+ mRowsAdapter.remove(mGenreRows[i]);
+ } else {
+ if (mGenreRows[i] == null || mRowsAdapter.indexOf(mGenreRows[i]) < 0) {
+ mGenreRows[i] = new ListRow(new HeaderItem(mGenreLabels.get(i)), adapter);
+ mRowsAdapter.add(visibleRowsCount, mGenreRows[i]);
+ }
+ visibleRowsCount++;
}
- break;
- case DVR_SETTINGS:
- gridRowAdapter = new ArrayObjectAdapter(new EmptyItemPresenter(this));
- // TODO: provide setup rows.
- break;
}
- if (gridRowAdapter != null) {
- mRowsAdapter.add(new ListRow(gridHeader, gridRowAdapter));
+ }
+ }
+
+ private boolean needToShowScheduledRecording(ScheduledRecording recording) {
+ int state = recording.getState();
+ return state == ScheduledRecording.STATE_RECORDING_IN_PROGRESS
+ || state == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
+ }
+
+ private void updateLatestRecordedProgram(SeriesRecording seriesRecording) {
+ RecordedProgram latestProgram = null;
+ for (RecordedProgram program :
+ mDvrDataManager.getRecordedPrograms(seriesRecording.getId())) {
+ if (latestProgram == null || RecordedProgram
+ .START_TIME_THEN_ID_COMPARATOR.compare(latestProgram, program) < 0) {
+ latestProgram = program;
+ }
+ }
+ mSeriesId2LatestProgram.put(seriesRecording.getSeriesId(), latestProgram);
+ }
+
+ private class ScheduleAdapter extends SortedArrayAdapter<Object> {
+ ScheduleAdapter(int maxItemCount) {
+ super(mPresenterSelector, SCHEDULE_COMPARATOR, maxItemCount);
+ }
+
+ @Override
+ public long getId(Object item) {
+ if (item instanceof ScheduledRecording) {
+ return ((ScheduledRecording) item).getId();
+ } else {
+ return -1;
+ }
+ }
+ }
+
+ private class SeriesAdapter extends SortedArrayAdapter<SeriesRecording> {
+ SeriesAdapter() {
+ super(mPresenterSelector, new Comparator<SeriesRecording>() {
+ @Override
+ public int compare(SeriesRecording lhs, SeriesRecording rhs) {
+ if (lhs.isStopped() && !rhs.isStopped()) {
+ return 1;
+ } else if (!lhs.isStopped() && rhs.isStopped()) {
+ return -1;
+ }
+ return SeriesRecording.PRIORITY_COMPARATOR.compare(lhs, rhs);
+ }
+ });
+ }
+
+ @Override
+ public long getId(SeriesRecording item) {
+ return item.getId();
+ }
+ }
+
+ private class RecordedProgramAdapter extends SortedArrayAdapter<Object> {
+ RecordedProgramAdapter() {
+ this(Integer.MAX_VALUE);
+ }
+
+ RecordedProgramAdapter(int maxItemCount) {
+ super(mPresenterSelector, RECORDED_PROGRAM_COMPARATOR, maxItemCount);
+ }
+
+ @Override
+ public long getId(Object item) {
+ if (item instanceof SeriesRecording) {
+ return ((SeriesRecording) item).getId();
+ } else if (item instanceof RecordedProgram) {
+ return ((RecordedProgram) item).getId();
+ } else {
+ return -1;
}
}
}
-}
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java
new file mode 100644
index 00000000..837d8ab2
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrChannelRecordDurationOptionFragment.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelRecordConflictFragment;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class DvrChannelRecordDurationOptionFragment extends DvrGuidedStepFragment {
+ private final List<Long> mDurations = new ArrayList<>();
+ private Channel mChannel;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID);
+ mChannel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(channelId);
+ }
+ SoftPreconditions.checkArgument(mChannel != null);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_channel_record_duration_dialog_title);
+ Drawable icon = getResources().getDrawable(R.drawable.ic_dvr, null);
+ return new Guidance(title, null, null, icon);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ int actionId = -1;
+ mDurations.clear();
+ mDurations.add(TimeUnit.MINUTES.toMillis(10));
+ mDurations.add(TimeUnit.MINUTES.toMillis(30));
+ mDurations.add(TimeUnit.HOURS.toMillis(1));
+ mDurations.add(TimeUnit.HOURS.toMillis(3));
+
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(++actionId)
+ .title(R.string.recording_start_dialog_10_min_duration)
+ .build());
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(++actionId)
+ .title(R.string.recording_start_dialog_30_min_duration)
+ .build());
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(++actionId)
+ .title(R.string.recording_start_dialog_1_hour_duration)
+ .build());
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(++actionId)
+ .title(R.string.recording_start_dialog_3_hours_duration)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ long duration = mDurations.get((int) action.getId());
+ long startTimeMs = System.currentTimeMillis();
+ long endTimeMs = System.currentTimeMillis() + duration;
+ List<ScheduledRecording> conflicts = dvrManager.getConflictingSchedules(
+ mChannel.getId(), startTimeMs, endTimeMs);
+ dvrManager.addSchedule(mChannel, startTimeMs, endTimeMs);
+ if (conflicts.isEmpty()) {
+ dismissDialog();
+ } else {
+ GuidedStepFragment fragment = new DvrChannelRecordConflictFragment();
+ Bundle args = new Bundle();
+ args.putLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID, mChannel.getId());
+ args.putLong(DvrHalfSizedDialogFragment.KEY_START_TIME_MS, startTimeMs);
+ args.putLong(DvrHalfSizedDialogFragment.KEY_END_TIME_MS, endTimeMs);
+ fragment.setArguments(args);
+ GuidedStepFragment.add(getFragmentManager(), fragment,
+ R.id.halfsized_dialog_host);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrConflictFragment.java b/src/com/android/tv/dvr/ui/DvrConflictFragment.java
new file mode 100644
index 00000000..e7be4d0a
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrConflictFragment.java
@@ -0,0 +1,339 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.graphics.drawable.Drawable;
+import android.media.tv.TvInputInfo;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.MainActivity;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.ConflictChecker;
+import com.android.tv.dvr.ConflictChecker.OnUpcomingConflictChangeListener;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.util.Utils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+public abstract class DvrConflictFragment extends DvrGuidedStepFragment {
+ private static final String TAG = "DvrConflictFragment";
+ private static final boolean DEBUG = false;
+
+ private static final int ACTION_DELETE_CONFLICT = 1;
+ private static final int ACTION_CANCEL = 2;
+ private static final int ACTION_VIEW_SCHEDULES = 3;
+
+ // The program count which will be listed in the description. This is the number of the
+ // program strings in R.plurals.dvr_program_conflict_dialog_description_many.
+ private static final int LISTED_PROGRAM_COUNT = 2;
+
+ protected List<ScheduledRecording> mConflicts;
+
+ void setConflicts(List<ScheduledRecording> conflicts) {
+ mConflicts = conflicts;
+ }
+
+ List<ScheduledRecording> getConflicts() {
+ return mConflicts;
+ }
+
+ @Override
+ public int onProvideTheme() {
+ return R.style.Theme_TV_Dvr_Conflict_GuidedStep;
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions,
+ Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getContext())
+ .clickAction(GuidedAction.ACTION_ID_OK)
+ .build());
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(ACTION_VIEW_SCHEDULES)
+ .title(R.string.dvr_action_view_schedules)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_VIEW_SCHEDULES) {
+ DvrUiHelper.startSchedulesActivityForOneTimeRecordingConflict(
+ getContext(), getConflicts());
+ }
+ dismissDialog();
+ }
+
+ String getConflictDescription() {
+ List<String> titles = new ArrayList<>();
+ HashSet<String> titleSet = new HashSet<>();
+ for (ScheduledRecording schedule : getConflicts()) {
+ String scheduleTitle = getScheduleTitle(schedule);
+ if (scheduleTitle != null && !titleSet.contains(scheduleTitle)) {
+ titles.add(scheduleTitle);
+ titleSet.add(scheduleTitle);
+ }
+ }
+ switch (titles.size()) {
+ case 0:
+ Log.i(TAG, "Conflict has been resolved by any reason. Maybe input might have"
+ + " been deleted.");
+ return null;
+ case 1:
+ return getResources().getString(
+ R.string.dvr_program_conflict_dialog_description_1, titles.get(0));
+ case 2:
+ return getResources().getString(
+ R.string.dvr_program_conflict_dialog_description_2, titles.get(0),
+ titles.get(1));
+ case 3:
+ return getResources().getString(
+ R.string.dvr_program_conflict_dialog_description_3, titles.get(0),
+ titles.get(1));
+ default:
+ return getResources().getQuantityString(
+ R.plurals.dvr_program_conflict_dialog_description_many,
+ titles.size() - LISTED_PROGRAM_COUNT, titles.get(0), titles.get(1),
+ titles.size() - LISTED_PROGRAM_COUNT);
+ }
+ }
+
+ @Nullable
+ private String getScheduleTitle(ScheduledRecording schedule) {
+ if (schedule.getType() == ScheduledRecording.TYPE_TIMED) {
+ Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(schedule.getChannelId());
+ if (channel != null) {
+ return channel.getDisplayName();
+ } else {
+ return null;
+ }
+ } else {
+ return schedule.getProgramTitle();
+ }
+ }
+
+ /**
+ * A fragment to show the program conflict.
+ */
+ public static class DvrProgramConflictFragment extends DvrConflictFragment {
+ private Program mProgram;
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM);
+ }
+ SoftPreconditions.checkArgument(mProgram != null);
+ TvInputInfo input = Utils.getTvInputInfoForProgram(getContext(), mProgram);
+ SoftPreconditions.checkNotNull(input);
+ List<ScheduledRecording> conflicts = null;
+ if (input != null) {
+ conflicts = TvApplication.getSingletons(getContext()).getDvrManager()
+ .getConflictingSchedules(mProgram);
+ }
+ if (conflicts == null) {
+ conflicts = Collections.emptyList();
+ }
+ if (conflicts.isEmpty()) {
+ dismissDialog();
+ }
+ setConflicts(conflicts);
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_program_conflict_dialog_title);
+ String descriptionPrefix = getString(
+ R.string.dvr_program_conflict_dialog_description_prefix, mProgram.getTitle());
+ String description = getConflictDescription();
+ if (description == null) {
+ dismissDialog();
+ }
+ Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null);
+ return new Guidance(title, descriptionPrefix + " " + description, null, icon);
+ }
+ }
+
+ /**
+ * A fragment to show the channel recording conflict.
+ */
+ public static class DvrChannelRecordConflictFragment extends DvrConflictFragment {
+ private Channel mChannel;
+ private long mStartTimeMs;
+ private long mEndTimeMs;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID);
+ mChannel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(channelId);
+ SoftPreconditions.checkArgument(mChannel != null);
+ TvInputInfo input = Utils.getTvInputInfoForChannelId(getContext(), mChannel.getId());
+ SoftPreconditions.checkNotNull(input);
+ List<ScheduledRecording> conflicts = null;
+ if (input != null) {
+ mStartTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_START_TIME_MS);
+ mEndTimeMs = args.getLong(DvrHalfSizedDialogFragment.KEY_END_TIME_MS);
+ conflicts = TvApplication.getSingletons(getContext()).getDvrManager()
+ .getConflictingSchedules(mChannel.getId(), mStartTimeMs, mEndTimeMs);
+ }
+ if (conflicts == null) {
+ conflicts = Collections.emptyList();
+ }
+ if (conflicts.isEmpty()) {
+ dismissDialog();
+ }
+ setConflicts(conflicts);
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_channel_conflict_dialog_title);
+ String descriptionPrefix = getString(
+ R.string.dvr_channel_conflict_dialog_description_prefix,
+ mChannel.getDisplayName());
+ String description = getConflictDescription();
+ if (description == null) {
+ dismissDialog();
+ }
+ Drawable icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null);
+ return new Guidance(title, descriptionPrefix + " " + description, null, icon);
+ }
+ }
+
+ /**
+ * A fragment to show the channel watching conflict.
+ * <p>
+ * This fragment is automatically closed when there are no upcoming conflicts.
+ */
+ public static class DvrChannelWatchConflictFragment extends DvrConflictFragment
+ implements OnUpcomingConflictChangeListener {
+ private long mChannelId;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mChannelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID);
+ }
+ SoftPreconditions.checkArgument(mChannelId != Channel.INVALID_ID);
+ ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker();
+ List<ScheduledRecording> conflicts = null;
+ if (checker != null) {
+ checker.addOnUpcomingConflictChangeListener(this);
+ conflicts = checker.getUpcomingConflicts();
+ if (DEBUG) Log.d(TAG, "onCreateView: upcoming conflicts: " + conflicts);
+ if (conflicts.isEmpty()) {
+ dismissDialog();
+ }
+ }
+ if (conflicts == null) {
+ if (DEBUG) Log.d(TAG, "onCreateView: There's no conflict.");
+ conflicts = Collections.emptyList();
+ }
+ if (conflicts.isEmpty()) {
+ dismissDialog();
+ }
+ setConflicts(conflicts);
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(
+ R.string.dvr_epg_channel_watch_conflict_dialog_title);
+ String description = getResources().getString(
+ R.string.dvr_epg_channel_watch_conflict_dialog_description);
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions,
+ Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(ACTION_DELETE_CONFLICT)
+ .title(R.string.dvr_action_delete_schedule)
+ .build());
+ actions.add(new GuidedAction.Builder(getContext())
+ .id(ACTION_CANCEL)
+ .title(R.string.dvr_action_record_program)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_CANCEL) {
+ ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker();
+ if (checker != null) {
+ checker.setCheckedConflictsForChannel(mChannelId, getConflicts());
+ }
+ } else if (action.getId() == ACTION_DELETE_CONFLICT) {
+ for (ScheduledRecording schedule : mConflicts) {
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ getDvrManager().stopRecording(schedule);
+ } else {
+ getDvrManager().removeScheduledRecording(schedule);
+ }
+ }
+ }
+ super.onGuidedActionClicked(action);
+ }
+
+ @Override
+ public void onDetach() {
+ ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker();
+ if (checker != null) {
+ checker.removeOnUpcomingConflictChangeListener(this);
+ }
+ super.onDetach();
+ }
+
+ @Override
+ public void onUpcomingConflictChange() {
+ ConflictChecker checker = ((MainActivity) getContext()).getDvrConflictChecker();
+ if (checker == null || checker.getUpcomingConflicts().isEmpty()) {
+ if (DEBUG) Log.d(TAG, "onUpcomingConflictChange: There's no conflict.");
+ dismissDialog();
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrDetailsActivity.java b/src/com/android/tv/dvr/ui/DvrDetailsActivity.java
new file mode 100644
index 00000000..806c775c
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrDetailsActivity.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v17.leanback.app.DetailsFragment;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+
+/**
+ * Activity to show details view in DVR.
+ */
+public class DvrDetailsActivity extends Activity {
+ /**
+ * Name of record id added to the Intent.
+ */
+ public static final String RECORDING_ID = "record_id";
+
+ /**
+ * Name of flag added to the Intent to determine if details view should hide "View schedule"
+ * button.
+ */
+ public static final String HIDE_VIEW_SCHEDULE = "hide_view_schedule";
+
+ /**
+ * Name of details view's type added to the intent.
+ */
+ public static final String DETAILS_VIEW_TYPE = "details_view_type";
+
+ /**
+ * Name of shared element between activities.
+ */
+ public static final String SHARED_ELEMENT_NAME = "shared_element";
+
+ /**
+ * CURRENT_RECORDING_VIEW refers to Current Recordings in DVR.
+ */
+ public static final int CURRENT_RECORDING_VIEW = 1;
+
+ /**
+ * SCHEDULED_RECORDING_VIEW refers to Scheduled Recordings in DVR.
+ */
+ public static final int SCHEDULED_RECORDING_VIEW = 2;
+
+ /**
+ * RECORDED_PROGRAM_VIEW refers to Recorded programs in DVR.
+ */
+ public static final int RECORDED_PROGRAM_VIEW = 3;
+
+ /**
+ * SERIES_RECORDING_VIEW refers to series recording in DVR.
+ */
+ public static final int SERIES_RECORDING_VIEW = 4;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_dvr_details);
+ long recordId = getIntent().getLongExtra(RECORDING_ID, -1);
+ int detailsViewType = getIntent().getIntExtra(DETAILS_VIEW_TYPE, -1);
+ boolean hideViewSchedule = getIntent().getBooleanExtra(HIDE_VIEW_SCHEDULE, false);
+ if (recordId != -1 && detailsViewType != -1 && savedInstanceState == null) {
+ Bundle args = new Bundle();
+ args.putLong(RECORDING_ID, recordId);
+ DetailsFragment detailsFragment = null;
+ if (detailsViewType == CURRENT_RECORDING_VIEW) {
+ detailsFragment = new CurrentRecordingDetailsFragment();
+ } else if (detailsViewType == SCHEDULED_RECORDING_VIEW) {
+ args.putBoolean(HIDE_VIEW_SCHEDULE, hideViewSchedule);
+ detailsFragment = new ScheduledRecordingDetailsFragment();
+ } else if (detailsViewType == RECORDED_PROGRAM_VIEW) {
+ detailsFragment = new RecordedProgramDetailsFragment();
+ } else if (detailsViewType == SERIES_RECORDING_VIEW) {
+ detailsFragment = new SeriesRecordingDetailsFragment();
+ }
+ detailsFragment.setArguments(args);
+ getFragmentManager().beginTransaction()
+ .replace(R.id.dvr_details_view_frame, detailsFragment).commit();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrDetailsFragment.java b/src/com/android/tv/dvr/ui/DvrDetailsFragment.java
new file mode 100644
index 00000000..21f9c4b4
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrDetailsFragment.java
@@ -0,0 +1,344 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvInputManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v17.leanback.app.DetailsFragment;
+import android.support.v17.leanback.widget.ArrayObjectAdapter;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.support.v17.leanback.widget.DetailsOverviewRow;
+import android.support.v17.leanback.widget.DetailsOverviewRowPresenter;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.PresenterSelector;
+import android.support.v17.leanback.widget.SparseArrayObjectAdapter;
+import android.support.v17.leanback.widget.VerticalGridView;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.TextAppearanceSpan;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.BaseProgram;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dialog.PinDialogFragment;
+import com.android.tv.dvr.DvrPlaybackActivity;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.parental.ParentalControlSettings;
+import com.android.tv.util.ImageLoader;
+import com.android.tv.util.ToastUtils;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+
+abstract class DvrDetailsFragment extends DetailsFragment {
+ private static final int LOAD_LOGO_IMAGE = 1;
+ private static final int LOAD_BACKGROUND_IMAGE = 2;
+
+ protected DetailsViewBackgroundHelper mBackgroundHelper;
+ private ArrayObjectAdapter mRowsAdapter;
+ private DetailsOverviewRow mDetailsOverview;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (!onLoadRecordingDetails(getArguments())) {
+ getActivity().finish();
+ return;
+ }
+ mBackgroundHelper = new DetailsViewBackgroundHelper(getActivity());
+ setupAdapter();
+ onCreateInternal();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ // TODO: remove the workaround of b/30401180.
+ VerticalGridView container = (VerticalGridView) getActivity()
+ .findViewById(R.id.container_list);
+ // Need to manually modify offset. Please refer DetailsFragment.setVerticalGridViewLayout.
+ container.setItemAlignmentOffset(0);
+ container.setWindowAlignmentOffset(
+ getResources().getDimensionPixelSize(R.dimen.lb_details_rows_align_top));
+ }
+
+ private void setupAdapter() {
+ DetailsOverviewRowPresenter rowPresenter = new DetailsOverviewRowPresenter(
+ new DetailsContentPresenter(getActivity()));
+ rowPresenter.setBackgroundColor(getResources().getColor(R.color.common_tv_background,
+ null));
+ rowPresenter.setSharedElementEnterTransition(getActivity(),
+ DvrDetailsActivity.SHARED_ELEMENT_NAME);
+ rowPresenter.setOnActionClickedListener(onCreateOnActionClickedListener());
+ mRowsAdapter = new ArrayObjectAdapter(onCreatePresenterSelector(rowPresenter));
+ setAdapter(mRowsAdapter);
+ }
+
+ /**
+ * Returns details views' rows adapter.
+ */
+ protected ArrayObjectAdapter getRowsAdapter() {
+ return mRowsAdapter;
+ }
+
+ /**
+ * Sets details overview.
+ */
+ protected void setDetailsOverviewRow(DetailsContent detailsContent) {
+ mDetailsOverview = new DetailsOverviewRow(detailsContent);
+ mDetailsOverview.setActionsAdapter(onCreateActionsAdapter());
+ mRowsAdapter.add(mDetailsOverview);
+ onLoadLogoAndBackgroundImages(detailsContent);
+ }
+
+ /**
+ * Creates and returns presenter selector will be used by rows adaptor.
+ */
+ protected PresenterSelector onCreatePresenterSelector(
+ DetailsOverviewRowPresenter rowPresenter) {
+ ClassPresenterSelector presenterSelector = new ClassPresenterSelector();
+ presenterSelector.addClassPresenter(DetailsOverviewRow.class, rowPresenter);
+ return presenterSelector;
+ }
+
+ /**
+ * Does customized initialization of subclasses. Since {@link #onCreate(Bundle)} might finish
+ * activity early when it cannot fetch valid recordings, subclasses' onCreate method should not
+ * do anything after calling {@link #onCreate(Bundle)}. If there's something subclasses have to
+ * do after the super class did onCreate, it should override this method and put the codes here.
+ */
+ protected void onCreateInternal() { }
+
+ /**
+ * Updates actions of details overview.
+ */
+ protected void updateActions() {
+ mDetailsOverview.setActionsAdapter(onCreateActionsAdapter());
+ }
+
+ /**
+ * Loads recording details according to the arguments the fragment got.
+ *
+ * @return false if cannot find valid recordings, else return true. If the return value
+ * is false, the detail activity and fragment will be ended.
+ */
+ abstract boolean onLoadRecordingDetails(Bundle args);
+
+ /**
+ * Creates actions users can interact with and their adaptor for this fragment.
+ */
+ abstract SparseArrayObjectAdapter onCreateActionsAdapter();
+
+ /**
+ * Creates actions listeners to implement the behavior of the fragment after users click some
+ * action buttons.
+ */
+ abstract OnActionClickedListener onCreateOnActionClickedListener();
+
+ /**
+ * Returns program title with episode number. If the program is null, returns channel name.
+ */
+ protected CharSequence getTitleFromProgram(BaseProgram program, Channel channel) {
+ String titleWithEpisodeNumber = program.getTitleWithEpisodeNumber(getContext());
+ SpannableString title = titleWithEpisodeNumber == null ? null
+ : new SpannableString(titleWithEpisodeNumber);
+ if (TextUtils.isEmpty(title)) {
+ title = new SpannableString(channel != null ? channel.getDisplayName()
+ : getContext().getResources().getString(
+ R.string.no_program_information));
+ } else {
+ String programTitle = program.getTitle();
+ title.setSpan(new TextAppearanceSpan(getContext(),
+ R.style.text_appearance_card_view_episode_number), programTitle == null ? 0
+ : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ return title;
+ }
+
+ /**
+ * Loads logo and background images for detail fragments.
+ */
+ protected void onLoadLogoAndBackgroundImages(DetailsContent detailsContent) {
+ Drawable logoDrawable = null;
+ Drawable backgroundDrawable = null;
+ if (TextUtils.isEmpty(detailsContent.getLogoImageUri())) {
+ logoDrawable = getContext().getResources()
+ .getDrawable(R.drawable.dvr_default_poster, null);
+ mDetailsOverview.setImageDrawable(logoDrawable);
+ }
+ if (TextUtils.isEmpty(detailsContent.getBackgroundImageUri())) {
+ backgroundDrawable = getContext().getResources()
+ .getDrawable(R.drawable.dvr_default_poster, null);
+ mBackgroundHelper.setBackground(backgroundDrawable);
+ }
+ if (logoDrawable != null && backgroundDrawable != null) {
+ return;
+ }
+ if (logoDrawable == null && backgroundDrawable == null
+ && detailsContent.getLogoImageUri().equals(
+ detailsContent.getBackgroundImageUri())) {
+ ImageLoader.loadBitmap(getContext(), detailsContent.getLogoImageUri(),
+ new MyImageLoaderCallback(this, LOAD_LOGO_IMAGE | LOAD_BACKGROUND_IMAGE,
+ getContext()));
+ return;
+ }
+ if (logoDrawable == null) {
+ int imageWidth = getResources().getDimensionPixelSize(R.dimen.dvr_details_poster_width);
+ int imageHeight = getResources()
+ .getDimensionPixelSize(R.dimen.dvr_details_poster_height);
+ ImageLoader.loadBitmap(getContext(), detailsContent.getLogoImageUri(),
+ imageWidth, imageHeight,
+ new MyImageLoaderCallback(this, LOAD_LOGO_IMAGE, getContext()));
+ }
+ if (backgroundDrawable == null) {
+ ImageLoader.loadBitmap(getContext(), detailsContent.getBackgroundImageUri(),
+ new MyImageLoaderCallback(this, LOAD_BACKGROUND_IMAGE, getContext()));
+ }
+ }
+
+ protected void startPlayback(RecordedProgram recordedProgram, long seekTimeMs) {
+ if (Utils.isInBundledPackageSet(recordedProgram.getPackageName()) &&
+ !isDataUriAccessible(recordedProgram.getDataUri())) {
+ // Since cleaning RecordedProgram from forgotten storage will take some time,
+ // ignore playback until cleaning is finished.
+ ToastUtils.show(getContext(),
+ getContext().getResources().getString(R.string.dvr_toast_recording_deleted),
+ Toast.LENGTH_SHORT);
+ return;
+ }
+ ParentalControlSettings parental = TvApplication.getSingletons(getActivity())
+ .getTvInputManagerHelper().getParentalControlSettings();
+ if (!parental.isParentalControlsEnabled()) {
+ launchPlaybackActivity(recordedProgram, seekTimeMs, false);
+ return;
+ }
+ ChannelDataManager channelDataManager =
+ TvApplication.getSingletons(getActivity()).getChannelDataManager();
+ Channel channel = channelDataManager.getChannel(recordedProgram.getChannelId());
+ if (channel != null && channel.isLocked()) {
+ checkPinToPlay(recordedProgram, seekTimeMs);
+ return;
+ }
+ String ratingString = recordedProgram.getContentRating();
+ if (TextUtils.isEmpty(ratingString)) {
+ launchPlaybackActivity(recordedProgram, seekTimeMs, false);
+ return;
+ }
+ String[] ratingList = ratingString.split(",");
+ TvContentRating[] programRatings = new TvContentRating[ratingList.length];
+ for (int i = 0; i < ratingList.length; i++) {
+ programRatings[i] = TvContentRating.unflattenFromString(ratingList[i]);
+ }
+ TvContentRating blockRatings = parental.getBlockedRating(programRatings);
+ if (blockRatings != null) {
+ checkPinToPlay(recordedProgram, seekTimeMs);
+ } else {
+ launchPlaybackActivity(recordedProgram, seekTimeMs, false);
+ }
+ }
+
+ private boolean isDataUriAccessible(Uri dataUri) {
+ if (dataUri == null || dataUri.getPath() == null) {
+ return false;
+ }
+ try {
+ File recordedProgramPath = new File(dataUri.getPath());
+ if (recordedProgramPath.exists()) {
+ return true;
+ }
+ } catch (SecurityException e) {
+ }
+ return false;
+ }
+
+ private void checkPinToPlay(RecordedProgram recordedProgram, long seekTimeMs) {
+ new PinDialogFragment(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_PROGRAM,
+ new PinDialogFragment.ResultListener() {
+ @Override
+ public void done(boolean success) {
+ if (success) {
+ launchPlaybackActivity(recordedProgram, seekTimeMs, true);
+ }
+ }
+ }).show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG);
+ }
+
+ private void launchPlaybackActivity(RecordedProgram mRecordedProgram, long seekTimeMs,
+ boolean pinChecked) {
+ Intent intent = new Intent(getActivity(), DvrPlaybackActivity.class);
+ intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, mRecordedProgram.getId());
+ if (seekTimeMs != TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME, seekTimeMs);
+ }
+ intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, pinChecked);
+ getActivity().startActivity(intent);
+ }
+
+ private static class MyImageLoaderCallback extends
+ ImageLoader.ImageLoaderCallback<DvrDetailsFragment> {
+ private final Context mContext;
+ private final int mLoadType;
+
+ public MyImageLoaderCallback(DvrDetailsFragment fragment,
+ int loadType, Context context) {
+ super(fragment);
+ mLoadType = loadType;
+ mContext = context;
+ }
+
+ @Override
+ public void onBitmapLoaded(DvrDetailsFragment fragment,
+ @Nullable Bitmap bitmap) {
+ Drawable drawable;
+ int loadType = mLoadType;
+ if (bitmap == null) {
+ Resources res = mContext.getResources();
+ drawable = res.getDrawable(R.drawable.dvr_default_poster, null);
+ if ((loadType & LOAD_BACKGROUND_IMAGE) != 0 && !fragment.isDetached()) {
+ loadType &= ~LOAD_BACKGROUND_IMAGE;
+ fragment.mBackgroundHelper.setBackgroundColor(
+ res.getColor(R.color.dvr_detail_default_background));
+ fragment.mBackgroundHelper.setScrim(
+ res.getColor(R.color.dvr_detail_default_background_scrim));
+ }
+ } else {
+ drawable = new BitmapDrawable(mContext.getResources(), bitmap);
+ }
+ if (!fragment.isDetached()) {
+ if ((loadType & LOAD_LOGO_IMAGE) != 0) {
+ fragment.mDetailsOverview.setImageDrawable(drawable);
+ }
+ if ((loadType & LOAD_BACKGROUND_IMAGE) != 0) {
+ fragment.mBackgroundHelper.setBackground(drawable);
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrDialogFragment.java b/src/com/android/tv/dvr/ui/DvrDialogFragment.java
deleted file mode 100644
index 38de9d8d..00000000
--- a/src/com/android/tv/dvr/ui/DvrDialogFragment.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.android.tv.dvr.ui;
-
-import android.app.FragmentManager;
-import android.content.Context;
-import android.os.Bundle;
-import android.support.v17.leanback.app.GuidedStepFragment;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import com.android.tv.MainActivity;
-import com.android.tv.R;
-import com.android.tv.guide.ProgramGuide;
-
-public class DvrDialogFragment extends HalfSizedDialogFragment {
- private final DvrGuidedStepFragment mDvrGuidedStepFragment;
-
- public DvrDialogFragment(DvrGuidedStepFragment dvrGuidedStepFragment) {
- mDvrGuidedStepFragment = dvrGuidedStepFragment;
- }
-
- @Override
- public void onAttach(Context context) {
- super.onAttach(context);
- ProgramGuide programGuide =
- ((MainActivity) getActivity()).getOverlayManager().getProgramGuide();
- if (programGuide != null && programGuide.isActive()) {
- programGuide.cancelHide();
- }
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container,
- Bundle savedInstanceState) {
- View view = super.onCreateView(inflater, container, savedInstanceState);
- FragmentManager fm = getChildFragmentManager();
- GuidedStepFragment.add(fm, mDvrGuidedStepFragment, R.id.halfsized_dialog_host);
- return view;
- }
-
- @Override
- public void onDetach() {
- super.onDetach();
- ProgramGuide programGuide =
- ((MainActivity) getActivity()).getOverlayManager().getProgramGuide();
- if (programGuide != null && programGuide.isActive()) {
- programGuide.scheduleHide();
- }
- }
-}
diff --git a/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java
new file mode 100644
index 00000000..73ddcdd0
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrForgetStorageErrorFragment.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+
+import java.util.List;
+
+public class DvrForgetStorageErrorFragment extends DvrGuidedStepFragment {
+ private static final int ACTION_CANCEL = 1;
+ private static final int ACTION_FORGET_STORAGE = 2;
+ private String mInputId;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mInputId = args.getString(DvrHalfSizedDialogFragment.KEY_INPUT_ID);
+ }
+ SoftPreconditions.checkArgument(!TextUtils.isEmpty(mInputId));
+ super.onCreate(savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_error_forget_storage_title);
+ String description = getResources().getString(
+ R.string.dvr_error_forget_storage_description);
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Activity activity = getActivity();
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_CANCEL)
+ .title(getResources().getString(R.string.dvr_action_error_cancel))
+ .build());
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_FORGET_STORAGE)
+ .title(getResources().getString(R.string.dvr_action_error_forget_storage))
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() != ACTION_FORGET_STORAGE) {
+ dismissDialog();
+ return;
+ }
+ DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ dvrManager.forgetStorage(mInputId);
+ Activity activity = getActivity();
+ if (activity instanceof DvrDetailsActivity) {
+ // Since we removed everything, just finish the activity.
+ activity.finish();
+ } else {
+ dismissDialog();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java b/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java
new file mode 100644
index 00000000..6b0c22ff
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrGuidedActionsStylist.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import com.android.tv.R;
+
+/**
+ * Stylist class used for DVR settings {@link GuidedStepFragment}.
+ */
+public class DvrGuidedActionsStylist extends GuidedActionsStylist {
+ private static boolean sInitialized;
+ private static float sWidthWeight;
+ private static int sItemHeight;
+
+ private final boolean mIsButtonActions;
+
+ public DvrGuidedActionsStylist(boolean isButtonActions) {
+ super();
+ mIsButtonActions = isButtonActions;
+ if (mIsButtonActions) {
+ setAsButtonActions();
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container) {
+ initializeIfNeeded(container.getContext());
+ View v = super.onCreateView(inflater, container);
+ if (mIsButtonActions) {
+ ((LinearLayout.LayoutParams) v.getLayoutParams()).weight = sWidthWeight;
+ }
+ return v;
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ initializeIfNeeded(parent.getContext());
+ ViewHolder viewHolder = super.onCreateViewHolder(parent);
+ viewHolder.itemView.getLayoutParams().height = sItemHeight;
+ return viewHolder;
+ }
+
+ private void initializeIfNeeded(Context context) {
+ if (sInitialized) {
+ return;
+ }
+ sInitialized = true;
+ sItemHeight = context.getResources().getDimensionPixelSize(
+ R.dimen.dvr_settings_one_line_action_container_height);
+ TypedValue outValue = new TypedValue();
+ context.getResources().getValue(R.dimen.dvr_settings_button_actions_list_width_weight,
+ outValue, true);
+ sWidthWeight = outValue.getFloat();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java
index 0854b91a..d26e6836 100644
--- a/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java
+++ b/src/com/android/tv/dvr/ui/DvrGuidedStepFragment.java
@@ -1,32 +1,41 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.android.tv.dvr.ui;
+import android.app.DialogFragment;
import android.content.Context;
import android.os.Bundle;
import android.support.v17.leanback.app.GuidedStepFragment;
-import android.support.v17.leanback.widget.GuidanceStylist;
+import android.support.v17.leanback.widget.GuidedAction;
import android.support.v17.leanback.widget.VerticalGridView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.android.tv.MainActivity;
+import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.dialog.SafeDismissDialogFragment;
import com.android.tv.dvr.DvrManager;
-import com.android.tv.guide.ProgramManager.TableEntry;
-import com.android.tv.R;
+import com.android.tv.dvr.ui.HalfSizedDialogFragment.OnActionClickListener;
public class DvrGuidedStepFragment extends GuidedStepFragment {
- private final TableEntry mEntry;
private DvrManager mDvrManager;
-
- public DvrGuidedStepFragment(TableEntry entry) {
- mEntry = entry;
- }
-
- protected TableEntry getEntry() {
- return mEntry;
- }
+ private OnActionClickListener mOnActionClickListener;
protected DvrManager getDvrManager() {
return mDvrManager;
@@ -42,32 +51,39 @@ public class DvrGuidedStepFragment extends GuidedStepFragment {
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = super.onCreateView(inflater, container, savedInstanceState);
- VerticalGridView gridView = getGuidedActionsStylist().getActionsGridView();
- gridView.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE);
+ VerticalGridView actionsList = getGuidedActionsStylist().getActionsGridView();
+ actionsList.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE);
+ VerticalGridView buttonActionsList = getGuidedButtonActionsStylist().getActionsGridView();
+ buttonActionsList.setWindowAlignment(VerticalGridView.WINDOW_ALIGN_BOTH_EDGE);
return view;
}
@Override
- public GuidanceStylist onCreateGuidanceStylist() {
- // Workaround: b/28448653
- return new GuidanceStylist() {
- @Override
- public int onProvideLayoutId() {
- return R.layout.halfsized_guidance;
- }
- };
+ public int onProvideTheme() {
+ return R.style.Theme_TV_Dvr_GuidedStep;
}
@Override
- public int onProvideTheme() {
- return R.style.Theme_TV_Dvr_GuidedStep;
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (mOnActionClickListener != null) {
+ mOnActionClickListener.onActionClick(action.getId());
+ }
+ dismissDialog();
}
protected void dismissDialog() {
- SafeDismissDialogFragment currentDialog =
- ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog();
- if (currentDialog instanceof DvrDialogFragment) {
- currentDialog.dismiss();
+ if (getActivity() instanceof MainActivity) {
+ SafeDismissDialogFragment currentDialog =
+ ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog();
+ if (currentDialog instanceof DvrHalfSizedDialogFragment) {
+ currentDialog.dismiss();
+ }
+ } else if (getParentFragment() instanceof DialogFragment) {
+ ((DialogFragment) getParentFragment()).dismiss();
}
}
-}
+
+ protected void setOnActionClickListener(OnActionClickListener listener) {
+ mOnActionClickListener = listener;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java
new file mode 100644
index 00000000..2b132db8
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrHalfSizedDialogFragment.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.MainActivity;
+import com.android.tv.R;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.dvr.ui.DvrConflictFragment.DvrChannelWatchConflictFragment;
+import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment;
+import com.android.tv.guide.ProgramGuide;
+
+import java.util.List;
+
+public class DvrHalfSizedDialogFragment extends HalfSizedDialogFragment {
+ /**
+ * Key for input ID.
+ * Type: String.
+ */
+ public static final String KEY_INPUT_ID = "DvrHalfSizedDialogFragment.input_id";
+ /**
+ * Key for the program.
+ * Type: {@link com.android.tv.data.Program}.
+ */
+ public static final String KEY_PROGRAM = "DvrHalfSizedDialogFragment.program";
+ /**
+ * Key for the channel ID.
+ * Type: long.
+ */
+ public static final String KEY_CHANNEL_ID = "DvrHalfSizedDialogFragment.channel_id";
+ /**
+ * Key for the recording start time in millisecond.
+ * Type: long.
+ */
+ public static final String KEY_START_TIME_MS = "DvrHalfSizedDialogFragment.start_time_ms";
+ /**
+ * Key for the recording end time in millisecond.
+ * Type: long.
+ */
+ public static final String KEY_END_TIME_MS = "DvrHalfSizedDialogFragment.end_time_ms";
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ Activity activity = getActivity();
+ if (activity instanceof MainActivity) {
+ ProgramGuide programGuide =
+ ((MainActivity) activity).getOverlayManager().getProgramGuide();
+ if (programGuide != null && programGuide.isActive()) {
+ programGuide.cancelHide();
+ }
+ }
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ Activity activity = getActivity();
+ if (activity instanceof MainActivity) {
+ ProgramGuide programGuide =
+ ((MainActivity) activity).getOverlayManager().getProgramGuide();
+ if (programGuide != null && programGuide.isActive()) {
+ programGuide.scheduleHide();
+ }
+ }
+ }
+
+ public abstract static class DvrGuidedStepDialogFragment extends DvrHalfSizedDialogFragment {
+ private DvrGuidedStepFragment mFragment;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = super.onCreateView(inflater, container, savedInstanceState);
+ mFragment = onCreateGuidedStepFragment();
+ mFragment.setArguments(getArguments());
+ mFragment.setOnActionClickListener(getOnActionClickListener());
+ GuidedStepFragment.add(getChildFragmentManager(),
+ mFragment, R.id.halfsized_dialog_host);
+ return view;
+ }
+
+ @Override
+ public void setOnActionClickListener(OnActionClickListener listener) {
+ super.setOnActionClickListener(listener);
+ if (mFragment != null) {
+ mFragment.setOnActionClickListener(listener);
+ }
+ }
+
+ protected abstract DvrGuidedStepFragment onCreateGuidedStepFragment();
+ }
+
+ /** A dialog fragment for {@link DvrScheduleFragment}. */
+ public static class DvrScheduleDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrScheduleFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrProgramConflictFragment}. */
+ public static class DvrProgramConflictDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrProgramConflictFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrChannelWatchConflictFragment}. */
+ public static class DvrChannelWatchConflictDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrChannelWatchConflictFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrChannelRecordDurationOptionFragment}. */
+ public static class DvrChannelRecordDurationOptionDialogFragment
+ extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrChannelRecordDurationOptionFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrInsufficientSpaceErrorFragment}. */
+ public static class DvrInsufficientSpaceErrorDialogFragment
+ extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrInsufficientSpaceErrorFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrMissingStorageErrorFragment}. */
+ public static class DvrMissingStorageErrorDialogFragment
+ extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrMissingStorageErrorFragment();
+ }
+ }
+
+ /**
+ * A dialog fragment to show error message when the current storage is too small to
+ * support DVR
+ */
+ public static class DvrSmallSizedStorageErrorDialogFragment
+ extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrGuidedStepFragment() {
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(
+ R.string.dvr_error_small_sized_storage_title);
+ String description = getResources().getString(
+ R.string.dvr_error_small_sized_storage_description,
+ DvrStorageStatusManager.MIN_STORAGE_SIZE_FOR_DVR_IN_BYTES / 1024
+ / 1024 / 1024);
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ Activity activity = getActivity();
+ actions.add(new GuidedAction.Builder(activity)
+ .id(GuidedAction.ACTION_ID_OK)
+ .title(android.R.string.ok)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ dismissDialog();
+ }
+ };
+ }
+ }
+
+ /** A dialog fragment for {@link DvrStopRecordingFragment}. */
+ public static class DvrStopRecordingDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrStopRecordingFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrAlreadyScheduledFragment}. */
+ public static class DvrAlreadyScheduledDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrAlreadyScheduledFragment();
+ }
+ }
+
+ /** A dialog fragment for {@link DvrAlreadyRecordedFragment}. */
+ public static class DvrAlreadyRecordedDialogFragment extends DvrGuidedStepDialogFragment {
+ @Override
+ protected DvrGuidedStepFragment onCreateGuidedStepFragment() {
+ return new DvrAlreadyRecordedFragment();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java
new file mode 100644
index 00000000..3b1dbfa0
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrInsufficientSpaceErrorFragment.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrDataManager;
+
+import java.util.List;
+
+public class DvrInsufficientSpaceErrorFragment extends DvrGuidedStepFragment {
+ private static final int ACTION_DONE = 1;
+ private static final int ACTION_OPEN_DVR = 2;
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_error_insufficient_space_title);
+ String description = getResources()
+ .getString(R.string.dvr_error_insufficient_space_description);
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ Activity activity = getActivity();
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_DONE)
+ .title(getResources().getString(R.string.dvr_action_error_done))
+ .build());
+ DvrDataManager dvrDataManager = TvApplication.getSingletons(getContext())
+ .getDvrDataManager();
+ if (!(dvrDataManager.getRecordedPrograms().isEmpty()
+ && dvrDataManager.getStartedRecordings().isEmpty()
+ && dvrDataManager.getNonStartedScheduledRecordings().isEmpty()
+ && dvrDataManager.getSeriesRecordings().isEmpty())) {
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_OPEN_DVR)
+ .title(getResources().getString(R.string.dvr_action_error_open_dvr))
+ .build());
+ }
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_OPEN_DVR) {
+ Intent intent = new Intent(getActivity(), DvrActivity.class);
+ getActivity().startActivity(intent);
+ }
+ dismissDialog();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrItemPresenter.java b/src/com/android/tv/dvr/ui/DvrItemPresenter.java
new file mode 100644
index 00000000..339e5d2f
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrItemPresenter.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.support.annotation.CallSuper;
+import android.support.v17.leanback.widget.Presenter;
+import android.view.View;
+import android.view.View.OnClickListener;
+
+import com.android.tv.dvr.DvrUiHelper;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+/**
+ * An abstract class to present DVR items in {@link RecordingCardView}, which is mainly used in
+ * {@link DvrBrowseFragment}. DVR items might include: {@link ScheduledRecording},
+ * {@link RecordedProgram}, and {@link SeriesRecording}.
+ */
+public abstract class DvrItemPresenter extends Presenter {
+ private final Set<ViewHolder> mBoundViewHolders = new HashSet<>();
+ private final OnClickListener mOnClickListener = onCreateOnClickListener();
+
+ @Override
+ @CallSuper
+ public void onBindViewHolder(ViewHolder viewHolder, Object o) {
+ viewHolder.view.setTag(o);
+ viewHolder.view.setOnClickListener(mOnClickListener);
+ mBoundViewHolders.add(viewHolder);
+ }
+
+ @Override
+ @CallSuper
+ public void onUnbindViewHolder(ViewHolder viewHolder) {
+ mBoundViewHolders.remove(viewHolder);
+ }
+
+ /**
+ * Unbinds all bound view holders.
+ */
+ public void unbindAllViewHolders() {
+ // When browse fragments are destroyed, RecyclerView would not call presenters'
+ // onUnbindViewHolder(). We should handle it by ourselves to prevent resources leaks.
+ for (ViewHolder viewHolder : new HashSet<>(mBoundViewHolders)) {
+ onUnbindViewHolder(viewHolder);
+ }
+ }
+
+ /**
+ * Creates {@link OnClickListener} for DVR library's card views.
+ */
+ protected OnClickListener onCreateOnClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (view instanceof RecordingCardView) {
+ RecordingCardView v = (RecordingCardView) view;
+ DvrUiHelper.startDetailsActivity((Activity) v.getContext(),
+ v.getTag(), v.getImageView(), false);
+ }
+ }
+ };
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java b/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java
new file mode 100644
index 00000000..2e2c2849
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrMissingStorageErrorFragment.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.common.SoftPreconditions;
+
+import java.util.List;
+
+public class DvrMissingStorageErrorFragment extends DvrGuidedStepFragment {
+ private static final int ACTION_CANCEL = 1;
+ private static final int ACTION_FORGET_STORAGE = 2;
+ private String mInputId;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mInputId = args.getString(DvrHalfSizedDialogFragment.KEY_INPUT_ID);
+ }
+ SoftPreconditions.checkArgument(!TextUtils.isEmpty(mInputId));
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getResources().getString(R.string.dvr_error_missing_storage_title);
+ String description = getResources().getString(
+ R.string.dvr_error_missing_storage_description);
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ Activity activity = getActivity();
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_CANCEL)
+ .title(getResources().getString(R.string.dvr_action_error_cancel))
+ .build());
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_FORGET_STORAGE)
+ .title(getResources().getString(R.string.dvr_action_error_forget_storage))
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_FORGET_STORAGE) {
+ DvrForgetStorageErrorFragment fragment = new DvrForgetStorageErrorFragment();
+ Bundle args = new Bundle();
+ args.putString(DvrHalfSizedDialogFragment.KEY_INPUT_ID, mInputId);
+ fragment.setArguments(args);
+ GuidedStepFragment.add(getFragmentManager(), fragment, R.id.halfsized_dialog_host);
+ return;
+ }
+ dismissDialog();
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java b/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java
new file mode 100644
index 00000000..8c4c856c
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrPlaybackCardPresenter.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+
+import com.android.tv.R;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.dvr.DvrPlaybackActivity;
+import com.android.tv.util.Utils;
+
+/**
+ * This class is used to generate Views and bind Objects for related recordings in DVR playback.
+ */
+public class DvrPlaybackCardPresenter extends RecordedProgramPresenter {
+ private static final String TAG = "DvrPlaybackCardPresenter";
+ private static final boolean DEBUG = false;
+
+ private final int mRelatedRecordingCardWidth;
+ private final int mRelatedRecordingCardHeight;
+
+ DvrPlaybackCardPresenter(Context context) {
+ super(context);
+ mRelatedRecordingCardWidth =
+ context.getResources().getDimensionPixelSize(R.dimen.dvr_related_recordings_width);
+ mRelatedRecordingCardHeight =
+ context.getResources().getDimensionPixelSize(R.dimen.dvr_related_recordings_height);
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ Resources res = parent.getResources();
+ RecordingCardView view = new RecordingCardView(
+ getContext(), mRelatedRecordingCardWidth, mRelatedRecordingCardHeight);
+ return new ViewHolder(view);
+ }
+
+ @Override
+ protected OnClickListener onCreateOnClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ long programId = ((RecordedProgram) v.getTag()).getId();
+ if (DEBUG) Log.d(TAG, "Play Related Recording:" + programId);
+ Intent intent = new Intent(getContext(), DvrPlaybackActivity.class);
+ intent.putExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, programId);
+ getContext().startActivity(intent);
+ }
+ };
+ }
+
+ @Override
+ protected String getDescription(RecordedProgram program) {
+ String description = program.getDescription();
+ if (TextUtils.isEmpty(description)) {
+ description =
+ getContext().getResources().getString(R.string.dvr_msg_no_program_description);
+ }
+ return description;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java b/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java
new file mode 100644
index 00000000..0bc4ecb1
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrPlaybackControlHelper.java
@@ -0,0 +1,313 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.graphics.drawable.Drawable;
+import android.media.MediaMetadata;
+import android.media.session.MediaController;
+import android.media.session.MediaController.TransportControls;
+import android.media.session.PlaybackState;
+import android.support.v17.leanback.app.PlaybackControlGlue;
+import android.support.v17.leanback.widget.AbstractDetailsDescriptionPresenter;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.PlaybackControlsRow;
+import android.support.v17.leanback.widget.PlaybackControlsRowPresenter;
+import android.support.v17.leanback.widget.RowPresenter;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+
+import com.android.tv.R;
+import com.android.tv.util.TimeShiftUtils;
+
+/**
+ * A helper class to assist {@link DvrPlaybackOverlayFragment} to manage its controls row and
+ * send command to the media controller. It also helps to update playback states displayed in the
+ * fragment according to information the media session provides.
+ */
+public class DvrPlaybackControlHelper extends PlaybackControlGlue {
+ private static final String TAG = "DvrPlaybackControlHelper";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Indicates the ID of the media under playback is unknown.
+ */
+ public static int UNKNOWN_MEDIA_ID = -1;
+
+ private int mPlaybackState = PlaybackState.STATE_NONE;
+ private int mPlaybackSpeedLevel;
+ private int mPlaybackSpeedId;
+ private boolean mReadyToControl;
+
+ private final MediaController mMediaController;
+ private final MediaController.Callback mMediaControllerCallback = new MediaControllerCallback();
+ private final TransportControls mTransportControls;
+ private final int mExtraPaddingTopForNoDescription;
+
+ public DvrPlaybackControlHelper(Activity activity, DvrPlaybackOverlayFragment overlayFragment) {
+ super(activity, overlayFragment, new int[TimeShiftUtils.MAX_SPEED_LEVEL + 1]);
+ mMediaController = activity.getMediaController();
+ mMediaController.registerCallback(mMediaControllerCallback);
+ mTransportControls = mMediaController.getTransportControls();
+ mExtraPaddingTopForNoDescription = activity.getResources()
+ .getDimensionPixelOffset(R.dimen.dvr_playback_controls_extra_padding_top);
+ }
+
+ @Override
+ public PlaybackControlsRowPresenter createControlsRowAndPresenter() {
+ PlaybackControlsRow controlsRow = new PlaybackControlsRow(this);
+ setControlsRow(controlsRow);
+ AbstractDetailsDescriptionPresenter detailsPresenter =
+ new AbstractDetailsDescriptionPresenter() {
+ @Override
+ protected void onBindDescription(
+ AbstractDetailsDescriptionPresenter.ViewHolder viewHolder, Object object) {
+ PlaybackControlGlue glue = (PlaybackControlGlue) object;
+ if (glue.hasValidMedia()) {
+ viewHolder.getTitle().setText(glue.getMediaTitle());
+ viewHolder.getSubtitle().setText(glue.getMediaSubtitle());
+ } else {
+ viewHolder.getTitle().setText("");
+ viewHolder.getSubtitle().setText("");
+ }
+ if (TextUtils.isEmpty(viewHolder.getSubtitle().getText())) {
+ viewHolder.view.setPadding(viewHolder.view.getPaddingLeft(),
+ mExtraPaddingTopForNoDescription,
+ viewHolder.view.getPaddingRight(), viewHolder.view.getPaddingBottom());
+ }
+ }
+ };
+ PlaybackControlsRowPresenter presenter =
+ new PlaybackControlsRowPresenter(detailsPresenter) {
+ @Override
+ protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) {
+ super.onBindRowViewHolder(vh, item);
+ vh.setOnKeyListener(DvrPlaybackControlHelper.this);
+ }
+
+ @Override
+ protected void onUnbindRowViewHolder(RowPresenter.ViewHolder vh) {
+ super.onUnbindRowViewHolder(vh);
+ vh.setOnKeyListener(null);
+ }
+ };
+ presenter.setProgressColor(getContext().getResources()
+ .getColor(R.color.play_controls_progress_bar_watched));
+ presenter.setBackgroundColor(getContext().getResources()
+ .getColor(R.color.play_controls_body_background_enabled));
+ presenter.setOnActionClickedListener(new OnActionClickedListener() {
+ @Override
+ public void onActionClicked(Action action) {
+ if (mReadyToControl) {
+ DvrPlaybackControlHelper.super.onActionClicked(action);
+ }
+ }
+ });
+ return presenter;
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (mReadyToControl) {
+ if (keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE && event.getAction() == KeyEvent.ACTION_DOWN
+ && (mPlaybackState == PlaybackState.STATE_FAST_FORWARDING
+ || mPlaybackState == PlaybackState.STATE_REWINDING)) {
+ // Workaround of b/31489271. Clicks play/pause button first to reset play controls
+ // to "play" state. Then we can pass MEDIA_PAUSE to let playback be paused.
+ onActionClicked(getControlsRow().getActionForKeyCode(keyCode));
+ }
+ return super.onKey(v, keyCode, event);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean hasValidMedia() {
+ PlaybackState playbackState = mMediaController.getPlaybackState();
+ return playbackState != null;
+ }
+
+ @Override
+ public boolean isMediaPlaying() {
+ PlaybackState playbackState = mMediaController.getPlaybackState();
+ if (playbackState == null) {
+ return false;
+ }
+ int state = playbackState.getState();
+ return state != PlaybackState.STATE_NONE && state != PlaybackState.STATE_CONNECTING
+ && state != PlaybackState.STATE_PAUSED;
+ }
+
+ /**
+ * Returns the ID of the media under playback.
+ */
+ public long getMediaId() {
+ MediaMetadata mediaMetadata = mMediaController.getMetadata();
+ return mediaMetadata == null ? UNKNOWN_MEDIA_ID
+ : mediaMetadata.getLong(MediaMetadata.METADATA_KEY_MEDIA_ID);
+ }
+
+ @Override
+ public CharSequence getMediaTitle() {
+ MediaMetadata mediaMetadata = mMediaController.getMetadata();
+ return mediaMetadata == null ? ""
+ : mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);
+ }
+
+ @Override
+ public CharSequence getMediaSubtitle() {
+ MediaMetadata mediaMetadata = mMediaController.getMetadata();
+ return mediaMetadata == null ? ""
+ : mediaMetadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE);
+ }
+
+ @Override
+ public int getMediaDuration() {
+ MediaMetadata mediaMetadata = mMediaController.getMetadata();
+ return mediaMetadata == null ? 0
+ : (int) mediaMetadata.getLong(MediaMetadata.METADATA_KEY_DURATION);
+ }
+
+ @Override
+ public Drawable getMediaArt() {
+ // Do not show the poster art on control row.
+ return null;
+ }
+
+ @Override
+ public long getSupportedActions() {
+ return ACTION_PLAY_PAUSE | ACTION_FAST_FORWARD | ACTION_REWIND;
+ }
+
+ @Override
+ public int getCurrentSpeedId() {
+ return mPlaybackSpeedId;
+ }
+
+ @Override
+ public int getCurrentPosition() {
+ PlaybackState playbackState = mMediaController.getPlaybackState();
+ if (playbackState == null) {
+ return 0;
+ }
+ return (int) playbackState.getPosition();
+ }
+
+ /**
+ * Unregister media controller's callback.
+ */
+ public void unregisterCallback() {
+ mMediaController.unregisterCallback(mMediaControllerCallback);
+ }
+
+ @Override
+ protected void startPlayback(int speedId) {
+ if (getCurrentSpeedId() == speedId) {
+ return;
+ }
+ if (speedId == PLAYBACK_SPEED_NORMAL) {
+ mTransportControls.play();
+ } else if (speedId <= -PLAYBACK_SPEED_FAST_L0) {
+ mTransportControls.rewind();
+ } else if (speedId >= PLAYBACK_SPEED_FAST_L0){
+ mTransportControls.fastForward();
+ }
+ }
+
+ @Override
+ protected void pausePlayback() {
+ mTransportControls.pause();
+ }
+
+ @Override
+ protected void skipToNext() {
+ // Do nothing.
+ }
+
+ @Override
+ protected void skipToPrevious() {
+ // Do nothing.
+ }
+
+ @Override
+ protected void onRowChanged(PlaybackControlsRow row) {
+ // Do nothing.
+ }
+
+ private void onStateChanged(int state, long positionMs, int speedLevel) {
+ if (DEBUG) Log.d(TAG, "onStateChanged");
+ getControlsRow().setCurrentTime((int) positionMs);
+ if (state == mPlaybackState && mPlaybackSpeedLevel == speedLevel) {
+ // Only position is changed, no need to update controls row
+ return;
+ }
+ // NOTICE: The below two variables should only be used in this method.
+ // The only usage of them is to confirm if the state is changed or not.
+ mPlaybackState = state;
+ mPlaybackSpeedLevel = speedLevel;
+ switch (state) {
+ case PlaybackState.STATE_PLAYING:
+ mPlaybackSpeedId = PLAYBACK_SPEED_NORMAL;
+ setFadingEnabled(true);
+ mReadyToControl = true;
+ break;
+ case PlaybackState.STATE_PAUSED:
+ mPlaybackSpeedId = PLAYBACK_SPEED_PAUSED;
+ setFadingEnabled(true);
+ mReadyToControl = true;
+ break;
+ case PlaybackState.STATE_FAST_FORWARDING:
+ mPlaybackSpeedId = PLAYBACK_SPEED_FAST_L0 + speedLevel;
+ setFadingEnabled(false);
+ mReadyToControl = true;
+ break;
+ case PlaybackState.STATE_REWINDING:
+ mPlaybackSpeedId = -PLAYBACK_SPEED_FAST_L0 - speedLevel;
+ setFadingEnabled(false);
+ mReadyToControl = true;
+ break;
+ case PlaybackState.STATE_CONNECTING:
+ setFadingEnabled(false);
+ mReadyToControl = false;
+ break;
+ case PlaybackState.STATE_NONE:
+ mReadyToControl = false;
+ break;
+ default:
+ setFadingEnabled(true);
+ break;
+ }
+ onStateChanged();
+ }
+
+ private class MediaControllerCallback extends MediaController.Callback {
+ @Override
+ public void onPlaybackStateChanged(PlaybackState state) {
+ if (DEBUG) Log.d(TAG, "Playback state changed: " + state.getState());
+ onStateChanged(state.getState(), state.getPosition(), (int) state.getPlaybackSpeed());
+ }
+
+ @Override
+ public void onMetadataChanged(MediaMetadata metadata) {
+ DvrPlaybackControlHelper.this.onMetadataChanged();
+ ((DvrPlaybackOverlayFragment) getFragment()).onMediaControllerUpdated();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java
new file mode 100644
index 00000000..51ec93b8
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrPlaybackOverlayFragment.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Point;
+import android.hardware.display.DisplayManager;
+import android.media.tv.TvContentRating;
+import android.os.Bundle;
+import android.media.session.PlaybackState;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvView;
+import android.support.v17.leanback.app.PlaybackOverlayFragment;
+import android.support.v17.leanback.widget.ArrayObjectAdapter;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.support.v17.leanback.widget.HeaderItem;
+import android.support.v17.leanback.widget.ListRow;
+import android.support.v17.leanback.widget.ListRowPresenter;
+import android.support.v17.leanback.widget.PlaybackControlsRow;
+import android.support.v17.leanback.widget.PlaybackControlsRowPresenter;
+import android.support.v17.leanback.widget.SinglePresenterSelector;
+import android.view.Display;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.BaseProgram;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.dialog.PinDialogFragment;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrPlayer;
+import com.android.tv.dvr.DvrPlaybackMediaSessionHelper;
+import com.android.tv.parental.ContentRatingsManager;
+import com.android.tv.util.Utils;
+
+public class DvrPlaybackOverlayFragment extends PlaybackOverlayFragment {
+ // TODO: Handles audio focus. Deals with block and ratings.
+ private static final String TAG = "DvrPlaybackOverlayFragment";
+ private static final boolean DEBUG = false;
+
+ private static final String MEDIA_SESSION_TAG = "com.android.tv.dvr.mediasession";
+ private static final float DISPLAY_ASPECT_RATIO_EPSILON = 0.01f;
+
+ // mProgram is only used to store program from intent. Don't use it elsewhere.
+ private RecordedProgram mProgram;
+ private DvrPlaybackMediaSessionHelper mMediaSessionHelper;
+ private DvrPlaybackControlHelper mPlaybackControlHelper;
+ private ArrayObjectAdapter mRowsAdapter;
+ private SortedArrayAdapter<BaseProgram> mRelatedRecordingsRowAdapter;
+ private DvrPlaybackCardPresenter mRelatedRecordingCardPresenter;
+ private DvrDataManager mDvrDataManager;
+ private ContentRatingsManager mContentRatingsManager;
+ private TvView mTvView;
+ private View mBlockScreenView;
+ private ListRow mRelatedRecordingsRow;
+ private int mExtraPaddingNoRelatedRow;
+ private int mWindowWidth;
+ private int mWindowHeight;
+ private float mAppliedAspectRatio;
+ private float mWindowAspectRatio;
+ private boolean mPinChecked;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ if (DEBUG) Log.d(TAG, "onCreate");
+ super.onCreate(savedInstanceState);
+ mExtraPaddingNoRelatedRow = getActivity().getResources()
+ .getDimensionPixelOffset(R.dimen.dvr_playback_fragment_extra_padding_top);
+ mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager();
+ mContentRatingsManager = TvApplication.getSingletons(getContext())
+ .getTvInputManagerHelper().getContentRatingsManager();
+ mProgram = getProgramFromIntent(getActivity().getIntent());
+ if (mProgram == null) {
+ Toast.makeText(getActivity(), getString(R.string.dvr_program_not_found),
+ Toast.LENGTH_SHORT).show();
+ getActivity().finish();
+ return;
+ }
+ Point size = new Point();
+ ((DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE))
+ .getDisplay(Display.DEFAULT_DISPLAY).getSize(size);
+ mWindowWidth = size.x;
+ mWindowHeight = size.y;
+ mWindowAspectRatio = mAppliedAspectRatio = (float) mWindowWidth / mWindowHeight;
+ setBackgroundType(PlaybackOverlayFragment.BG_LIGHT);
+ setFadingEnabled(true);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ mTvView = (TvView) getActivity().findViewById(R.id.dvr_tv_view);
+ mBlockScreenView = getActivity().findViewById(R.id.block_screen);
+ mMediaSessionHelper = new DvrPlaybackMediaSessionHelper(
+ getActivity(), MEDIA_SESSION_TAG, new DvrPlayer(mTvView), this);
+ mPlaybackControlHelper = new DvrPlaybackControlHelper(getActivity(), this);
+ setUpRows();
+ preparePlayback(getActivity().getIntent());
+ DvrPlayer dvrPlayer = mMediaSessionHelper.getDvrPlayer();
+ dvrPlayer.setAspectRatioChangedListener(new DvrPlayer.AspectRatioChangedListener() {
+ @Override
+ public void onAspectRatioChanged(float videoAspectRatio) {
+ updateAspectRatio(videoAspectRatio);
+ }
+ });
+ mPinChecked = getActivity().getIntent()
+ .getBooleanExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED, false);
+ dvrPlayer.setContentBlockedListener(new DvrPlayer.ContentBlockedListener() {
+ @Override
+ public void onContentBlocked(TvContentRating rating) {
+ if (mPinChecked) {
+ mTvView.unblockContent(rating);
+ return;
+ }
+ mBlockScreenView.setVisibility(View.VISIBLE);
+ getActivity().getMediaController().getTransportControls().pause();
+ new PinDialogFragment(PinDialogFragment.PIN_DIALOG_TYPE_UNLOCK_DVR,
+ new PinDialogFragment.ResultListener() {
+ @Override
+ public void done(boolean success) {
+ if (success) {
+ mPinChecked = true;
+ mTvView.unblockContent(rating);
+ mBlockScreenView.setVisibility(View.GONE);
+ getActivity().getMediaController()
+ .getTransportControls().play();
+ }
+ }
+ }, mContentRatingsManager.getDisplayNameForRating(rating))
+ .show(getActivity().getFragmentManager(), PinDialogFragment.DIALOG_TAG);
+ }
+ });
+ }
+
+ @Override
+ public void onPause() {
+ if (DEBUG) Log.d(TAG, "onPause");
+ super.onPause();
+ if (mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_FAST_FORWARDING
+ || mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_REWINDING) {
+ getActivity().getMediaController().getTransportControls().pause();
+ }
+ if (mMediaSessionHelper.getPlaybackState() == PlaybackState.STATE_NONE) {
+ getActivity().requestVisibleBehind(false);
+ } else {
+ getActivity().requestVisibleBehind(true);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(TAG, "onDestroy");
+ mPlaybackControlHelper.unregisterCallback();
+ mMediaSessionHelper.release();
+ mRelatedRecordingCardPresenter.unbindAllViewHolders();
+ super.onDestroy();
+ }
+
+ /**
+ * Passes the intent to the fragment.
+ */
+ public void onNewIntent(Intent intent) {
+ mProgram = getProgramFromIntent(intent);
+ if (mProgram == null) {
+ Toast.makeText(getActivity(), getString(R.string.dvr_program_not_found),
+ Toast.LENGTH_SHORT).show();
+ // Continue playing the original program
+ return;
+ }
+ preparePlayback(intent);
+ }
+
+ /**
+ * Should be called when windows' size is changed in order to notify DVR player
+ * to update it's view width/height and position.
+ */
+ public void onWindowSizeChanged(final int windowWidth, final int windowHeight) {
+ mWindowWidth = windowWidth;
+ mWindowHeight = windowHeight;
+ mWindowAspectRatio = (float) mWindowWidth / mWindowHeight;
+ updateAspectRatio(mAppliedAspectRatio);
+ }
+
+ public RecordedProgram getNextEpisode(RecordedProgram program) {
+ int position = mRelatedRecordingsRowAdapter.findInsertPosition(program);
+ if (position == mRelatedRecordingsRowAdapter.size()) {
+ return null;
+ } else {
+ return (RecordedProgram) mRelatedRecordingsRowAdapter.get(position);
+ }
+ }
+
+ void onMediaControllerUpdated() {
+ mRowsAdapter.notifyArrayItemRangeChanged(0, 1);
+ }
+
+ private void updateAspectRatio(float videoAspectRatio) {
+ if (Math.abs(mAppliedAspectRatio - videoAspectRatio) < DISPLAY_ASPECT_RATIO_EPSILON) {
+ // No need to change
+ return;
+ }
+ if (videoAspectRatio < mWindowAspectRatio) {
+ int newPadding = (mWindowWidth - Math.round(mWindowHeight * videoAspectRatio)) / 2;
+ ((ViewGroup) mTvView.getParent()).setPadding(newPadding, 0, newPadding, 0);
+ } else {
+ int newPadding = (mWindowHeight - Math.round(mWindowWidth / videoAspectRatio)) / 2;
+ ((ViewGroup) mTvView.getParent()).setPadding(0, newPadding, 0, newPadding);
+ }
+ mAppliedAspectRatio = videoAspectRatio;
+ }
+
+ private void preparePlayback(Intent intent) {
+ mMediaSessionHelper.setupPlayback(mProgram, getSeekTimeFromIntent(intent));
+ getActivity().getMediaController().getTransportControls().prepare();
+ updateRelatedRecordingsRow();
+ }
+
+ private void updateRelatedRecordingsRow() {
+ boolean wasEmpty = (mRelatedRecordingsRowAdapter.size() == 0);
+ mRelatedRecordingsRowAdapter.clear();
+ long programId = mProgram.getId();
+ String seriesId = mProgram.getSeriesId();
+ if (!TextUtils.isEmpty(seriesId)) {
+ if (DEBUG) Log.d(TAG, "Update related recordings with:" + seriesId);
+ for (RecordedProgram program : mDvrDataManager.getRecordedPrograms()) {
+ if (seriesId.equals(program.getSeriesId()) && programId != program.getId()) {
+ mRelatedRecordingsRowAdapter.add(program);
+ }
+ }
+ }
+ View view = getView();
+ if (mRelatedRecordingsRowAdapter.size() == 0) {
+ mRowsAdapter.remove(mRelatedRecordingsRow);
+ view.setPadding(view.getPaddingLeft(), mExtraPaddingNoRelatedRow,
+ view.getPaddingRight(), view.getPaddingBottom());
+ } else if (wasEmpty){
+ mRowsAdapter.add(mRelatedRecordingsRow);
+ view.setPadding(view.getPaddingLeft(), 0,
+ view.getPaddingRight(), view.getPaddingBottom());
+ }
+ }
+
+ private void setUpRows() {
+ PlaybackControlsRowPresenter controlsRowPresenter =
+ mPlaybackControlHelper.createControlsRowAndPresenter();
+
+ ClassPresenterSelector selector = new ClassPresenterSelector();
+ selector.addClassPresenter(PlaybackControlsRow.class, controlsRowPresenter);
+ selector.addClassPresenter(ListRow.class, new ListRowPresenter());
+
+ mRowsAdapter = new ArrayObjectAdapter(selector);
+ mRowsAdapter.add(mPlaybackControlHelper.getControlsRow());
+ mRelatedRecordingsRow = getRelatedRecordingsRow();
+ setAdapter(mRowsAdapter);
+ }
+
+ private ListRow getRelatedRecordingsRow() {
+ mRelatedRecordingCardPresenter = new DvrPlaybackCardPresenter(getActivity());
+ mRelatedRecordingsRowAdapter = new RelatedRecordingsAdapter(mRelatedRecordingCardPresenter);
+ HeaderItem header = new HeaderItem(0,
+ getActivity().getString(R.string.dvr_playback_related_recordings));
+ return new ListRow(header, mRelatedRecordingsRowAdapter);
+ }
+
+ private RecordedProgram getProgramFromIntent(Intent intent) {
+ long programId = intent.getLongExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_ID, -1);
+ return mDvrDataManager.getRecordedProgram(programId);
+ }
+
+ private long getSeekTimeFromIntent(Intent intent) {
+ return intent.getLongExtra(Utils.EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME,
+ TvInputManager.TIME_SHIFT_INVALID_TIME);
+ }
+
+ private class RelatedRecordingsAdapter extends SortedArrayAdapter<BaseProgram> {
+ RelatedRecordingsAdapter(DvrPlaybackCardPresenter presenter) {
+ super(new SinglePresenterSelector(presenter), BaseProgram.EPISODE_COMPARATOR);
+ }
+
+ @Override
+ long getId(BaseProgram item) {
+ return item.getId();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java b/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java
deleted file mode 100644
index 92052b5b..00000000
--- a/src/com/android/tv/dvr/ui/DvrRecordConflictFragment.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.android.tv.dvr.ui;
-
-import android.app.Activity;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import com.android.tv.MainActivity;
-import com.android.tv.R;
-
-import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
-import android.support.v17.leanback.widget.GuidedAction;
-
-import com.android.tv.data.Channel;
-import com.android.tv.data.ChannelDataManager;
-import com.android.tv.data.Program;
-import com.android.tv.dvr.ScheduledRecording;
-import com.android.tv.guide.ProgramManager.TableEntry;
-
-import java.text.DateFormat;
-import java.util.Date;
-import java.util.List;
-
-public class DvrRecordConflictFragment extends DvrGuidedStepFragment {
- private static final int DVR_EPG_RECORD = 1;
- private static final int DVR_EPG_NOT_RECORD = 2;
-
- private List<ScheduledRecording> mConflicts;
-
- public DvrRecordConflictFragment(TableEntry entry) {
- super(entry);
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container,
- Bundle savedInstanceState) {
- mConflicts = getDvrManager().getScheduledRecordingsThatConflict(getEntry().program);
- return super.onCreateView(inflater, container, savedInstanceState);
- }
-
- @Override
- public Guidance onCreateGuidance(Bundle savedInstanceState) {
- final MainActivity tvActivity = (MainActivity) getActivity();
- final ChannelDataManager channelDataManager = tvActivity.getChannelDataManager();
- StringBuilder sb = new StringBuilder();
- for (ScheduledRecording r : mConflicts) {
- Channel channel = channelDataManager.getChannel(r.getChannelId());
- if (channel == null) {
- continue;
- }
- sb.append(channel.getDisplayName())
- .append(" : ")
- .append(DateFormat.getDateTimeInstance().format(new Date(r.getStartTimeMs())))
- .append("\n");
- }
- String title = getResources().getString(R.string.dvr_epg_conflict_dialog_title);
- String description = sb.toString();
- return new Guidance(title, description, null, null);
- }
-
- @Override
- public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
- Activity activity = getActivity();
- actions.add(new GuidedAction.Builder(activity)
- .id(DVR_EPG_RECORD)
- .title(getResources().getString(R.string.dvr_epg_record))
- .build());
- actions.add(new GuidedAction.Builder(activity)
- .id(DVR_EPG_NOT_RECORD)
- .title(getResources().getString(R.string.dvr_epg_do_not_record))
- .build());
- }
-
- @Override
- public void onGuidedActionClicked(GuidedAction action) {
- Program program = getEntry().program;
- if (action.getId() == DVR_EPG_RECORD) {
- getDvrManager().addSchedule(program, mConflicts);
- }
- dismissDialog();
- }
-}
diff --git a/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java b/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java
deleted file mode 100644
index d4d5cc41..00000000
--- a/src/com/android/tv/dvr/ui/DvrRecordDeleteFragment.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.android.tv.dvr.ui;
-
-import android.app.Activity;
-import android.os.Bundle;
-
-import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
-import android.support.v17.leanback.widget.GuidedAction;
-
-import com.android.tv.R;
-import com.android.tv.guide.ProgramManager.TableEntry;
-
-import java.util.List;
-
-public class DvrRecordDeleteFragment extends DvrGuidedStepFragment {
- private static final int ACTION_DELETE_YES = 1;
- private static final int ACTION_DELETE_NO = 2;
-
- public DvrRecordDeleteFragment(TableEntry entry) {
- super(entry);
- }
-
- @Override
- public Guidance onCreateGuidance(Bundle savedInstanceState) {
- String title = getResources().getString(R.string.epg_dvr_dialog_message_delete_schedule);
- return new Guidance(title, null, null, null);
- }
-
- @Override
- public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
- Activity activity = getActivity();
- actions.add(new GuidedAction.Builder(activity)
- .id(ACTION_DELETE_YES)
- .title(getResources().getString(android.R.string.yes))
- .build());
- actions.add(new GuidedAction.Builder(activity)
- .id(ACTION_DELETE_NO)
- .title(getResources().getString(android.R.string.no))
- .build());
- }
-
- @Override
- public void onGuidedActionClicked(GuidedAction action) {
- if (action.getId() == ACTION_DELETE_YES) {
- getDvrManager().removeScheduledRecording(getEntry().scheduledRecording);
- }
- dismissDialog();
- }
-}
diff --git a/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java
deleted file mode 100644
index 77e78ccc..00000000
--- a/src/com/android/tv/dvr/ui/DvrRecordScheduleFragment.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.android.tv.dvr.ui;
-
-import android.app.Activity;
-import android.app.FragmentManager;
-import android.os.Bundle;
-
-import android.support.v17.leanback.app.GuidedStepFragment;
-import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
-import android.support.v17.leanback.widget.GuidedAction;
-
-import com.android.tv.data.Program;
-import com.android.tv.dialog.SafeDismissDialogFragment;
-import com.android.tv.dvr.ScheduledRecording;
-import com.android.tv.guide.ProgramManager.TableEntry;
-import com.android.tv.MainActivity;
-import com.android.tv.R;
-
-import java.util.List;
-
-public class DvrRecordScheduleFragment extends DvrGuidedStepFragment {
- private static final int ACTION_RECORD_YES = 1;
- private static final int ACTION_RECORD_NO = 2;
-
- public DvrRecordScheduleFragment(TableEntry entry) {
- super(entry);
- }
-
- @Override
- public Guidance onCreateGuidance(Bundle savedInstanceState) {
- String title = getResources().getString(R.string.epg_dvr_dialog_message_schedule_recording);
- return new Guidance(title, null, null, null);
- }
-
- @Override
- public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
- Activity activity = getActivity();
- actions.add(new GuidedAction.Builder(activity)
- .id(ACTION_RECORD_YES)
- .title(getResources().getString(android.R.string.yes))
- .build());
- actions.add(new GuidedAction.Builder(activity)
- .id(ACTION_RECORD_NO)
- .title(getResources().getString(android.R.string.no))
- .build());
- }
-
- @Override
- public void onGuidedActionClicked(GuidedAction action) {
- TableEntry entry = getEntry();
- Program program = entry.program;
- final List<ScheduledRecording> conflicts =
- getDvrManager().getScheduledRecordingsThatConflict(program);
- if (action.getId() == ACTION_RECORD_YES) {
- if (conflicts.isEmpty()) {
- getDvrManager().addSchedule(program, conflicts);
- dismissDialog();
- } else {
- DvrRecordConflictFragment dvrConflict = new DvrRecordConflictFragment(entry);
- SafeDismissDialogFragment currentDialog =
- ((MainActivity) getActivity()).getOverlayManager().getCurrentDialog();
- if (currentDialog instanceof DvrDialogFragment) {
- FragmentManager fm = currentDialog.getChildFragmentManager();
- GuidedStepFragment.add(fm, dvrConflict, R.id.halfsized_dialog_host);
- }
- }
- } else if (action.getId() == ACTION_RECORD_NO) {
- dismissDialog();
- }
- }
-}
diff --git a/src/com/android/tv/dvr/ui/DvrScheduleFragment.java b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java
new file mode 100644
index 00000000..da6d1637
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrScheduleFragment.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.text.format.DateUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.ui.DvrConflictFragment.DvrProgramConflictFragment;
+import com.android.tv.util.Utils;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A fragment which asks the user the type of the recording.
+ * <p>
+ * The program should be episodic and the series recording should not had been created yet.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrScheduleFragment extends DvrGuidedStepFragment {
+ private static final String TAG = "DvrScheduleFragment";
+
+ private static final int ACTION_RECORD_EPISODE = 1;
+ private static final int ACTION_RECORD_SERIES = 2;
+
+ private Program mProgram;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mProgram = args.getParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM);
+ }
+ DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ SoftPreconditions.checkArgument(mProgram != null && mProgram.isEpisodic(), TAG,
+ "The program should be episodic: " + mProgram);
+ SeriesRecording seriesRecording = dvrManager.getSeriesRecording(mProgram);
+ SoftPreconditions.checkArgument(seriesRecording == null
+ || seriesRecording.isStopped(), TAG,
+ "The series recording should be stopped or null: " + seriesRecording);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public int onProvideTheme() {
+ return R.style.Theme_TV_Dvr_GuidedStep_Twoline_Action;
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_schedule_dialog_title);
+ Drawable icon = getResources().getDrawable(R.drawable.ic_dvr, null);
+ return new Guidance(title, null, null, icon);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Context context = getContext();
+ String description;
+ if (mProgram.getStartTimeUtcMillis() <= System.currentTimeMillis()) {
+ description = getString(R.string.dvr_action_record_episode_from_now_description,
+ DateUtils.formatDateTime(context, mProgram.getEndTimeUtcMillis(),
+ DateUtils.FORMAT_SHOW_TIME));
+ } else {
+ description = Utils.getDurationString(context, mProgram.getStartTimeUtcMillis(),
+ mProgram.getEndTimeUtcMillis(), true);
+ }
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_RECORD_EPISODE)
+ .title(R.string.dvr_action_record_episode)
+ .description(description)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_RECORD_SERIES)
+ .title(R.string.dvr_action_record_series)
+ .description(mProgram.getTitle())
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_RECORD_EPISODE) {
+ getDvrManager().addSchedule(mProgram);
+ List<ScheduledRecording> conflicts = getDvrManager().getConflictingSchedules(mProgram);
+ if (conflicts.isEmpty()) {
+ DvrUiHelper.showAddScheduleToast(getContext(), mProgram.getTitle(),
+ mProgram.getStartTimeUtcMillis(), mProgram.getEndTimeUtcMillis());
+ dismissDialog();
+ } else {
+ GuidedStepFragment fragment = new DvrProgramConflictFragment();
+ Bundle args = new Bundle();
+ args.putParcelable(DvrHalfSizedDialogFragment.KEY_PROGRAM, mProgram);
+ fragment.setArguments(args);
+ GuidedStepFragment.add(getFragmentManager(), fragment,
+ R.id.halfsized_dialog_host);
+ }
+ } else if (action.getId() == ACTION_RECORD_SERIES) {
+ SeriesRecording seriesRecording = TvApplication.getSingletons(getContext())
+ .getDvrDataManager().getSeriesRecording(mProgram.getSeriesId());
+ if (seriesRecording == null) {
+ seriesRecording = getDvrManager().addSeriesRecording(mProgram,
+ Collections.emptyList(), SeriesRecording.STATE_SERIES_STOPPED);
+ } else {
+ // Reset priority to the highest.
+ seriesRecording = SeriesRecording.buildFrom(seriesRecording)
+ .setPriority(TvApplication.getSingletons(getContext())
+ .getDvrScheduleManager().suggestNewSeriesPriority())
+ .build();
+ getDvrManager().updateSeriesRecording(seriesRecording);
+ }
+ DvrUiHelper.startSeriesSettingsActivity(getContext(),
+ seriesRecording.getId(), null, true, true, true);
+ dismissDialog();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java b/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java
new file mode 100644
index 00000000..f6e6ac26
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrSchedulesActivity.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.os.Bundle;
+import android.support.annotation.IntDef;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.EpisodicProgramLoadTask;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.SeriesRecordingScheduler;
+import com.android.tv.dvr.ui.list.DvrSchedulesFragment;
+import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Activity to show the list of recording schedules.
+ */
+public class DvrSchedulesActivity extends Activity {
+ /**
+ * The key for the type of the schedules which will be listed in the list. The type of the value
+ * should be {@link ScheduleListType}.
+ */
+ public static final String KEY_SCHEDULES_TYPE = "schedules_type";
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_FULL_SCHEDULE, TYPE_SERIES_SCHEDULE})
+ public @interface ScheduleListType {}
+ /**
+ * A type which means the activity will display the full scheduled recordings.
+ */
+ public static final int TYPE_FULL_SCHEDULE = 0;
+ /**
+ * A type which means the activity will display a scheduled recording list of a series
+ * recording.
+ */
+ public static final int TYPE_SERIES_SCHEDULE = 1;
+
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
+ // Pass null to prevent automatically re-creating fragments
+ super.onCreate(null);
+ setContentView(R.layout.activity_dvr_schedules);
+ int scheduleType = getIntent().getIntExtra(KEY_SCHEDULES_TYPE, TYPE_FULL_SCHEDULE);
+ if (scheduleType == TYPE_FULL_SCHEDULE) {
+ DvrSchedulesFragment schedulesFragment = new DvrSchedulesFragment();
+ schedulesFragment.setArguments(getIntent().getExtras());
+ getFragmentManager().beginTransaction().add(
+ R.id.fragment_container, schedulesFragment).commit();
+ } else if (scheduleType == TYPE_SERIES_SCHEDULE) {
+ final ProgressDialog dialog = ProgressDialog.show(this, null, getString(
+ R.string.dvr_series_schedules_progress_message_reading_programs));
+ SeriesRecording seriesRecording = getIntent().getExtras()
+ .getParcelable(DvrSeriesSchedulesFragment
+ .SERIES_SCHEDULES_KEY_SERIES_RECORDING);
+ // To get programs faster, hold the update of the series schedules.
+ SeriesRecordingScheduler.getInstance(this).pauseUpdate();
+ new EpisodicProgramLoadTask(this, Collections.singletonList(seriesRecording)) {
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ SeriesRecordingScheduler.getInstance(DvrSchedulesActivity.this).resumeUpdate();
+ dialog.dismiss();
+ Bundle args = getIntent().getExtras();
+ args.putParcelableArrayList(DvrSeriesSchedulesFragment
+ .SERIES_SCHEDULES_KEY_SERIES_PROGRAMS, new ArrayList<>(programs));
+ DvrSeriesSchedulesFragment schedulesFragment = new DvrSeriesSchedulesFragment();
+ schedulesFragment.setArguments(args);
+ getFragmentManager().beginTransaction().add(
+ R.id.fragment_container, schedulesFragment).commit();
+ }
+ }.setLoadCurrentProgram(true)
+ .setLoadDisallowedProgram(true)
+ .setLoadScheduledEpisode(true)
+ .setIgnoreChannelOption(true)
+ .execute();
+ } else {
+ finish();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java
new file mode 100644
index 00000000..f57e4b05
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrSeriesDeletionActivity.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.ui.SeriesDeletionFragment;
+import com.android.tv.ui.sidepanel.SettingsFragment;
+
+/**
+ * Activity to show details view in DVR.
+ */
+public class DvrSeriesDeletionActivity extends Activity {
+ /**
+ * Name of series id added to the Intent.
+ */
+ public static final String SERIES_RECORDING_ID = "series_recording_id";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_dvr_series_settings);
+ // Check savedInstanceState to prevent that activity is being showed with animation.
+ if (savedInstanceState == null) {
+ SeriesDeletionFragment deletionFragment = new SeriesDeletionFragment();
+ deletionFragment.setArguments(getIntent().getExtras());
+ GuidedStepFragment.addAsRoot(this, deletionFragment, R.id.dvr_settings_view_frame);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.java
new file mode 100644
index 00000000..1a0d13d3
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledDialogActivity.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+
+import com.android.tv.R;
+
+public class DvrSeriesScheduledDialogActivity extends Activity {
+ /**
+ * Name of series recording id added to the Intent.
+ */
+ public static final String SERIES_RECORDING_ID = "series_recording_id";
+
+ /**
+ * Name of flag to check if the dialog should show view schedule option.
+ */
+ public static final String SHOW_VIEW_SCHEDULE_OPTION = "show_view_schedule_option";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.halfsized_dialog);
+ if (savedInstanceState == null) {
+ DvrSeriesScheduledFragment dvrSeriesScheduledFragment =
+ new DvrSeriesScheduledFragment();
+ dvrSeriesScheduledFragment.setArguments(getIntent().getExtras());
+ GuidedStepFragment.addAsRoot(this, dvrSeriesScheduledFragment,
+ R.id.halfsized_dialog_host);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java
new file mode 100644
index 00000000..1173df46
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrSeriesScheduledFragment.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.v17.leanback.widget.GuidanceStylist;
+import android.support.v17.leanback.widget.GuidedAction;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.ui.list.DvrSeriesSchedulesFragment;
+
+import java.util.List;
+
+public class DvrSeriesScheduledFragment extends DvrGuidedStepFragment {
+ private final static long SERIES_RECORDING_ID_NOT_SET = -1;
+
+ private final static int ACTION_VIEW_SCHEDULES = 1;
+
+ private DvrScheduleManager mDvrScheduleManager;
+ private SeriesRecording mSeriesRecording;
+ private boolean mShowViewScheduleOption;
+
+ private int mSchedulesAddedCount = 0;
+ private boolean mHasConflict = false;
+ private int mInThisSeriesConflictCount = 0;
+ private int mOutThisSeriesConflictCount = 0;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ long seriesRecordingId = getArguments().getLong(
+ DvrSeriesScheduledDialogActivity.SERIES_RECORDING_ID, SERIES_RECORDING_ID_NOT_SET);
+ if (seriesRecordingId == SERIES_RECORDING_ID_NOT_SET) {
+ getActivity().finish();
+ return;
+ }
+ mShowViewScheduleOption = getArguments().getBoolean(
+ DvrSeriesScheduledDialogActivity.SHOW_VIEW_SCHEDULE_OPTION);
+ mDvrScheduleManager = TvApplication.getSingletons(context).getDvrScheduleManager();
+ mSeriesRecording = TvApplication.getSingletons(context).getDvrDataManager()
+ .getSeriesRecording(seriesRecordingId);
+ if (mSeriesRecording == null) {
+ getActivity().finish();
+ return;
+ }
+ mSchedulesAddedCount = TvApplication.getSingletons(getContext()).getDvrManager()
+ .getAvailableScheduledRecording(mSeriesRecording.getId()).size();
+ List<ScheduledRecording> conflictingRecordings =
+ mDvrScheduleManager.getConflictingSchedules(mSeriesRecording);
+ mHasConflict = !conflictingRecordings.isEmpty();
+ for (ScheduledRecording recording : conflictingRecordings) {
+ if (recording.getSeriesRecordingId() == mSeriesRecording.getId()) {
+ ++mInThisSeriesConflictCount;
+ } else {
+ ++mOutThisSeriesConflictCount;
+ }
+ }
+ }
+
+ @Override
+ public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_series_recording_dialog_title);
+ Drawable icon;
+ if (!mHasConflict) {
+ icon = getResources().getDrawable(R.drawable.ic_check_circle_white_48dp, null);
+ } else {
+ icon = getResources().getDrawable(R.drawable.ic_error_white_48dp, null);
+ }
+ return new GuidanceStylist.Guidance(title, getDescription(), null, icon);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ Context context = getContext();
+ actions.add(new GuidedAction.Builder(context)
+ .clickAction(GuidedAction.ACTION_ID_OK)
+ .build());
+ if (mShowViewScheduleOption) {
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_VIEW_SCHEDULES)
+ .title(R.string.dvr_action_view_schedules)
+ .build());
+ }
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_VIEW_SCHEDULES) {
+ Intent intent = new Intent(getActivity(), DvrSchedulesActivity.class);
+ intent.putExtra(DvrSchedulesActivity.KEY_SCHEDULES_TYPE, DvrSchedulesActivity
+ .TYPE_SERIES_SCHEDULE);
+ intent.putExtra(DvrSeriesSchedulesFragment.SERIES_SCHEDULES_KEY_SERIES_RECORDING,
+ mSeriesRecording);
+ startActivity(intent);
+ }
+ getActivity().finish();
+ }
+
+ private String getDescription() {
+ if (!mHasConflict) {
+ return getResources().getQuantityString(
+ R.plurals.dvr_series_recording_scheduled_no_conflict, mSchedulesAddedCount,
+ mSchedulesAddedCount, mSeriesRecording.getTitle());
+ } else {
+ // mInThisSeriesConflictCount equals 0 and mOutThisSeriesConflictCount equals 0 means
+ // mHasConflict is false. So we don't need to check that case.
+ if (mInThisSeriesConflictCount != 0 && mOutThisSeriesConflictCount != 0) {
+ return getResources().getQuantityString(R.plurals
+ .dvr_series_recording_scheduled_this_and_other_series_conflict,
+ mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(),
+ mInThisSeriesConflictCount + mOutThisSeriesConflictCount);
+ } else if (mInThisSeriesConflictCount != 0) {
+ return getResources().getQuantityString(R.plurals
+ .dvr_series_recording_scheduled_only_this_series_conflict,
+ mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(),
+ mInThisSeriesConflictCount);
+ } else {
+ if (mOutThisSeriesConflictCount == 1) {
+ return getResources().getQuantityString(R.plurals
+ .dvr_series_recording_scheduled_only_other_series_one_conflict,
+ mSchedulesAddedCount, mSchedulesAddedCount,
+ mSeriesRecording.getTitle());
+ } else {
+ return getResources().getQuantityString(R.plurals
+ .dvr_series_recording_scheduled_only_other_series_conflict,
+ mSchedulesAddedCount, mSchedulesAddedCount, mSeriesRecording.getTitle(),
+ mOutThisSeriesConflictCount);
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java
new file mode 100644
index 00000000..3f7671b3
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrSeriesSettingsActivity.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+
+/**
+ * Activity to show details view in DVR.
+ */
+public class DvrSeriesSettingsActivity extends Activity {
+ /**
+ * Name of series id added to the Intent.
+ * Type: Long
+ */
+ public static final String SERIES_RECORDING_ID = "series_recording_id";
+ /**
+ * Name of the boolean flag to decide if the series recording with empty schedule and recording
+ * will be removed.
+ */
+ public static final String REMOVE_EMPTY_SERIES_RECORDING = "remove_empty_series_recording";
+ /**
+ * Name of the boolean flag to decide if the setting fragment should be translucent.
+ */
+ public static final String IS_WINDOW_TRANSLUCENT = "windows_translucent";
+ /**
+ * Name of the channel id list. If the channel list is given, we show the channels
+ * from the values in channel option.
+ * Type: Long array
+ */
+ public static final String CHANNEL_ID_LIST = "channel_id_list";
+
+ /**
+ * Name of the boolean flag to check if the confirm dialog should show view schedule option.
+ */
+ public static final String SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG =
+ "show_view_schedule_option_in_dialog";
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, true);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_dvr_series_settings);
+ long seriesRecordingId = getIntent().getLongExtra(SERIES_RECORDING_ID, -1);
+ SoftPreconditions.checkArgument(seriesRecordingId != -1);
+
+ if (savedInstanceState == null) {
+ SeriesSettingsFragment settingFragment = new SeriesSettingsFragment();
+ settingFragment.setArguments(getIntent().getExtras());
+ GuidedStepFragment.addAsRoot(this, settingFragment, R.id.dvr_settings_view_frame);
+ }
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ if (!getIntent().getExtras().getBoolean(IS_WINDOW_TRANSLUCENT, true)) {
+ getWindow().setBackgroundDrawable(
+ new ColorDrawable(getColor(R.color.common_tv_background)));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java
new file mode 100644
index 00000000..c3867886
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrStopRecordingFragment.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.ScheduledRecording;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+
+/**
+ * A fragment which asks the user to make a recording schedule for the program.
+ * <p>
+ * If the program belongs to a series and the series recording is not created yet, we will show the
+ * option to record all the episodes of the series.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrStopRecordingFragment extends DvrGuidedStepFragment {
+ /**
+ * The action ID for the stop action.
+ */
+ public static final int ACTION_STOP = 1;
+ /**
+ * Key for the program.
+ * Type: {@link com.android.tv.data.Program}.
+ */
+ public static final String KEY_REASON = "DvrStopRecordingFragment.type";
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({REASON_USER_STOP, REASON_ON_CONFLICT})
+ public @interface ReasonType {}
+ /**
+ * The dialog is shown because users want to stop some currently recording program.
+ */
+ public static final int REASON_USER_STOP = 1;
+ /**
+ * The dialog is shown because users want to record some program that is conflict to the
+ * current recording program.
+ */
+ public static final int REASON_ON_CONFLICT = 2;
+
+ private ScheduledRecording mSchedule;
+ private DvrDataManager mDvrDataManager;
+ private @ReasonType int mStopReason;
+
+ private final ScheduledRecordingListener mScheduledRecordingListener =
+ new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... schedules) { }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getId() == mSchedule.getId()) {
+ dismissDialog();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ if (schedule.getId() == mSchedule.getId()
+ && schedule.getState()
+ != ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ dismissDialog();
+ return;
+ }
+ }
+ }
+ };
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ Bundle args = getArguments();
+ long channelId = args.getLong(DvrHalfSizedDialogFragment.KEY_CHANNEL_ID);
+ mSchedule = getDvrManager().getCurrentRecording(channelId);
+ if (mSchedule == null) {
+ dismissDialog();
+ return;
+ }
+ mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ mStopReason = args.getInt(KEY_REASON);
+ }
+
+ @Override
+ public void onDetach() {
+ if (mDvrDataManager != null) {
+ mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ }
+ super.onDetach();
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_stop_recording_dialog_title);
+ String description;
+ if (mStopReason == REASON_ON_CONFLICT) {
+ String programTitle = mSchedule.getProgramTitle();
+ if (TextUtils.isEmpty(programTitle)) {
+ ChannelDataManager channelDataManager =
+ TvApplication.getSingletons(getActivity()).getChannelDataManager();
+ Channel channel = channelDataManager.getChannel(mSchedule.getChannelId());
+ programTitle = channel.getDisplayName();
+ }
+ description = getString(R.string.dvr_stop_recording_dialog_description_on_conflict,
+ mSchedule.getProgramTitle());
+ } else {
+ description = getString(R.string.dvr_stop_recording_dialog_description);
+ }
+ Drawable image = getResources().getDrawable(R.drawable.ic_warning_white_96dp, null);
+ return new Guidance(title, description, null, image);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Context context = getContext();
+ actions.add(new GuidedAction.Builder(context)
+ .id(ACTION_STOP)
+ .title(R.string.dvr_action_stop)
+ .build());
+ actions.add(new GuidedAction.Builder(context)
+ .clickAction(GuidedAction.ACTION_ID_CANCEL)
+ .build());
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java
new file mode 100644
index 00000000..5b880bd6
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingDialogFragment.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.DialogFragment;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.R;
+
+/**
+ * A dialog fragment which contains {@link DvrStopSeriesRecordingFragment}.
+ */
+public class DvrStopSeriesRecordingDialogFragment extends DialogFragment {
+ public static final String DIALOG_TAG = "dialog_tag";
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.halfsized_dialog, container, false);
+ GuidedStepFragment fragment = new DvrStopSeriesRecordingFragment();
+ fragment.setArguments(getArguments());
+ GuidedStepFragment.add(getChildFragmentManager(), fragment, R.id.halfsized_dialog_host);
+ return view;
+ }
+
+ @Override
+ public int getTheme() {
+ return R.style.Theme_TV_dialog_HalfSizedDialog;
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java
new file mode 100644
index 00000000..feaa2357
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/DvrStopSeriesRecordingFragment.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A fragment which asks the user to stop series recording.
+ */
+public class DvrStopSeriesRecordingFragment extends DvrGuidedStepFragment {
+ /**
+ * Key for the series recording to be stopped.
+ */
+ public static final String KEY_SERIES_RECORDING = "key_series_recoridng";
+
+ private static final int ACTION_STOP_SERIES_RECORDING = 1;
+
+ private SeriesRecording mSeriesRecording;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mSeriesRecording = getArguments().getParcelable(KEY_SERIES_RECORDING);
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public GuidanceStylist.Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title = getString(R.string.dvr_series_schedules_stop_dialog_title);
+ String description = getString(R.string.dvr_series_schedules_stop_dialog_description);
+ Drawable icon = getContext().getDrawable(R.drawable.ic_dvr_delete);
+ return new GuidanceStylist.Guidance(title, description, null, icon);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions, Bundle savedInstanceState) {
+ Activity activity = getActivity();
+ actions.add(new GuidedAction.Builder(activity)
+ .id(ACTION_STOP_SERIES_RECORDING)
+ .title(R.string.dvr_series_schedules_stop_dialog_action_stop)
+ .build());
+ actions.add(new GuidedAction.Builder(activity)
+ .clickAction(GuidedAction.ACTION_ID_CANCEL)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ if (action.getId() == ACTION_STOP_SERIES_RECORDING) {
+ ApplicationSingletons singletons = TvApplication.getSingletons(getContext());
+ DvrManager dvrManager = singletons.getDvrManager();
+ DvrDataManager dataManager = singletons.getDvrDataManager();
+ List<ScheduledRecording> toDelete = new ArrayList<>();
+ for (ScheduledRecording r : dataManager.getAvailableScheduledRecordings()) {
+ if (r.getSeriesRecordingId() == mSeriesRecording.getId()) {
+ if (r.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ toDelete.add(r);
+ } else {
+ dvrManager.stopRecording(r);
+ }
+ }
+ }
+ if (!toDelete.isEmpty()) {
+ dvrManager.forceRemoveScheduledRecording(ScheduledRecording.toArray(toDelete));
+ }
+ dvrManager.updateSeriesRecording(SeriesRecording.buildFrom(mSeriesRecording)
+ .setState(SeriesRecording.STATE_SERIES_STOPPED).build());
+ }
+ dismissDialog();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/EmptyItemPresenter.java b/src/com/android/tv/dvr/ui/EmptyItemPresenter.java
deleted file mode 100644
index c0305128..00000000
--- a/src/com/android/tv/dvr/ui/EmptyItemPresenter.java
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.dvr.ui;
-
-import android.content.res.Resources;
-import android.graphics.Color;
-import android.support.v17.leanback.widget.Presenter;
-import android.view.Gravity;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import com.android.tv.R;
-import com.android.tv.TvApplication;
-import com.android.tv.data.ChannelDataManager;
-import com.android.tv.util.Utils;
-
-/**
- * Shows the item "NONE". Used for rows with now items.
- */
-public class EmptyItemPresenter extends Presenter {
-
- private final DvrBrowseFragment mMainFragment;
-
- public EmptyItemPresenter(DvrBrowseFragment mainFragment) {
- mMainFragment = mainFragment;
- }
-
- @Override
- public ViewHolder onCreateViewHolder(ViewGroup parent) {
- TextView view = new TextView(parent.getContext());
- Resources resources = view.getResources();
- view.setLayoutParams(new ViewGroup.LayoutParams(
- resources.getDimensionPixelSize(R.dimen.dvr_card_layout_width),
- resources.getDimensionPixelSize(R.dimen.dvr_card_layout_width)));
- view.setFocusable(true);
- view.setFocusableInTouchMode(true);
- view.setBackgroundColor(
- Utils.getColor(mMainFragment.getResources(), R.color.setup_background));
- view.setTextColor(Color.WHITE);
- view.setGravity(Gravity.CENTER);
- return new ViewHolder(view);
- }
-
- @Override
- public void onBindViewHolder(ViewHolder viewHolder, Object recording) {
- ((TextView) viewHolder.view).setText(
- viewHolder.view.getContext().getString(R.string.dvr_msg_no_recording_on_the_row));
- }
-
- @Override
- public void onUnbindViewHolder(ViewHolder viewHolder) { }
-}
diff --git a/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java
new file mode 100644
index 00000000..d4d4d8ab
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/FullScheduleCardHolder.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+/**
+ * Special object for schedule preview;
+ */
+final class FullScheduleCardHolder {
+ /**
+ * Full schedule card holder.
+ */
+ static final FullScheduleCardHolder FULL_SCHEDULE_CARD_HOLDER = new FullScheduleCardHolder();
+
+ private FullScheduleCardHolder() { }
+}
diff --git a/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java b/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java
new file mode 100644
index 00000000..7dd85f45
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/FullSchedulesCardPresenter.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.support.v17.leanback.widget.Presenter;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.util.Utils;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}.
+ */
+public class FullSchedulesCardPresenter extends Presenter {
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ Context context = parent.getContext();
+ RecordingCardView view = new RecordingCardView(context);
+ return new ScheduledRecordingViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(ViewHolder baseHolder, Object o) {
+ final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder;
+ final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
+ final Context context = viewHolder.view.getContext();
+
+ cardView.setImage(context.getDrawable(R.drawable.dvr_full_schedule));
+ cardView.setTitle(context.getString(R.string.dvr_full_schedule_card_view_title));
+ List<ScheduledRecording> scheduledRecordings = TvApplication.getSingletons(context)
+ .getDvrDataManager().getAvailableScheduledRecordings();
+ int fullDays = 0;
+ if (!scheduledRecordings.isEmpty()) {
+ fullDays = Utils.computeDateDifference(System.currentTimeMillis(),
+ Collections.max(scheduledRecordings, ScheduledRecording.START_TIME_COMPARATOR)
+ .getStartTimeMs()) + 1;
+ }
+ cardView.setContent(context.getResources().getQuantityString(
+ R.plurals.dvr_full_schedule_card_view_content, fullDays, fullDays), null);
+
+ View.OnClickListener clickListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ DvrUiHelper.startSchedulesActivity(context, null);
+ }
+ };
+ baseHolder.view.setOnClickListener(clickListener);
+ }
+
+ @Override
+ public void onUnbindViewHolder(ViewHolder baseHolder) {
+ ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder;
+ final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
+ cardView.reset();
+ }
+
+ private static final class ScheduledRecordingViewHolder extends ViewHolder {
+ ScheduledRecordingViewHolder(RecordingCardView view) {
+ super(view);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java b/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java
index dc89a8e0..d320816e 100644
--- a/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java
+++ b/src/com/android/tv/dvr/ui/HalfSizedDialogFragment.java
@@ -1,21 +1,83 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
package com.android.tv.dvr.ui;
+import android.content.DialogInterface;
import android.os.Bundle;
+import android.os.Handler;
+import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import com.android.tv.dialog.SafeDismissDialogFragment;
import com.android.tv.R;
+import com.android.tv.dialog.SafeDismissDialogFragment;
+
+import java.util.concurrent.TimeUnit;
public class HalfSizedDialogFragment extends SafeDismissDialogFragment {
public static final String DIALOG_TAG = HalfSizedDialogFragment.class.getSimpleName();
public static final String TRACKER_LABEL = "Half sized dialog";
+ private static final long AUTO_DISMISS_TIME_THRESHOLD_MS = TimeUnit.SECONDS.toMillis(30);
+
+ private OnActionClickListener mOnActionClickListener;
+
+ private Handler mHandler = new Handler();
+ private Runnable mAutoDismisser = new Runnable() {
+ @Override
+ public void run() {
+ dismiss();
+ }
+ };
+
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
- return inflater.inflate(R.layout.halfsized_dialog, null);
+ return inflater.inflate(R.layout.halfsized_dialog, container, false);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ getDialog().setOnKeyListener(new DialogInterface.OnKeyListener() {
+ public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent keyEvent) {
+ mHandler.removeCallbacks(mAutoDismisser);
+ mHandler.postDelayed(mAutoDismisser, AUTO_DISMISS_TIME_THRESHOLD_MS);
+ return false;
+ }
+ });
+ mHandler.postDelayed(mAutoDismisser, AUTO_DISMISS_TIME_THRESHOLD_MS);
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (mOnActionClickListener != null) {
+ // Dismisses the dialog to prevent the callback being forgotten during
+ // fragment re-creating.
+ dismiss();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ mHandler.removeCallbacks(mAutoDismisser);
}
@Override
@@ -27,4 +89,29 @@ public class HalfSizedDialogFragment extends SafeDismissDialogFragment {
public String getTrackerLabel() {
return TRACKER_LABEL;
}
-}
+
+ /**
+ * Sets {@link OnActionClickListener} for the dialog fragment. If listener is set, the dialog
+ * will be automatically closed when it's paused to prevent the fragment being re-created by
+ * the framework, which will result the listener being forgotten.
+ */
+ public void setOnActionClickListener(OnActionClickListener listener) {
+ mOnActionClickListener = listener;
+ }
+
+ /**
+ * Returns {@link OnActionClickListener} for sub-classes or any inner fragments.
+ */
+ protected OnActionClickListener getOnActionClickListener() {
+ return mOnActionClickListener;
+ }
+
+ /**
+ * An interface to provide callbacks for half-sized dialogs. Subclasses or inner fragments
+ * should invoke {@link OnActionClickListener#onActionClick(long)} and provide the identifier
+ * of the action user clicked.
+ */
+ public interface OnActionClickListener {
+ void onActionClick(long actionId);
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java b/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java
new file mode 100644
index 00000000..158bd824
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/PrioritySettingsFragment.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.FragmentManager;
+import android.content.Context;
+import android.graphics.Typeface;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.SeriesRecording;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Fragment for DVR series recording settings.
+ */
+public class PrioritySettingsFragment extends GuidedStepFragment {
+ /**
+ * Name of series recording id starting the fragment.
+ * Type: Long
+ */
+ public static final String COME_FROM_SERIES_RECORDING_ID = "series_recording_id";
+
+ private static final int ONE_TIME_RECORDING_ID = 0;
+ // button action's IDs are negative.
+ private static final long ACTION_ID_SAVE = -100L;
+
+ private final List<SeriesRecording> mSeriesRecordings = new ArrayList<>();
+
+ private SeriesRecording mSelectedRecording;
+ private SeriesRecording mComeFromSeriesRecording;
+ private float mSelectedActionElevation;
+ private int mActionColor;
+ private int mSelectedActionColor;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mSeriesRecordings.clear();
+ mSeriesRecordings.add(new SeriesRecording.Builder()
+ .setTitle(getString(R.string.dvr_priority_action_one_time_recording))
+ .setPriority(Long.MAX_VALUE)
+ .setId(ONE_TIME_RECORDING_ID)
+ .build());
+ DvrDataManager dvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ long comeFromSeriesRecordingId =
+ getArguments().getLong(COME_FROM_SERIES_RECORDING_ID, -1);
+ for (SeriesRecording series : dvrDataManager.getSeriesRecordings()) {
+ if (series.getState() == SeriesRecording.STATE_SERIES_NORMAL
+ || series.getId() == comeFromSeriesRecordingId) {
+ mSeriesRecordings.add(series);
+ }
+ }
+ mSeriesRecordings.sort(SeriesRecording.PRIORITY_COMPARATOR);
+ mComeFromSeriesRecording = dvrDataManager.getSeriesRecording(comeFromSeriesRecordingId);
+ mSelectedActionElevation = getResources().getDimension(R.dimen.card_elevation_normal);
+ mActionColor = getResources().getColor(R.color.dvr_guided_step_action_text_color, null);
+ mSelectedActionColor =
+ getResources().getColor(R.color.dvr_guided_step_action_text_color_selected, null);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ setSelectedActionPosition(mComeFromSeriesRecording == null ? 1
+ : mSeriesRecordings.indexOf(mComeFromSeriesRecording));
+ }
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String breadcrumb = mComeFromSeriesRecording == null ? null
+ : mComeFromSeriesRecording.getTitle();
+ return new Guidance(getString(R.string.dvr_priority_title),
+ getString(R.string.dvr_priority_description), breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ int position = 0;
+ for (SeriesRecording seriesRecording : mSeriesRecordings) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(position++)
+ .title(seriesRecording.getTitle())
+ .build());
+ }
+ }
+
+ @Override
+ public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_SAVE)
+ .title(getString(R.string.dvr_priority_button_action_save))
+ .build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .clickAction(GuidedAction.ACTION_ID_CANCEL)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ long actionId = action.getId();
+ if (actionId == ACTION_ID_SAVE) {
+ DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ int size = mSeriesRecordings.size();
+ for (int i = 1; i < size; ++i) {
+ long priority = DvrScheduleManager.suggestSeriesPriority(size - i);
+ SeriesRecording seriesRecording = mSeriesRecordings.get(i);
+ if (seriesRecording.getPriority() != priority) {
+ dvrManager.updateSeriesRecording(SeriesRecording.buildFrom(seriesRecording)
+ .setPriority(priority).build());
+ }
+ }
+ FragmentManager fragmentManager = getFragmentManager();
+ fragmentManager.popBackStack();
+ } else if (actionId == GuidedAction.ACTION_ID_CANCEL) {
+ FragmentManager fragmentManager = getFragmentManager();
+ fragmentManager.popBackStack();
+ } else if (mSelectedRecording == null) {
+ mSelectedRecording = mSeriesRecordings.get((int) actionId);
+ for (int i = 0; i < mSeriesRecordings.size(); ++i) {
+ updateItem(i);
+ }
+ } else {
+ mSelectedRecording = null;
+ for (int i = 0; i < mSeriesRecordings.size(); ++i) {
+ updateItem(i);
+ }
+ }
+ }
+
+ @Override
+ public void onGuidedActionFocused(GuidedAction action) {
+ super.onGuidedActionFocused(action);
+ if (mSelectedRecording == null) {
+ return;
+ }
+ if (action.getId() < 0) {
+ int selectedPosition = mSeriesRecordings.indexOf(mSelectedRecording);
+ mSelectedRecording = null;
+ for (int i = 0; i < mSeriesRecordings.size(); ++i) {
+ updateItem(i);
+ }
+ return;
+ }
+ int position = (int) action.getId();
+ int previousPosition = mSeriesRecordings.indexOf(mSelectedRecording);
+ mSeriesRecordings.remove(mSelectedRecording);
+ mSeriesRecordings.add(position, mSelectedRecording);
+ updateItem(previousPosition);
+ updateItem(position);
+ notifyActionChanged(previousPosition);
+ notifyActionChanged(position);
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateButtonActionsStylist() {
+ return new DvrGuidedActionsStylist(true);
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateActionsStylist() {
+ return new DvrGuidedActionsStylist(false) {
+ @Override
+ public void onBindViewHolder(ViewHolder vh, GuidedAction action) {
+ super.onBindViewHolder(vh, action);
+ updateItem(vh.itemView, (int) action.getId());
+ }
+
+ @Override
+ public int onProvideItemLayoutId() {
+ return R.layout.priority_settings_action_item;
+ }
+ };
+ }
+
+ private void updateItem(int position) {
+ View itemView = getActionItemView(position);
+ if (itemView == null) {
+ return;
+ }
+ updateItem(itemView, position);
+ }
+
+ private void updateItem(View itemView, int position) {
+ GuidedAction action = getActions().get(position);
+ action.setTitle(mSeriesRecordings.get(position).getTitle());
+ boolean selected = mSelectedRecording != null
+ && mSeriesRecordings.indexOf(mSelectedRecording) == position;
+ TextView titleView = (TextView) itemView.findViewById(R.id.guidedactions_item_title);
+ ImageView imageView = (ImageView) itemView.findViewById(R.id.guidedactions_item_tail_image);
+ if (position == 0) {
+ // one-time recording
+ itemView.setBackgroundResource(R.drawable.setup_selector_background);
+ imageView.setVisibility(View.GONE);
+ itemView.setFocusable(false);
+ itemView.setElevation(0);
+ // strings.xml <i> tag doesn't work.
+ titleView.setTypeface(titleView.getTypeface(), Typeface.ITALIC);
+ } else if (mSelectedRecording == null) {
+ titleView.setTextColor(mActionColor);
+ itemView.setBackgroundResource(R.drawable.setup_selector_background);
+ imageView.setImageResource(R.drawable.ic_draggable_white);
+ imageView.setVisibility(View.VISIBLE);
+ itemView.setFocusable(true);
+ itemView.setElevation(0);
+ titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL);
+ } else if (selected) {
+ titleView.setTextColor(mSelectedActionColor);
+ itemView.setBackgroundResource(R.drawable.priority_settings_action_item_selected);
+ imageView.setImageResource(R.drawable.ic_dragging_grey);
+ imageView.setVisibility(View.VISIBLE);
+ itemView.setFocusable(true);
+ itemView.setElevation(mSelectedActionElevation);
+ titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL);
+ } else {
+ titleView.setTextColor(mActionColor);
+ itemView.setBackgroundResource(R.drawable.setup_selector_background);
+ imageView.setVisibility(View.INVISIBLE);
+ itemView.setFocusable(true);
+ itemView.setElevation(0);
+ titleView.setTypeface(titleView.getTypeface(), Typeface.NORMAL);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java b/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java
new file mode 100644
index 00000000..e698b8a2
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/RecordedProgramDetailsFragment.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.res.Resources;
+import android.media.tv.TvInputManager;
+import android.os.Bundle;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.SparseArrayObjectAdapter;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.dvr.RecordedProgram;
+
+/**
+ * {@link DetailsFragment} for recorded program in DVR.
+ */
+public class RecordedProgramDetailsFragment extends DvrDetailsFragment
+ implements DvrDataManager.RecordedProgramListener {
+ private static final int ACTION_RESUME_PLAYING = 1;
+ private static final int ACTION_PLAY_FROM_BEGINNING = 2;
+ private static final int ACTION_DELETE_RECORDING = 3;
+
+ private DvrWatchedPositionManager mDvrWatchedPositionManager;
+
+ private RecordedProgram mRecordedProgram;
+ private DetailsContent mDetailsContent;
+ private boolean mPaused;
+ private DvrDataManager mDvrDataManager;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ mDvrDataManager = TvApplication.getSingletons(getContext()).getDvrDataManager();
+ mDvrDataManager.addRecordedProgramListener(this);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onCreateInternal() {
+ mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity())
+ .getDvrWatchedPositionManager();
+ setDetailsOverviewRow(mDetailsContent);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (mPaused) {
+ updateActions();
+ mPaused = false;
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mPaused = true;
+ }
+
+ @Override
+ public void onDestroy() {
+ mDvrDataManager.removeRecordedProgramListener(this);
+ super.onDestroy();
+ }
+
+ @Override
+ protected boolean onLoadRecordingDetails(Bundle args) {
+ long recordedProgramId = args.getLong(DvrDetailsActivity.RECORDING_ID);
+ mRecordedProgram = mDvrDataManager.getRecordedProgram(recordedProgramId);
+ if (mRecordedProgram == null) {
+ // notify super class to end activity before initializing anything
+ return false;
+ }
+ mDetailsContent = createDetailsContent();
+ return true;
+ }
+
+ private DetailsContent createDetailsContent() {
+ Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(mRecordedProgram.getChannelId());
+ String description = TextUtils.isEmpty(mRecordedProgram.getLongDescription())
+ ? mRecordedProgram.getDescription() : mRecordedProgram.getLongDescription();
+ return new DetailsContent.Builder()
+ .setTitle(getTitleFromProgram(mRecordedProgram, channel))
+ .setStartTimeUtcMillis(mRecordedProgram.getStartTimeUtcMillis())
+ .setEndTimeUtcMillis(mRecordedProgram.getEndTimeUtcMillis())
+ .setDescription(description)
+ .setImageUris(mRecordedProgram, channel)
+ .build();
+ }
+
+ @Override
+ protected SparseArrayObjectAdapter onCreateActionsAdapter() {
+ SparseArrayObjectAdapter adapter =
+ new SparseArrayObjectAdapter(new ActionPresenterSelector());
+ Resources res = getResources();
+ if (mDvrWatchedPositionManager.getWatchedStatus(mRecordedProgram)
+ == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) {
+ adapter.set(ACTION_RESUME_PLAYING, new Action(ACTION_RESUME_PLAYING,
+ res.getString(R.string.dvr_detail_resume_play), null,
+ res.getDrawable(R.drawable.lb_ic_play)));
+ adapter.set(ACTION_PLAY_FROM_BEGINNING, new Action(ACTION_PLAY_FROM_BEGINNING,
+ res.getString(R.string.dvr_detail_play_from_beginning), null,
+ res.getDrawable(R.drawable.lb_ic_replay)));
+ } else {
+ adapter.set(ACTION_PLAY_FROM_BEGINNING, new Action(ACTION_PLAY_FROM_BEGINNING,
+ res.getString(R.string.dvr_detail_watch), null,
+ res.getDrawable(R.drawable.lb_ic_play)));
+ }
+ adapter.set(ACTION_DELETE_RECORDING, new Action(ACTION_DELETE_RECORDING,
+ res.getString(R.string.dvr_detail_delete), null,
+ res.getDrawable(R.drawable.ic_delete_32dp)));
+ return adapter;
+ }
+
+ @Override
+ protected OnActionClickedListener onCreateOnActionClickedListener() {
+ return new OnActionClickedListener() {
+ @Override
+ public void onActionClicked(Action action) {
+ if (action.getId() == ACTION_PLAY_FROM_BEGINNING) {
+ startPlayback(mRecordedProgram, TvInputManager.TIME_SHIFT_INVALID_TIME);
+ } else if (action.getId() == ACTION_RESUME_PLAYING) {
+ startPlayback(mRecordedProgram, mDvrWatchedPositionManager
+ .getWatchedPosition(mRecordedProgram.getId()));
+ } else if (action.getId() == ACTION_DELETE_RECORDING) {
+ DvrManager dvrManager = TvApplication
+ .getSingletons(getActivity()).getDvrManager();
+ dvrManager.removeRecordedProgram(mRecordedProgram);
+ getActivity().finish();
+ }
+ }
+ };
+ }
+
+ @Override
+ public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) { }
+
+ @Override
+ public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) { }
+
+ @Override
+ public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (recordedProgram.getId() == mRecordedProgram.getId()) {
+ getActivity().finish();
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java
index 0b656bdc..1bf34310 100644
--- a/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java
+++ b/src/com/android/tv/dvr/ui/RecordedProgramPresenter.java
@@ -17,105 +17,166 @@
package com.android.tv.dvr.ui;
import android.app.Activity;
-import android.app.AlertDialog;
import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.res.Resources;
import android.media.tv.TvContract;
-import android.support.v17.leanback.widget.Presenter;
+import android.media.tv.TvInputManager;
+import android.net.Uri;
+import android.text.Spannable;
+import android.text.SpannableString;
import android.text.TextUtils;
+import android.text.style.TextAppearanceSpan;
import android.view.View;
import android.view.ViewGroup;
-import java.util.List;
-
-import com.android.tv.MainActivity;
import com.android.tv.R;
import com.android.tv.TvApplication;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.dvr.RecordedProgram;
import com.android.tv.data.Channel;
import com.android.tv.data.ChannelDataManager;
-import com.android.tv.dvr.DvrManager;
-import com.android.tv.ui.DialogUtils;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener;
import com.android.tv.util.Utils;
+import java.util.concurrent.TimeUnit;
+
/**
* Presents a {@link RecordedProgram} in the {@link DvrBrowseFragment}.
*/
-public class RecordedProgramPresenter extends Presenter {
+public class RecordedProgramPresenter extends DvrItemPresenter {
private final ChannelDataManager mChannelDataManager;
+ private final DvrWatchedPositionManager mDvrWatchedPositionManager;
+ private final Context mContext;
+ private String mTodayString;
+ private String mYesterdayString;
+ private final int mProgressBarColor;
+ private final boolean mShowEpisodeTitle;
- public RecordedProgramPresenter(Context context) {
+ private static final class RecordedProgramViewHolder extends ViewHolder
+ implements WatchedPositionChangedListener {
+ private RecordedProgram mProgram;
+
+ RecordedProgramViewHolder(RecordingCardView view, int progressColor) {
+ super(view);
+ view.setProgressBarColor(progressColor);
+ }
+
+ private void setProgram(RecordedProgram program) {
+ mProgram = program;
+ }
+
+ private void setProgressBar(long watchedPositionMs) {
+ ((RecordingCardView) view).setProgressBar(
+ (watchedPositionMs == TvInputManager.TIME_SHIFT_INVALID_TIME) ? null
+ : Math.min(100, (int) (100.0f * watchedPositionMs
+ / mProgram.getDurationMillis())));
+ }
+
+ @Override
+ public void onWatchedPositionChanged(long programId, long positionMs) {
+ if (programId == mProgram.getId()) {
+ setProgressBar(positionMs);
+ }
+ }
+ }
+
+ public RecordedProgramPresenter(Context context, boolean showEpisodeTitle) {
+ mContext = context;
mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager();
+ mTodayString = context.getString(R.string.dvr_date_today);
+ mYesterdayString = context.getString(R.string.dvr_date_yesterday);
+ mDvrWatchedPositionManager =
+ TvApplication.getSingletons(context).getDvrWatchedPositionManager();
+ mProgressBarColor = context.getResources()
+ .getColor(R.color.play_controls_progress_bar_watched);
+ mShowEpisodeTitle = showEpisodeTitle;
+ }
+
+ public RecordedProgramPresenter(Context context) {
+ this(context, false);
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
- Context context = parent.getContext();
- RecordingCardView view = new RecordingCardView(context);
- return new ViewHolder(view);
+ RecordingCardView view = new RecordingCardView(mContext);
+ return new RecordedProgramViewHolder(view, mProgressBarColor);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, Object o) {
- final RecordedProgram recording = (RecordedProgram) o;
+ final RecordedProgram program = (RecordedProgram) o;
final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
- final Context context = viewHolder.view.getContext();
- final Resources resources = context.getResources();
-
- Channel channel = mChannelDataManager.getChannel(recording.getChannelId());
-
- if (!TextUtils.isEmpty(recording.getTitle())) {
- cardView.setTitle(recording.getTitle());
- } else {
- cardView.setTitle(resources.getString(R.string.dvr_msg_program_title_unknown));
+ Channel channel = mChannelDataManager.getChannel(program.getChannelId());
+ String titleString = mShowEpisodeTitle ? program.getEpisodeDisplayTitle(mContext)
+ : program.getTitleWithEpisodeNumber(mContext);
+ SpannableString title = titleString == null ? null : new SpannableString(titleString);
+ if (TextUtils.isEmpty(title)) {
+ title = new SpannableString(channel != null ? channel.getDisplayName()
+ : mContext.getResources().getString(R.string.no_program_information));
+ } else if (!mShowEpisodeTitle) {
+ // TODO: Some translation may add delimiters in-between program titles, we should use
+ // a more robust way to get the span range.
+ String programTitle = program.getTitle();
+ title.setSpan(new TextAppearanceSpan(mContext,
+ R.style.text_appearance_card_view_episode_number), programTitle == null ? 0
+ : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
- if (recording.getPosterArt() != null) {
- cardView.setImageUri(recording.getPosterArt());
- } else if (recording.getThumbnail() != null) {
- cardView.setImageUri(recording.getThumbnail());
- } else {
- if (channel != null) {
- cardView.setImageUri(TvContract.buildChannelLogoUri(channel.getId()).toString());
- }
+ cardView.setTitle(title);
+ String imageUri = null;
+ boolean isChannelLogo = false;
+ if (program.getPosterArtUri() != null) {
+ imageUri = program.getPosterArtUri();
+ } else if (program.getThumbnailUri() != null) {
+ imageUri = program.getThumbnailUri();
+ } else if (channel != null) {
+ imageUri = TvContract.buildChannelLogoUri(channel.getId()).toString();
+ isChannelLogo = true;
}
- cardView.setContent(Utils.getDurationString(context, recording.getStartTimeUtcMillis(),
- recording.getEndTimeUtcMillis(), true));
- //TODO: replace with a detail card
- viewHolder.view.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- DialogUtils.showListDialog(v.getContext(),
- new int[] { R.string.dvr_detail_play, R.string.dvr_detail_delete },
- new Runnable[] {
- new Runnable() {
- @Override
- public void run() {
- Intent intent = new Intent(context, MainActivity.class);
- intent.putExtra(Utils.EXTRA_KEY_RECORDING_URI,
- recording.getUri());
- context.startActivity(intent);
- ((Activity) context).finish();
- }
- },
- new Runnable() {
- @Override
- public void run() {
- DvrManager dvrManager = TvApplication
- .getSingletons(context).getDvrManager();
- dvrManager.removeRecordedProgram(recording);
- }
- },
- });
- }
- });
-
+ cardView.setImageUri(imageUri, isChannelLogo);
+ int durationMinutes =
+ Math.max(1, (int) TimeUnit.MILLISECONDS.toMinutes(program.getDurationMillis()));
+ String durationString = getContext().getResources().getQuantityString(
+ R.plurals.dvr_program_duration, durationMinutes, durationMinutes);
+ cardView.setContent(getDescription(program), durationString);
+ if (viewHolder instanceof RecordedProgramViewHolder) {
+ RecordedProgramViewHolder cardViewHolder = (RecordedProgramViewHolder) viewHolder;
+ cardViewHolder.setProgram(program);
+ mDvrWatchedPositionManager.addListener(cardViewHolder, program.getId());
+ cardViewHolder
+ .setProgressBar(mDvrWatchedPositionManager.getWatchedPosition(program.getId()));
+ }
+ super.onBindViewHolder(viewHolder, o);
}
@Override
public void onUnbindViewHolder(ViewHolder viewHolder) {
- final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
- cardView.reset();
+ if (viewHolder instanceof RecordedProgramViewHolder) {
+ mDvrWatchedPositionManager.removeListener((RecordedProgramViewHolder) viewHolder,
+ ((RecordedProgramViewHolder) viewHolder).mProgram.getId());
+ }
+ ((RecordingCardView) viewHolder.view).reset();
+ super.onUnbindViewHolder(viewHolder);
+ }
+
+ /**
+ * Returns description would be used in its card view.
+ */
+ protected String getDescription(RecordedProgram recording) {
+ int dateDifference = Utils.computeDateDifference(recording.getStartTimeUtcMillis(),
+ System.currentTimeMillis());
+ if (dateDifference == 0) {
+ return mTodayString;
+ } else if (dateDifference == 1) {
+ return mYesterdayString;
+ } else {
+ return Utils.getDurationString(mContext, recording.getStartTimeUtcMillis(),
+ recording.getStartTimeUtcMillis(), false, true, false, 0);
+ }
+ }
+
+ /**
+ * Returns context.
+ */
+ protected Context getContext() {
+ return mContext;
}
}
diff --git a/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java b/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java
deleted file mode 100644
index eeb26041..00000000
--- a/src/com/android/tv/dvr/ui/RecordedProgramsAdapter.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.dvr.ui;
-
-import android.support.v17.leanback.widget.PresenterSelector;
-
-import com.android.tv.common.recording.RecordedProgram;
-import com.android.tv.dvr.DvrDataManager;
-
-/**
- * Adapter for {@link RecordedProgram}.
- */
-final class RecordedProgramsAdapter extends SortedArrayAdapter<RecordedProgram>
- implements DvrDataManager.RecordedProgramListener {
- private final DvrDataManager mDataManager;
-
- RecordedProgramsAdapter(DvrDataManager dataManager, PresenterSelector presenterSelector) {
- super(presenterSelector, RecordedProgram.START_TIME_THEN_ID_COMPARATOR);
- mDataManager = dataManager;
- }
-
- public void start() {
- clear();
- addAll(mDataManager.getRecordedPrograms());
- mDataManager.addRecordedProgramListener(this);
- }
-
- public void stop() {
- mDataManager.removeRecordedProgramListener(this);
- }
-
- @Override
- long getId(RecordedProgram item) {
- return item.getId();
- }
-
- @Override // DvrDataManager.RecordedProgramListener
- public void onRecordedProgramAdded(RecordedProgram recordedProgram) {
- add(recordedProgram);
- }
-
- @Override // DvrDataManager.RecordedProgramListener
- public void onRecordedProgramChanged(RecordedProgram recordedProgram) {
- change(recordedProgram);
- }
-
- @Override // DvrDataManager.RecordedProgramListener
- public void onRecordedProgramRemoved(RecordedProgram recordedProgram) {
- remove(recordedProgram);
- }
-}
diff --git a/src/com/android/tv/dvr/ui/RecordingCardView.java b/src/com/android/tv/dvr/ui/RecordingCardView.java
index def11248..51c3b03b 100644
--- a/src/com/android/tv/dvr/ui/RecordingCardView.java
+++ b/src/com/android/tv/dvr/ui/RecordingCardView.java
@@ -25,15 +25,19 @@ import android.support.annotation.Nullable;
import android.support.v17.leanback.widget.BaseCardView;
import android.text.TextUtils;
import android.view.LayoutInflater;
+import android.view.View;
import android.widget.ImageView;
+import android.widget.ProgressBar;
import android.widget.TextView;
import com.android.tv.R;
+import com.android.tv.dvr.RecordedProgram;
import com.android.tv.util.ImageLoader;
/**
* A CardView for displaying info about a {@link com.android.tv.dvr.ScheduledRecording} or
- * {@link com.android.tv.common.recording.RecordedProgram}
+ * {@link RecordedProgram} or
+ * {@link com.android.tv.dvr.SeriesRecording}.
*/
class RecordingCardView extends BaseCardView {
private final ImageView mImageView;
@@ -41,36 +45,85 @@ class RecordingCardView extends BaseCardView {
private final int mImageHeight;
private String mImageUri;
private final TextView mTitleView;
- private final TextView mContentView;
+ private final TextView mMajorContentView;
+ private final TextView mMinorContentView;
+ private final ProgressBar mProgressBar;
+ private final View mAffiliatedIconContainer;
+ private final ImageView mAffiliatedIcon;
private final Drawable mDefaultImage;
RecordingCardView(Context context) {
+ this(context,
+ context.getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width),
+ context.getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_height));
+ }
+
+ RecordingCardView(Context context, int imageWidth, int imageHeight) {
super(context);
//TODO(dvr): move these to the layout XML.
setCardType(BaseCardView.CARD_TYPE_INFO_UNDER_WITH_EXTRA);
+ setInfoVisibility(BaseCardView.CARD_REGION_VISIBLE_ALWAYS);
setFocusable(true);
setFocusableInTouchMode(true);
- mDefaultImage = getResources().getDrawable(R.drawable.default_now_card, null);
+ mDefaultImage = getResources().getDrawable(R.drawable.dvr_default_poster, null);
LayoutInflater inflater = LayoutInflater.from(getContext());
inflater.inflate(R.layout.dvr_recording_card_view, this);
-
mImageView = (ImageView) findViewById(R.id.image);
- mImageWidth = getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width);
- mImageHeight = getResources().getDimensionPixelSize(R.dimen.dvr_card_image_layout_width);
+ mImageWidth = imageWidth;
+ mImageHeight = imageHeight;
+ mProgressBar = (ProgressBar) findViewById(R.id.recording_progress);
+ mAffiliatedIconContainer = findViewById(R.id.affiliated_icon_container);
+ mAffiliatedIcon = (ImageView) findViewById(R.id.affiliated_icon);
mTitleView = (TextView) findViewById(R.id.title);
- mContentView = (TextView) findViewById(R.id.content);
+ mMajorContentView = (TextView) findViewById(R.id.content_major);
+ mMinorContentView = (TextView) findViewById(R.id.content_minor);
}
void setTitle(CharSequence title) {
mTitleView.setText(title);
}
- void setContent(CharSequence content) {
- mContentView.setText(content);
+ void setContent(CharSequence majorContent, CharSequence minorContent) {
+ if (!TextUtils.isEmpty(majorContent)) {
+ mMajorContentView.setText(majorContent);
+ mMajorContentView.setVisibility(View.VISIBLE);
+ } else {
+ mMajorContentView.setVisibility(View.GONE);
+ }
+ if (!TextUtils.isEmpty(minorContent)) {
+ mMinorContentView.setText(minorContent);
+ mMinorContentView.setVisibility(View.VISIBLE);
+ } else {
+ mMinorContentView.setVisibility(View.GONE);
+ }
+ }
+
+ /**
+ * Sets progress bar. If progress is {@code null}, hides progress bar.
+ */
+ void setProgressBar(Integer progress) {
+ if (progress == null) {
+ mProgressBar.setVisibility(View.GONE);
+ } else {
+ mProgressBar.setProgress(progress);
+ mProgressBar.setVisibility(View.VISIBLE);
+ }
+ }
+
+ /**
+ * Sets the color of progress bar.
+ */
+ void setProgressBarColor(int color) {
+ mProgressBar.getProgressDrawable().setTint(color);
}
- void setImageUri(String uri) {
+ void setImageUri(String uri, boolean isChannelLogo) {
+ if (isChannelLogo) {
+ mImageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
+ } else {
+ mImageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
+ }
mImageUri = uri;
if (TextUtils.isEmpty(uri)) {
mImageView.setImageDrawable(mDefaultImage);
@@ -80,14 +133,31 @@ class RecordingCardView extends BaseCardView {
}
}
- public void setImageUri(Uri uri) {
- if (uri != null) {
- setImageUri(uri.toString());
+ /**
+ * Set image to card view.
+ */
+ public void setImage(Drawable image) {
+ if (image != null) {
+ mImageView.setImageDrawable(image);
+ }
+ }
+
+ public void setAffiliatedIcon(int imageResId) {
+ if (imageResId > 0) {
+ mAffiliatedIconContainer.setVisibility(View.VISIBLE);
+ mAffiliatedIcon.setImageResource(imageResId);
} else {
- setImageUri("");
+ mAffiliatedIconContainer.setVisibility(View.INVISIBLE);
}
}
+ /**
+ * Returns image view.
+ */
+ public ImageView getImageView() {
+ return mImageView;
+ }
+
private static class RecordingCardImageLoaderCallback
extends ImageLoader.ImageLoaderCallback<RecordingCardView> {
private final String mUri;
@@ -108,8 +178,8 @@ class RecordingCardView extends BaseCardView {
}
public void reset() {
- mTitleView.setText("");
- mContentView.setText("");
+ mTitleView.setText(null);
+ setContent(null, null);
mImageView.setImageDrawable(mDefaultImage);
}
}
diff --git a/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java
new file mode 100644
index 00000000..4e19ec3f
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/RecordingDetailsFragment.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.os.Bundle;
+import android.support.v17.leanback.app.DetailsFragment;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.TextAppearanceSpan;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.ScheduledRecording;
+
+/**
+ * {@link DetailsFragment} for recordings in DVR.
+ */
+abstract class RecordingDetailsFragment extends DvrDetailsFragment {
+ private ScheduledRecording mRecording;
+
+ @Override
+ protected void onCreateInternal() {
+ setDetailsOverviewRow(createDetailsContent());
+ }
+
+ @Override
+ protected boolean onLoadRecordingDetails(Bundle args) {
+ long scheduledRecordingId = args.getLong(DvrDetailsActivity.RECORDING_ID);
+ mRecording = TvApplication.getSingletons(getContext()).getDvrDataManager()
+ .getScheduledRecording(scheduledRecordingId);
+ return mRecording != null;
+ }
+
+ /**
+ * Returns {@link ScheduledRecording} for the current fragment.
+ */
+ public ScheduledRecording getRecording() {
+ return mRecording;
+ }
+
+ private DetailsContent createDetailsContent() {
+ Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(mRecording.getChannelId());
+ SpannableString title = mRecording.getProgramTitleWithEpisodeNumber(getContext()) == null ?
+ null : new SpannableString(mRecording
+ .getProgramTitleWithEpisodeNumber(getContext()));
+ if (TextUtils.isEmpty(title)) {
+ title = new SpannableString(channel != null ? channel.getDisplayName()
+ : getContext().getResources().getString(
+ R.string.no_program_information));
+ } else {
+ String programTitle = mRecording.getProgramTitle();
+ title.setSpan(new TextAppearanceSpan(getContext(),
+ R.style.text_appearance_card_view_episode_number), programTitle == null ? 0
+ : programTitle.length(), title.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ String description = !TextUtils.isEmpty(mRecording.getProgramDescription()) ?
+ mRecording.getProgramDescription() : mRecording.getProgramLongDescription();
+ if (TextUtils.isEmpty(description)) {
+ description = channel != null ? channel.getDescription() : null;
+ }
+ return new DetailsContent.Builder()
+ .setTitle(title)
+ .setStartTimeUtcMillis(mRecording.getStartTimeMs())
+ .setEndTimeUtcMillis(mRecording.getEndTimeMs())
+ .setDescription(description)
+ .setImageUris(mRecording.getProgramPosterArtUri(),
+ mRecording.getProgramThumbnailUri(), channel)
+ .build();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java
new file mode 100644
index 00000000..60816bb5
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/ScheduledRecordingDetailsFragment.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.SparseArrayObjectAdapter;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+
+/**
+ * {@link RecordingDetailsFragment} for scheduled recording in DVR.
+ */
+public class ScheduledRecordingDetailsFragment extends RecordingDetailsFragment {
+ private static final int ACTION_VIEW_SCHEDULE = 1;
+ private static final int ACTION_CANCEL = 2;
+
+ private DvrManager mDvrManager;
+ private Action mScheduleAction;
+ private boolean mHideViewSchedule;
+
+ @Override
+ public void onCreate(Bundle savedInstance) {
+ mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ mHideViewSchedule = getArguments().getBoolean(DvrDetailsActivity.HIDE_VIEW_SCHEDULE);
+ super.onCreate(savedInstance);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (mScheduleAction != null) {
+ mScheduleAction.setIcon(getResources().getDrawable(getScheduleIconId()));
+ }
+ }
+
+ @Override
+ protected SparseArrayObjectAdapter onCreateActionsAdapter() {
+ SparseArrayObjectAdapter adapter =
+ new SparseArrayObjectAdapter(new ActionPresenterSelector());
+ Resources res = getResources();
+ if (!mHideViewSchedule) {
+ mScheduleAction = new Action(ACTION_VIEW_SCHEDULE,
+ res.getString(R.string.dvr_detail_view_schedule), null,
+ res.getDrawable(getScheduleIconId()));
+ adapter.set(ACTION_VIEW_SCHEDULE, mScheduleAction);
+ }
+ adapter.set(ACTION_CANCEL, new Action(ACTION_CANCEL,
+ res.getString(R.string.epg_dvr_dialog_message_remove_recording_schedule), null,
+ res.getDrawable(R.drawable.ic_dvr_cancel_32dp)));
+ return adapter;
+ }
+
+ @Override
+ protected OnActionClickedListener onCreateOnActionClickedListener() {
+ return new OnActionClickedListener() {
+ @Override
+ public void onActionClicked(Action action) {
+ long actionId = action.getId();
+ if (actionId == ACTION_VIEW_SCHEDULE) {
+ DvrUiHelper.startSchedulesActivity(getContext(), getRecording());
+ } else if (actionId == ACTION_CANCEL) {
+ mDvrManager.removeScheduledRecording(getRecording());
+ getActivity().finish();
+ }
+ }
+ };
+ }
+
+ private int getScheduleIconId() {
+ if (mDvrManager.isConflicting(getRecording())) {
+ return R.drawable.ic_warning_white_32dp;
+ } else {
+ return R.drawable.ic_schedule_32dp;
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java
index 533a4882..5f447f13 100644
--- a/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java
+++ b/src/com/android/tv/dvr/ui/ScheduledRecordingPresenter.java
@@ -16,156 +16,162 @@
package com.android.tv.dvr.ui;
-import android.app.AlertDialog;
+import android.app.Activity;
import android.content.Context;
-import android.content.DialogInterface;
import android.media.tv.TvContract;
-import android.support.annotation.Nullable;
-import android.support.v17.leanback.widget.Presenter;
-import android.view.View;
+import android.os.Handler;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.TextAppearanceSpan;
import android.view.ViewGroup;
-import android.widget.Toast;
import com.android.tv.ApplicationSingletons;
import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.data.Channel;
import com.android.tv.data.ChannelDataManager;
-import com.android.tv.data.Program;
-import com.android.tv.data.ProgramDataManager;
import com.android.tv.dvr.DvrManager;
import com.android.tv.dvr.ScheduledRecording;
import com.android.tv.util.Utils;
+import java.util.concurrent.TimeUnit;
+
/**
* Presents a {@link ScheduledRecording} in the {@link DvrBrowseFragment}.
*/
-public class ScheduledRecordingPresenter extends Presenter {
+public class ScheduledRecordingPresenter extends DvrItemPresenter {
+ private static final long PROGRESS_UPDATE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(5);
+
private final ChannelDataManager mChannelDataManager;
+ private final DvrManager mDvrManager;
+ private final Context mContext;
+ private final int mProgressBarColor;
private static final class ScheduledRecordingViewHolder extends ViewHolder {
- private ProgramDataManager.QueryProgramTask mQueryProgramTask;
+ private final Handler mHandler = new Handler();
+ private ScheduledRecording mScheduledRecording;
+ private final Runnable mProgressBarUpdater = new Runnable() {
+ @Override
+ public void run() {
+ updateProgressBar();
+ mHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS);
+ }
+ };
- ScheduledRecordingViewHolder(RecordingCardView view) {
+ ScheduledRecordingViewHolder(RecordingCardView view, int progressBarColor) {
super(view);
+ view.setProgressBarColor(progressBarColor);
+ }
+
+ private void updateProgressBar() {
+ if (mScheduledRecording == null) {
+ return;
+ }
+ int recordingState = mScheduledRecording.getState();
+ RecordingCardView cardView = (RecordingCardView) view;
+ if (recordingState == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
+ cardView.setProgressBar(Math.max(0, Math.min((int) (100 *
+ (System.currentTimeMillis() - mScheduledRecording.getStartTimeMs())
+ / mScheduledRecording.getDuration()), 100)));
+ } else if (recordingState == ScheduledRecording.STATE_RECORDING_FINISHED) {
+ cardView.setProgressBar(100);
+ } else {
+ // Hides progress bar.
+ cardView.setProgressBar(null);
+ }
+ }
+
+ private void startUpdateProgressBar() {
+ mHandler.post(mProgressBarUpdater);
+ }
+
+ private void stopUpdateProgressBar() {
+ mHandler.removeCallbacks(mProgressBarUpdater);
}
}
public ScheduledRecordingPresenter(Context context) {
ApplicationSingletons singletons = TvApplication.getSingletons(context);
mChannelDataManager = singletons.getChannelDataManager();
+ mDvrManager = singletons.getDvrManager();
+ mContext = context;
+ mProgressBarColor = context.getResources()
+ .getColor(R.color.play_controls_recording_icon_color_on_focus);
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
Context context = parent.getContext();
RecordingCardView view = new RecordingCardView(context);
- return new ScheduledRecordingViewHolder(view);
+ return new ScheduledRecordingViewHolder(view, mProgressBarColor);
}
@Override
public void onBindViewHolder(ViewHolder baseHolder, Object o) {
- ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder;
+ final ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder;
final ScheduledRecording recording = (ScheduledRecording) o;
final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
final Context context = viewHolder.view.getContext();
- long programId = recording.getProgramId();
- if (programId == ScheduledRecording.ID_NOT_SET) {
- setTitleAndImage(cardView, recording, null);
+ setTitleAndImage(cardView, recording);
+ int dateDifference = Utils.computeDateDifference(System.currentTimeMillis(),
+ recording.getStartTimeMs());
+ if (dateDifference <= 0) {
+ cardView.setContent(mContext.getString(R.string.dvr_date_today_time,
+ Utils.getDurationString(context, recording.getStartTimeMs(),
+ recording.getEndTimeMs(), false, false, true, 0)), null);
+ } else if (dateDifference == 1) {
+ cardView.setContent(mContext.getString(R.string.dvr_date_tomorrow_time,
+ Utils.getDurationString(context, recording.getStartTimeMs(),
+ recording.getEndTimeMs(), false, false, true, 0)), null);
} else {
- viewHolder.mQueryProgramTask = new ProgramDataManager.QueryProgramTask(
- context.getContentResolver(), programId) {
- @Override
- protected void onPostExecute(Program program) {
- super.onPostExecute(program);
- setTitleAndImage(cardView, recording, program);
- }
- };
- viewHolder.mQueryProgramTask.executeOnDbThread();
-
+ cardView.setContent(Utils.getDurationString(context, recording.getStartTimeMs(),
+ recording.getStartTimeMs(), false, true, false, 0), null);
}
- cardView.setContent(Utils.getDurationString(context, recording.getStartTimeMs(),
- recording.getEndTimeMs(), true));
- //TODO: replace with a detail card
- View.OnClickListener clickListener = new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- switch (recording.getState()) {
- case ScheduledRecording.STATE_RECORDING_NOT_STARTED: {
- showScheduledRecordingDialog(v.getContext(), recording);
- break;
- }
- case ScheduledRecording.STATE_RECORDING_IN_PROGRESS: {
- showCurrentlyRecordingDialog(v.getContext(), recording);
- break;
- }
- }
- }
- };
- baseHolder.view.setOnClickListener(clickListener);
- }
-
- private void setTitleAndImage(RecordingCardView cardView, ScheduledRecording recording,
- @Nullable Program program) {
- if (program != null) {
- cardView.setTitle(program.getTitle());
- cardView.setImageUri(program.getPosterArtUri());
+ if (mDvrManager.isConflicting(recording)) {
+ cardView.setAffiliatedIcon(R.drawable.ic_warning_white_32dp);
} else {
- cardView.setTitle(
- cardView.getResources().getString(R.string.dvr_msg_program_title_unknown));
- Channel channel = mChannelDataManager.getChannel(recording.getChannelId());
- if (channel != null) {
- cardView.setImageUri(TvContract.buildChannelLogoUri(channel.getId()).toString());
- }
+ cardView.setAffiliatedIcon(0);
}
+ viewHolder.updateProgressBar();
+ viewHolder.mScheduledRecording = recording;
+ viewHolder.startUpdateProgressBar();
+ super.onBindViewHolder(viewHolder, o);
}
@Override
public void onUnbindViewHolder(ViewHolder baseHolder) {
ScheduledRecordingViewHolder viewHolder = (ScheduledRecordingViewHolder) baseHolder;
+ viewHolder.stopUpdateProgressBar();
final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
- if (viewHolder.mQueryProgramTask != null) {
- viewHolder.mQueryProgramTask.cancel(true);
- viewHolder.mQueryProgramTask = null;
- }
+ viewHolder.mScheduledRecording = null;
cardView.reset();
+ super.onUnbindViewHolder(viewHolder);
}
- private void showScheduledRecordingDialog(final Context context,
- final ScheduledRecording recording) {
- DialogInterface.OnClickListener removeScheduleListener
- = new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- // TODO(DVR) handle success/failure.
- DvrManager dvrManager = TvApplication.getSingletons(context)
- .getDvrManager();
- dvrManager.removeScheduledRecording((ScheduledRecording) recording);
- }
- };
- new AlertDialog.Builder(context)
- .setMessage(R.string.epg_dvr_dialog_message_remove_recording_schedule)
- .setNegativeButton(android.R.string.no, null)
- .setPositiveButton(android.R.string.yes, removeScheduleListener)
- .show();
- }
-
- private void showCurrentlyRecordingDialog(final Context context,
- final ScheduledRecording recording) {
- DialogInterface.OnClickListener stopRecordingListener
- = new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- DvrManager dvrManager = TvApplication.getSingletons(context)
- .getDvrManager();
- dvrManager.stopRecording((ScheduledRecording) recording);
- }
- };
- new AlertDialog.Builder(context)
- .setMessage(R.string.epg_dvr_dialog_message_stop_recording)
- .setNegativeButton(android.R.string.no, null)
- .setPositiveButton(android.R.string.yes, stopRecordingListener)
- .show();
+ private void setTitleAndImage(RecordingCardView cardView, ScheduledRecording recording) {
+ Channel channel = mChannelDataManager.getChannel(recording.getChannelId());
+ SpannableString title = recording.getProgramTitleWithEpisodeNumber(mContext) == null ?
+ null : new SpannableString(recording.getProgramTitleWithEpisodeNumber(mContext));
+ if (TextUtils.isEmpty(title)) {
+ title = new SpannableString(channel != null ? channel.getDisplayName()
+ : mContext.getResources().getString(R.string.no_program_information));
+ } else {
+ String programTitle = recording.getProgramTitle();
+ title.setSpan(new TextAppearanceSpan(mContext,
+ R.style.text_appearance_card_view_episode_number),
+ programTitle == null ? 0 : programTitle.length(), title.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ String imageUri = recording.getProgramPosterArtUri();
+ boolean isChannelLogo = false;
+ if (TextUtils.isEmpty(imageUri)) {
+ imageUri = channel != null ?
+ TvContract.buildChannelLogoUri(channel.getId()).toString() : null;
+ isChannelLogo = true;
+ }
+ cardView.setTitle(title);
+ cardView.setImageUri(imageUri, isChannelLogo);
}
}
diff --git a/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java b/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java
deleted file mode 100644
index 65955276..00000000
--- a/src/com/android/tv/dvr/ui/ScheduledRecordingsAdapter.java
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.dvr.ui;
-
-import android.support.v17.leanback.widget.PresenterSelector;
-
-import com.android.tv.dvr.DvrDataManager;
-import com.android.tv.dvr.ScheduledRecording;
-
-/**
- * Adapter for {@link ScheduledRecording} filtered by
- * {@link com.android.tv.dvr.ScheduledRecording.RecordingState}.
- */
-final class ScheduledRecordingsAdapter extends SortedArrayAdapter<ScheduledRecording>
- implements DvrDataManager.ScheduledRecordingListener {
- private final int mState;
- private final DvrDataManager mDataManager;
-
- ScheduledRecordingsAdapter(DvrDataManager dataManager, int state,
- PresenterSelector presenterSelector) {
- super(presenterSelector, ScheduledRecording.START_TIME_THEN_PRIORITY_COMPARATOR);
- mDataManager = dataManager;
- mState = state;
- }
-
- public void start() {
- clear();
- switch (mState) {
- case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
- addAll(mDataManager.getNonStartedScheduledRecordings());
- break;
- case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
- addAll(mDataManager.getStartedRecordings());
- break;
- default:
- throw new IllegalStateException("Unknown recording state " + mState);
-
- }
- mDataManager.addScheduledRecordingListener(this);
- }
-
- public void stop() {
- mDataManager.removeScheduledRecordingListener(this);
- }
-
- @Override
- long getId(ScheduledRecording item) {
- return item.getId();
- }
-
- @Override //DvrDataManager.ScheduledRecordingListener
- public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) {
- if (scheduledRecording.getState() == mState) {
- add(scheduledRecording);
- }
- }
-
- @Override //DvrDataManager.ScheduledRecordingListener
- public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) {
- remove(scheduledRecording);
- }
-
- @Override //DvrDataManager.ScheduledRecordingListener
- public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) {
- if (scheduledRecording.getState() == mState) {
- change(scheduledRecording);
- } else {
- remove(scheduledRecording);
- }
- }
-}
diff --git a/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java b/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java
new file mode 100644
index 00000000..36e3cfc1
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/SeriesDeletionFragment.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.Context;
+import android.media.tv.TvInputManager;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+import android.text.TextUtils;
+import android.view.ViewGroup.LayoutParams;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.ui.GuidedActionsStylistWithDivider;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Fragment for DVR series recording settings.
+ */
+public class SeriesDeletionFragment extends GuidedStepFragment {
+ private static final long WATCHED_TIME_UNIT_THRESHOLD = TimeUnit.MINUTES.toMillis(2);
+
+ // Since recordings' IDs are used as its check actions' IDs, which are random positive numbers,
+ // negative values are used by other actions to prevent duplicated IDs.
+ private static final long ACTION_ID_SELECT_WATCHED = -110;
+ private static final long ACTION_ID_SELECT_ALL = -111;
+ private static final long ACTION_ID_DELETE = -112;
+
+ private DvrDataManager mDvrDataManager;
+ private DvrWatchedPositionManager mDvrWatchedPositionManager;
+ private List<RecordedProgram> mRecordings;
+ private final Set<Long> mWatchedRecordings = new HashSet<>();
+ private boolean mAllSelected;
+ private long mSeriesRecordingId;
+ private int mOneLineActionHeight;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mSeriesRecordingId = getArguments()
+ .getLong(DvrSeriesDeletionActivity.SERIES_RECORDING_ID, -1);
+ SoftPreconditions.checkArgument(mSeriesRecordingId != -1);
+ mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ mDvrWatchedPositionManager =
+ TvApplication.getSingletons(context).getDvrWatchedPositionManager();
+ mRecordings = mDvrDataManager.getRecordedPrograms(mSeriesRecordingId);
+ mOneLineActionHeight = getResources().getDimensionPixelSize(
+ R.dimen.dvr_settings_one_line_action_container_height);
+ if (mRecordings.isEmpty()) {
+ Toast.makeText(getActivity(), getString(R.string.dvr_series_deletion_no_recordings),
+ Toast.LENGTH_LONG).show();
+ finishGuidedStepFragments();
+ return;
+ }
+ Collections.sort(mRecordings, RecordedProgram.EPISODE_COMPARATOR);
+ }
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String breadcrumb = null;
+ SeriesRecording series = mDvrDataManager.getSeriesRecording(mSeriesRecordingId);
+ if (series != null) {
+ breadcrumb = series.getTitle();
+ }
+ return new Guidance(getString(R.string.dvr_series_deletion_title),
+ getString(R.string.dvr_series_deletion_description), breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_SELECT_WATCHED)
+ .title(getString(R.string.dvr_series_select_watched))
+ .build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_SELECT_ALL)
+ .title(getString(R.string.dvr_series_select_all))
+ .build());
+ actions.add(GuidedActionsStylistWithDivider.createDividerAction(getContext()));
+ for (RecordedProgram recording : mRecordings) {
+ long watchedPositionMs =
+ mDvrWatchedPositionManager.getWatchedPosition(recording.getId());
+ String title = recording.getEpisodeDisplayTitle(getContext());
+ if (TextUtils.isEmpty(title)) {
+ title = TextUtils.isEmpty(recording.getTitle()) ?
+ getString(R.string.channel_banner_no_title) : recording.getTitle();
+ }
+ String description;
+ if (watchedPositionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ description = getWatchedString(watchedPositionMs, recording.getDurationMillis());
+ mWatchedRecordings.add(recording.getId());
+ } else {
+ description = getString(R.string.dvr_series_never_watched);
+ }
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(recording.getId())
+ .title(title)
+ .description(description)
+ .checkSetId(GuidedAction.CHECKBOX_CHECK_SET_ID)
+ .build());
+ }
+ }
+
+ @Override
+ public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_DELETE)
+ .title(getString(R.string.dvr_detail_delete))
+ .build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .clickAction(GuidedAction.ACTION_ID_CANCEL)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ long actionId = action.getId();
+ if (actionId == ACTION_ID_DELETE) {
+ List<Long> idsToDelete = new ArrayList<>();
+ for (GuidedAction guidedAction : getActions()) {
+ if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID
+ && guidedAction.isChecked()) {
+ idsToDelete.add(guidedAction.getId());
+ }
+ }
+ if (!idsToDelete.isEmpty()) {
+ DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager();
+ dvrManager.removeRecordedPrograms(idsToDelete);
+ }
+ Toast.makeText(getContext(), getResources().getQuantityString(
+ R.plurals.dvr_msg_episodes_deleted, idsToDelete.size(), idsToDelete.size(),
+ mRecordings.size()), Toast.LENGTH_LONG).show();
+ finishGuidedStepFragments();
+ } else if (actionId == GuidedAction.ACTION_ID_CANCEL) {
+ finishGuidedStepFragments();
+ } else if (actionId == ACTION_ID_SELECT_WATCHED) {
+ for (GuidedAction guidedAction : getActions()) {
+ if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) {
+ long recordingId = guidedAction.getId();
+ if (mWatchedRecordings.contains(recordingId)) {
+ guidedAction.setChecked(true);
+ } else {
+ guidedAction.setChecked(false);
+ }
+ notifyActionChanged(findActionPositionById(recordingId));
+ }
+ }
+ mAllSelected = updateSelectAllState();
+ } else if (actionId == ACTION_ID_SELECT_ALL) {
+ mAllSelected = !mAllSelected;
+ for (GuidedAction guidedAction : getActions()) {
+ if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) {
+ guidedAction.setChecked(mAllSelected);
+ notifyActionChanged(findActionPositionById(guidedAction.getId()));
+ }
+ }
+ updateSelectAllState(action, mAllSelected);
+ } else {
+ mAllSelected = updateSelectAllState();
+ }
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateButtonActionsStylist() {
+ return new DvrGuidedActionsStylist(true);
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateActionsStylist() {
+ return new GuidedActionsStylistWithDivider() {
+ @Override
+ public void onBindViewHolder(ViewHolder vh, GuidedAction action) {
+ super.onBindViewHolder(vh, action);
+ if (action.getId() == ACTION_DIVIDER) {
+ return;
+ }
+ LayoutParams lp = vh.itemView.getLayoutParams();
+ if (action.getCheckSetId() != GuidedAction.CHECKBOX_CHECK_SET_ID) {
+ lp.height = mOneLineActionHeight;
+ } else {
+ vh.itemView.setLayoutParams(
+ new LayoutParams(lp.width, LayoutParams.WRAP_CONTENT));
+ }
+ }
+ };
+ }
+
+ private String getWatchedString(long watchedPositionMs, long durationMs) {
+ if (durationMs > WATCHED_TIME_UNIT_THRESHOLD) {
+ return getResources().getString(R.string.dvr_series_watched_info_minutes,
+ Math.max(1, TimeUnit.MILLISECONDS.toMinutes(watchedPositionMs)),
+ TimeUnit.MILLISECONDS.toMinutes(durationMs));
+ } else {
+ return getResources().getString(R.string.dvr_series_watched_info_seconds,
+ Math.max(1, TimeUnit.MILLISECONDS.toSeconds(watchedPositionMs)),
+ TimeUnit.MILLISECONDS.toSeconds(durationMs));
+ }
+ }
+
+ private boolean updateSelectAllState() {
+ for (GuidedAction guidedAction : getActions()) {
+ if (guidedAction.getCheckSetId() == GuidedAction.CHECKBOX_CHECK_SET_ID) {
+ if (!guidedAction.isChecked()) {
+ if (mAllSelected) {
+ updateSelectAllState(findActionById(ACTION_ID_SELECT_ALL), false);
+ }
+ return false;
+ }
+ }
+ }
+ if (!mAllSelected) {
+ updateSelectAllState(findActionById(ACTION_ID_SELECT_ALL), true);
+ }
+ return true;
+ }
+
+ private void updateSelectAllState(GuidedAction selectAll, boolean select) {
+ selectAll.setTitle(select ? getString(R.string.dvr_series_deselect_all)
+ : getString(R.string.dvr_series_select_all));
+ notifyActionChanged(findActionPositionById(ACTION_ID_SELECT_ALL));
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java b/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java
new file mode 100644
index 00000000..e9e391d4
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/SeriesRecordingDetailsFragment.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.media.tv.TvInputManager;
+import android.os.Bundle;
+import android.support.v17.leanback.app.DetailsFragment;
+import android.support.v17.leanback.widget.Action;
+import android.support.v17.leanback.widget.ArrayObjectAdapter;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.support.v17.leanback.widget.DetailsOverviewRow;
+import android.support.v17.leanback.widget.DetailsOverviewRowPresenter;
+import android.support.v17.leanback.widget.HeaderItem;
+import android.support.v17.leanback.widget.ListRow;
+import android.support.v17.leanback.widget.ListRowPresenter;
+import android.support.v17.leanback.widget.OnActionClickedListener;
+import android.support.v17.leanback.widget.PresenterSelector;
+import android.support.v17.leanback.widget.SparseArrayObjectAdapter;
+import android.text.TextUtils;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.BaseProgram;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * {@link DetailsFragment} for series recording in DVR.
+ */
+public class SeriesRecordingDetailsFragment extends DvrDetailsFragment implements
+ DvrDataManager.SeriesRecordingListener, DvrDataManager.RecordedProgramListener {
+ private static final int ACTION_WATCH = 1;
+ private static final int ACTION_SERIES_SCHEDULES = 2;
+ private static final int ACTION_DELETE = 3;
+
+ private DvrWatchedPositionManager mDvrWatchedPositionManager;
+ private DvrDataManager mDvrDataManager;
+
+ private SeriesRecording mSeries;
+ // NOTICE: mRecordedPrograms should only be used in creating details fragments.
+ // After fragments are created, it should be cleared to save resources.
+ private List<RecordedProgram> mRecordedPrograms;
+ private RecordedProgram mRecommendRecordedProgram;
+ private DetailsContent mDetailsContent;
+ private int mSeasonRowCount;
+ private SparseArrayObjectAdapter mActionsAdapter;
+ private Action mDeleteAction;
+
+ private boolean mPaused;
+ private long mInitialPlaybackPositionMs;
+ private String mWatchLabel;
+ private String mResumeLabel;
+ private Drawable mWatchDrawable;
+ private RecordedProgramPresenter mRecordedProgramPresenter;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ mDvrDataManager = TvApplication.getSingletons(getActivity()).getDvrDataManager();
+ mWatchLabel = getString(R.string.dvr_detail_watch);
+ mResumeLabel = getString(R.string.dvr_detail_series_resume);
+ mWatchDrawable = getResources().getDrawable(R.drawable.lb_ic_play, null);
+ mRecordedProgramPresenter = new RecordedProgramPresenter(getContext(), true);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onCreateInternal() {
+ mDvrWatchedPositionManager = TvApplication.getSingletons(getActivity())
+ .getDvrWatchedPositionManager();
+ setDetailsOverviewRow(mDetailsContent);
+ setupRecordedProgramsRow();
+ mDvrDataManager.addSeriesRecordingListener(this);
+ mDvrDataManager.addRecordedProgramListener(this);
+ mRecordedPrograms = null;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (mPaused) {
+ updateWatchAction();
+ mPaused = false;
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mPaused = true;
+ }
+
+ private void updateWatchAction() {
+ List<RecordedProgram> programs = mDvrDataManager.getRecordedPrograms(mSeries.getId());
+ Collections.sort(programs, RecordedProgram.EPISODE_COMPARATOR);
+ mRecommendRecordedProgram = getRecommendProgram(programs);
+ if (mRecommendRecordedProgram == null) {
+ mActionsAdapter.clear(ACTION_WATCH);
+ } else {
+ String episodeStatus;
+ if(mDvrWatchedPositionManager.getWatchedStatus(mRecommendRecordedProgram)
+ == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) {
+ episodeStatus = mResumeLabel;
+ mInitialPlaybackPositionMs = mDvrWatchedPositionManager
+ .getWatchedPosition(mRecommendRecordedProgram.getId());
+ } else {
+ episodeStatus = mWatchLabel;
+ mInitialPlaybackPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
+ }
+ String episodeDisplayNumber = mRecommendRecordedProgram.getEpisodeDisplayNumber(
+ getContext());
+ mActionsAdapter.set(ACTION_WATCH, new Action(ACTION_WATCH,
+ episodeStatus, episodeDisplayNumber, mWatchDrawable));
+ }
+ }
+
+ @Override
+ protected boolean onLoadRecordingDetails(Bundle args) {
+ long recordId = args.getLong(DvrDetailsActivity.RECORDING_ID);
+ mSeries = TvApplication.getSingletons(getActivity()).getDvrDataManager()
+ .getSeriesRecording(recordId);
+ if (mSeries == null) {
+ return false;
+ }
+ mRecordedPrograms = mDvrDataManager.getRecordedPrograms(mSeries.getId());
+ Collections.sort(mRecordedPrograms, RecordedProgram.SEASON_REVERSED_EPISODE_COMPARATOR);
+ mDetailsContent = createDetailsContent();
+ return true;
+ }
+
+ @Override
+ protected PresenterSelector onCreatePresenterSelector(
+ DetailsOverviewRowPresenter rowPresenter) {
+ ClassPresenterSelector presenterSelector = new ClassPresenterSelector();
+ presenterSelector.addClassPresenter(DetailsOverviewRow.class, rowPresenter);
+ presenterSelector.addClassPresenter(ListRow.class, new ListRowPresenter());
+ return presenterSelector;
+ }
+
+ private DetailsContent createDetailsContent() {
+ Channel channel = TvApplication.getSingletons(getContext()).getChannelDataManager()
+ .getChannel(mSeries.getChannelId());
+ String description = TextUtils.isEmpty(mSeries.getLongDescription())
+ ? mSeries.getDescription() : mSeries.getLongDescription();
+ return new DetailsContent.Builder()
+ .setTitle(mSeries.getTitle())
+ .setDescription(description)
+ .setImageUris(mSeries.getPosterUri(), mSeries.getPhotoUri(), channel)
+ .build();
+ }
+
+ @Override
+ protected SparseArrayObjectAdapter onCreateActionsAdapter() {
+ mActionsAdapter = new SparseArrayObjectAdapter(new ActionPresenterSelector());
+ Resources res = getResources();
+ updateWatchAction();
+ mActionsAdapter.set(ACTION_SERIES_SCHEDULES, new Action(ACTION_SERIES_SCHEDULES,
+ getString(R.string.dvr_detail_view_schedule), null,
+ res.getDrawable(R.drawable.ic_schedule_32dp, null)));
+ mDeleteAction = new Action(ACTION_DELETE,
+ getString(R.string.dvr_detail_series_delete), null,
+ res.getDrawable(R.drawable.ic_delete_32dp, null));
+ if (!mRecordedPrograms.isEmpty()) {
+ mActionsAdapter.set(ACTION_DELETE, mDeleteAction);
+ }
+ return mActionsAdapter;
+ }
+
+ private void setupRecordedProgramsRow() {
+ for (RecordedProgram program : mRecordedPrograms) {
+ addProgram(program);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mDvrDataManager.removeSeriesRecordingListener(this);
+ mDvrDataManager.removeRecordedProgramListener(this);
+ if (mSeries != null) {
+ DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager();
+ if (dvrManager.canRemoveSeriesRecording(mSeries.getId())) {
+ dvrManager.removeSeriesRecording(mSeries.getId());
+ }
+ }
+ mRecordedProgramPresenter.unbindAllViewHolders();
+ }
+
+ @Override
+ protected OnActionClickedListener onCreateOnActionClickedListener() {
+ return new OnActionClickedListener() {
+ @Override
+ public void onActionClicked(Action action) {
+ if (action.getId() == ACTION_WATCH) {
+ startPlayback(mRecommendRecordedProgram, mInitialPlaybackPositionMs);
+ } else if (action.getId() == ACTION_SERIES_SCHEDULES) {
+ DvrUiHelper.startSchedulesActivityForSeries(getContext(), mSeries);
+ } else if (action.getId() == ACTION_DELETE) {
+ DvrUiHelper.startSeriesDeletionActivity(getContext(), mSeries.getId());
+ }
+ }
+ };
+ }
+
+ /**
+ * The programs are sorted by season number and episode number.
+ */
+ private RecordedProgram getRecommendProgram(List<RecordedProgram> programs) {
+ for (int i = programs.size() - 1 ; i >= 0 ; i--) {
+ RecordedProgram program = programs.get(i);
+ int watchedStatus = mDvrWatchedPositionManager.getWatchedStatus(program);
+ if (watchedStatus == DvrWatchedPositionManager.DVR_WATCHED_STATUS_NEW) {
+ continue;
+ }
+ if (watchedStatus == DvrWatchedPositionManager.DVR_WATCHED_STATUS_WATCHING) {
+ return program;
+ }
+ if (i == programs.size() - 1) {
+ return program;
+ } else {
+ return programs.get(i + 1);
+ }
+ }
+ return programs.isEmpty() ? null : programs.get(0);
+ }
+
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording series : seriesRecordings) {
+ if (mSeries.getId() == series.getId()) {
+ mSeries = series;
+ }
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording series : seriesRecordings) {
+ if (series.getId() == mSeries.getId()) {
+ mSeries = null;
+ getActivity().finish();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (TextUtils.equals(recordedProgram.getSeriesId(), mSeries.getSeriesId())) {
+ addProgram(recordedProgram);
+ if (mActionsAdapter.lookup(ACTION_DELETE) == null) {
+ mActionsAdapter.set(ACTION_DELETE, mDeleteAction);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) {
+ // Do nothing
+ }
+
+ @Override
+ public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (TextUtils.equals(recordedProgram.getSeriesId(), mSeries.getSeriesId())) {
+ ListRow row = getSeasonRow(recordedProgram.getSeasonNumber(), false);
+ if (row != null) {
+ SeasonRowAdapter adapter = (SeasonRowAdapter) row.getAdapter();
+ adapter.remove(recordedProgram);
+ if (adapter.isEmpty()) {
+ getRowsAdapter().remove(row);
+ if (getRowsAdapter().size() == 1) {
+ // No season rows left. Only DetailsOverviewRow
+ mActionsAdapter.clear(ACTION_DELETE);
+ }
+ }
+ }
+ if (recordedProgram.getId() == mRecommendRecordedProgram.getId()) {
+ updateWatchAction();
+ }
+ }
+ }
+ }
+
+ private void addProgram(RecordedProgram program) {
+ String programSeasonNumber =
+ TextUtils.isEmpty(program.getSeasonNumber()) ? "" : program.getSeasonNumber();
+ getOrCreateSeasonRowAdapter(programSeasonNumber).add(program);
+ }
+
+ private SeasonRowAdapter getOrCreateSeasonRowAdapter(String seasonNumber) {
+ ListRow row = getSeasonRow(seasonNumber, true);
+ return (SeasonRowAdapter) row.getAdapter();
+ }
+
+ private ListRow getSeasonRow(String seasonNumber, boolean createNewRow) {
+ seasonNumber = TextUtils.isEmpty(seasonNumber) ? "" : seasonNumber;
+ ArrayObjectAdapter rowsAdaptor = getRowsAdapter();
+ for (int i = rowsAdaptor.size() - 1; i >= 0; i--) {
+ Object row = rowsAdaptor.get(i);
+ if (row instanceof ListRow) {
+ int compareResult = BaseProgram.numberCompare(seasonNumber,
+ ((SeasonRowAdapter) ((ListRow) row).getAdapter()).mSeasonNumber);
+ if (compareResult == 0) {
+ return (ListRow) row;
+ } else if (compareResult < 0) {
+ return createNewRow ? createNewSeasonRow(seasonNumber, i + 1) : null;
+ }
+ }
+ }
+ return createNewRow ? createNewSeasonRow(seasonNumber, rowsAdaptor.size()) : null;
+ }
+
+ private ListRow createNewSeasonRow(String seasonNumber, int position) {
+ String seasonTitle = seasonNumber.isEmpty() ? mSeries.getTitle()
+ : getString(R.string.dvr_detail_series_season_title, seasonNumber);
+ HeaderItem header = new HeaderItem(mSeasonRowCount++, seasonTitle);
+ ClassPresenterSelector selector = new ClassPresenterSelector();
+ selector.addClassPresenter(RecordedProgram.class, mRecordedProgramPresenter);
+ ListRow row = new ListRow(header, new SeasonRowAdapter(selector,
+ new Comparator<RecordedProgram>() {
+ @Override
+ public int compare(RecordedProgram lhs, RecordedProgram rhs) {
+ return BaseProgram.EPISODE_COMPARATOR.compare(lhs, rhs);
+ }
+ }, seasonNumber));
+ getRowsAdapter().add(position, row);
+ return row;
+ }
+
+ private class SeasonRowAdapter extends SortedArrayAdapter<RecordedProgram> {
+ private String mSeasonNumber;
+
+ SeasonRowAdapter(PresenterSelector selector, Comparator<RecordedProgram> comparator,
+ String seasonNumber) {
+ super(selector, comparator);
+ mSeasonNumber = seasonNumber;
+ }
+
+ @Override
+ public long getId(RecordedProgram program) {
+ return program.getId();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java b/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java
new file mode 100644
index 00000000..c2c0f596
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/SeriesRecordingPresenter.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.text.TextUtils;
+import android.view.ViewGroup;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.RecordedProgramListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrWatchedPositionManager;
+import com.android.tv.dvr.DvrWatchedPositionManager.WatchedPositionChangedListener;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+
+import java.util.List;
+
+/**
+ * Presents a {@link SeriesRecording} in {@link DvrBrowseFragment}.
+ */
+public class SeriesRecordingPresenter extends DvrItemPresenter {
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrDataManager mDvrDataManager;
+ private final DvrManager mDvrManager;
+ private final DvrWatchedPositionManager mWatchedPositionManager;
+
+ private static final class SeriesRecordingViewHolder extends ViewHolder implements
+ WatchedPositionChangedListener, ScheduledRecordingListener, RecordedProgramListener {
+ private SeriesRecording mSeriesRecording;
+ private RecordingCardView mCardView;
+ private DvrDataManager mDvrDataManager;
+ private DvrManager mDvrManager;
+ private DvrWatchedPositionManager mWatchedPositionManager;
+
+ SeriesRecordingViewHolder(RecordingCardView view, DvrDataManager dvrDataManager,
+ DvrManager dvrManager, DvrWatchedPositionManager watchedPositionManager) {
+ super(view);
+ mCardView = view;
+ mDvrDataManager = dvrDataManager;
+ mDvrManager = dvrManager;
+ mWatchedPositionManager = watchedPositionManager;
+ }
+
+ @Override
+ public void onWatchedPositionChanged(long recordedProgramId, long positionMs) {
+ if (positionMs != TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ mWatchedPositionManager.removeListener(this, recordedProgramId);
+ updateCardViewContent();
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording scheduledRecording : scheduledRecordings) {
+ if (scheduledRecording.getSeriesRecordingId() == mSeriesRecording.getId()) {
+ updateCardViewContent();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording scheduledRecording : scheduledRecordings) {
+ if (scheduledRecording.getSeriesRecordingId() == mSeriesRecording.getId()) {
+ updateCardViewContent();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsAdded(RecordedProgram... recordedPrograms) {
+ boolean needToUpdateCardView = false;
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (TextUtils.equals(recordedProgram.getSeriesId(),
+ mSeriesRecording.getSeriesId())) {
+ mDvrDataManager.removeScheduledRecordingListener(this);
+ mWatchedPositionManager.addListener(this, recordedProgram.getId());
+ needToUpdateCardView = true;
+ }
+ }
+ if (needToUpdateCardView) {
+ updateCardViewContent();
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsRemoved(RecordedProgram... recordedPrograms) {
+ boolean needToUpdateCardView = false;
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (TextUtils.equals(recordedProgram.getSeriesId(),
+ mSeriesRecording.getSeriesId())) {
+ if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId())
+ == TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ mWatchedPositionManager.removeListener(this, recordedProgram.getId());
+ }
+ needToUpdateCardView = true;
+ }
+ }
+ if (needToUpdateCardView) {
+ updateCardViewContent();
+ }
+ }
+
+ @Override
+ public void onRecordedProgramsChanged(RecordedProgram... recordedPrograms) {
+ // Do nothing
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ // Do nothing
+ }
+
+ public void onBound(SeriesRecording seriesRecording) {
+ mSeriesRecording = seriesRecording;
+ mDvrDataManager.addScheduledRecordingListener(this);
+ mDvrDataManager.addRecordedProgramListener(this);
+ for (RecordedProgram recordedProgram :
+ mDvrDataManager.getRecordedPrograms(mSeriesRecording.getId())) {
+ if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId())
+ == TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ mWatchedPositionManager.addListener(this, recordedProgram.getId());
+ }
+ }
+ updateCardViewContent();
+ }
+
+ public void onUnbound() {
+ mDvrDataManager.removeScheduledRecordingListener(this);
+ mDvrDataManager.removeRecordedProgramListener(this);
+ mWatchedPositionManager.removeListener(this);
+ }
+
+ private void updateCardViewContent() {
+ int count = 0;
+ int quantityStringID;
+ List<RecordedProgram> recordedPrograms =
+ mDvrDataManager.getRecordedPrograms(mSeriesRecording.getId());
+ if (recordedPrograms.size() == 0) {
+ count = mDvrManager.getAvailableScheduledRecording(mSeriesRecording.getId()).size();
+ quantityStringID = R.plurals.dvr_count_scheduled_recordings;
+ } else {
+ for (RecordedProgram recordedProgram : recordedPrograms) {
+ if (mWatchedPositionManager.getWatchedPosition(recordedProgram.getId())
+ == TvInputManager.TIME_SHIFT_INVALID_TIME) {
+ count++;
+ }
+ }
+ if (count == 0) {
+ count = recordedPrograms.size();
+ quantityStringID = R.plurals.dvr_count_recordings;
+ } else {
+ quantityStringID = R.plurals.dvr_count_new_recordings;
+ }
+ }
+ mCardView.setContent(mCardView.getResources()
+ .getQuantityString(quantityStringID, count, count), null);
+ }
+ }
+
+ public SeriesRecordingPresenter(Context context) {
+ ApplicationSingletons singletons = TvApplication.getSingletons(context);
+ mChannelDataManager = singletons.getChannelDataManager();
+ mDvrDataManager = singletons.getDvrDataManager();
+ mDvrManager = singletons.getDvrManager();
+ mWatchedPositionManager = singletons.getDvrWatchedPositionManager();
+ }
+
+ @Override
+ public ViewHolder onCreateViewHolder(ViewGroup parent) {
+ Context context = parent.getContext();
+ RecordingCardView view = new RecordingCardView(context);
+ return new SeriesRecordingViewHolder(view, mDvrDataManager, mDvrManager,
+ mWatchedPositionManager);
+ }
+
+ @Override
+ public void onBindViewHolder(ViewHolder baseHolder, Object o) {
+ final SeriesRecordingViewHolder viewHolder = (SeriesRecordingViewHolder) baseHolder;
+ final SeriesRecording seriesRecording = (SeriesRecording) o;
+ final RecordingCardView cardView = (RecordingCardView) viewHolder.view;
+ viewHolder.onBound(seriesRecording);
+ setTitleAndImage(cardView, seriesRecording);
+ super.onBindViewHolder(baseHolder, o);
+ }
+
+ @Override
+ public void onUnbindViewHolder(ViewHolder viewHolder) {
+ ((RecordingCardView) viewHolder.view).reset();
+ ((SeriesRecordingViewHolder) viewHolder).onUnbound();
+ super.onUnbindViewHolder(viewHolder);
+ }
+
+ private void setTitleAndImage(RecordingCardView cardView, SeriesRecording recording) {
+ cardView.setTitle(recording.getTitle());
+ if (recording.getPosterUri() != null) {
+ cardView.setImageUri(recording.getPosterUri(), false);
+ } else {
+ Channel channel = mChannelDataManager.getChannel(recording.getChannelId());
+ String imageUri = null;
+ if (channel != null) {
+ imageUri = TvContract.buildChannelLogoUri(channel.getId()).toString();
+ }
+ cardView.setImageUri(imageUri, true);
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java b/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java
new file mode 100644
index 00000000..6c05c9c6
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/SeriesSettingsFragment.java
@@ -0,0 +1,397 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.dvr.ui;
+
+import android.app.FragmentManager;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+import android.util.Log;
+import android.util.LongSparseArray;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ProgressBar;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.Channel;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.EpisodicProgramLoadTask;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.SeriesRecording.ChannelOption;
+import com.android.tv.dvr.SeriesRecordingScheduler;
+import com.android.tv.dvr.SeriesRecordingScheduler.OnSeriesRecordingUpdatedListener;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Fragment for DVR series recording settings.
+ */
+public class SeriesSettingsFragment extends GuidedStepFragment
+ implements DvrDataManager.SeriesRecordingListener {
+ private static final String TAG = "SeriesSettingsFragment";
+ private static final boolean DEBUG = false;
+
+ private static final long ACTION_ID_PRIORITY = 10;
+ private static final long ACTION_ID_CHANNEL = 11;
+
+ private static final long SUB_ACTION_ID_CHANNEL_ALL = 102;
+ // Each channel's action id = SUB_ACTION_ID_CHANNEL_ONE_BASE + channel id
+ private static final long SUB_ACTION_ID_CHANNEL_ONE_BASE = 500;
+
+ private DvrDataManager mDvrDataManager;
+ private ChannelDataManager mChannelDataManager;
+ private DvrManager mDvrManager;
+ private SeriesRecording mSeriesRecording;
+ private long mSeriesRecordingId;
+ @ChannelOption int mChannelOption;
+ private Comparator<Channel> mChannelComparator;
+ private long mSelectedChannelId;
+ private int mBackStackCount;
+ private boolean mShowViewScheduleOptionInDialog;
+
+ private String mFragmentTitle;
+ private String mProrityActionTitle;
+ private String mProrityActionHighestText;
+ private String mProrityActionLowestText;
+ private String mChannelsActionTitle;
+ private String mChannelsActionAllText;
+ private LongSparseArray<Channel> mId2Channel = new LongSparseArray<>();
+ private List<Channel> mChannels = new ArrayList<>();
+ private EpisodicProgramLoadTask mEpisodicProgramLoadTask;
+
+ private GuidedAction mPriorityGuidedAction;
+ private GuidedAction mChannelsGuidedAction;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mBackStackCount = getFragmentManager().getBackStackEntryCount();
+ mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ mSeriesRecordingId = getArguments().getLong(DvrSeriesSettingsActivity.SERIES_RECORDING_ID);
+ mSeriesRecording = mDvrDataManager.getSeriesRecording(mSeriesRecordingId);
+ if (mSeriesRecording == null) {
+ getActivity().finish();
+ return;
+ }
+ mDvrManager = TvApplication.getSingletons(context).getDvrManager();
+ mShowViewScheduleOptionInDialog = getArguments().getBoolean(
+ DvrSeriesSettingsActivity.SHOW_VIEW_SCHEDULE_OPTION_IN_DIALOG);
+ mDvrDataManager.addSeriesRecordingListener(this);
+ long[] channelIds = getArguments().getLongArray(DvrSeriesSettingsActivity.CHANNEL_ID_LIST);
+ mChannelDataManager = TvApplication.getSingletons(context).getChannelDataManager();
+ if (channelIds == null) {
+ Channel channel = mChannelDataManager.getChannel(mSeriesRecording.getChannelId());
+ if (channel != null) {
+ mId2Channel.put(channel.getId(), channel);
+ mChannels.add(channel);
+ }
+ collectChannelsInBackground();
+ } else {
+ for (long channelId : channelIds) {
+ Channel channel = mChannelDataManager.getChannel(channelId);
+ if (channel != null) {
+ mId2Channel.put(channel.getId(), channel);
+ mChannels.add(channel);
+ }
+ }
+ }
+ mChannelOption = mSeriesRecording.getChannelOption();
+ mSelectedChannelId = Channel.INVALID_ID;
+ if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE) {
+ Channel channel = mChannelDataManager.getChannel(mSeriesRecording.getChannelId());
+ if (channel != null) {
+ mSelectedChannelId = channel.getId();
+ } else {
+ mChannelOption = SeriesRecording.OPTION_CHANNEL_ALL;
+ }
+ }
+ mChannelComparator = new Channel.DefaultComparator(context,
+ TvApplication.getSingletons(context).getTvInputManagerHelper());
+ mChannels.sort(mChannelComparator);
+ mFragmentTitle = getString(R.string.dvr_series_settings_title);
+ mProrityActionTitle = getString(R.string.dvr_series_settings_priority);
+ mProrityActionHighestText = getString(R.string.dvr_series_settings_priority_highest);
+ mProrityActionLowestText = getString(R.string.dvr_series_settings_priority_lowest);
+ mChannelsActionTitle = getString(R.string.dvr_series_settings_channels);
+ mChannelsActionAllText = getString(R.string.dvr_series_settings_channels_all);
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ mDvrDataManager.removeSeriesRecordingListener(this);
+ if (mEpisodicProgramLoadTask != null) {
+ mEpisodicProgramLoadTask.cancel(true);
+ mEpisodicProgramLoadTask = null;
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ DvrManager dvrManager = TvApplication.getSingletons(getActivity()).getDvrManager();
+ if (getFragmentManager().getBackStackEntryCount() == mBackStackCount
+ && getArguments()
+ .getBoolean(DvrSeriesSettingsActivity.REMOVE_EMPTY_SERIES_RECORDING)
+ && dvrManager.canRemoveSeriesRecording(mSeriesRecordingId)) {
+ dvrManager.removeSeriesRecording(mSeriesRecordingId);
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String breadcrumb = mSeriesRecording.getTitle();
+ String title = mFragmentTitle;
+ return new Guidance(title, null, breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ mPriorityGuidedAction = new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_PRIORITY)
+ .title(mProrityActionTitle)
+ .build();
+ updatePriorityGuidedAction(false);
+ actions.add(mPriorityGuidedAction);
+
+ mChannelsGuidedAction = new GuidedAction.Builder(getActivity())
+ .id(ACTION_ID_CHANNEL)
+ .title(mChannelsActionTitle)
+ .subActions(buildChannelSubAction())
+ .build();
+ actions.add(mChannelsGuidedAction);
+ updateChannelsGuidedAction(false);
+ }
+
+ @Override
+ public void onCreateButtonActions(List<GuidedAction> actions, Bundle savedInstanceState) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .clickAction(GuidedAction.ACTION_ID_OK)
+ .build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .clickAction(GuidedAction.ACTION_ID_CANCEL)
+ .build());
+ }
+
+ @Override
+ public void onGuidedActionClicked(GuidedAction action) {
+ long actionId = action.getId();
+ if (actionId == GuidedAction.ACTION_ID_OK) {
+ if (mEpisodicProgramLoadTask != null) {
+ mEpisodicProgramLoadTask.cancel(true);
+ mEpisodicProgramLoadTask = null;
+ }
+ if (mChannelOption != mSeriesRecording.getChannelOption()
+ || mSeriesRecording.isStopped()
+ || (mChannelOption == SeriesRecording.OPTION_CHANNEL_ONE
+ && mSeriesRecording.getChannelId() != mSelectedChannelId)) {
+ SeriesRecording.Builder builder = SeriesRecording.buildFrom(mSeriesRecording)
+ .setChannelOption(mChannelOption)
+ .setState(SeriesRecording.STATE_SERIES_NORMAL);
+ if (mSelectedChannelId != Channel.INVALID_ID) {
+ builder.setChannelId(mSelectedChannelId);
+ }
+ TvApplication.getSingletons(getContext()).getDvrManager()
+ .updateSeriesRecording(builder.build());
+ SeriesRecordingScheduler scheduler =
+ SeriesRecordingScheduler.getInstance(getContext());
+ // Since dialog is used even after the fragment is closed, we should
+ // use application context.
+ ProgressDialog dialog = ProgressDialog.show(getContext(), null, getString(
+ R.string.dvr_series_schedules_progress_message_updating_programs));
+ scheduler.addOnSeriesRecordingUpdatedListener(
+ new OnSeriesRecordingUpdatedListener() {
+ @Override
+ public void onSeriesRecordingUpdated(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ if (seriesRecording.getId() == mSeriesRecordingId) {
+ dialog.dismiss();
+ scheduler.removeOnSeriesRecordingUpdatedListener(this);
+ showConfirmDialog();
+ return;
+ }
+ }
+ }
+ });
+ } else {
+ showConfirmDialog();
+ }
+ } else if (actionId == GuidedAction.ACTION_ID_CANCEL) {
+ finishGuidedStepFragments();
+ } else if (actionId == ACTION_ID_PRIORITY) {
+ FragmentManager fragmentManager = getFragmentManager();
+ PrioritySettingsFragment fragment = new PrioritySettingsFragment();
+ Bundle args = new Bundle();
+ args.putLong(PrioritySettingsFragment.COME_FROM_SERIES_RECORDING_ID,
+ mSeriesRecording.getId());
+ fragment.setArguments(args);
+ GuidedStepFragment.add(fragmentManager, fragment, R.id.dvr_settings_view_frame);
+ }
+ }
+
+ @Override
+ public boolean onSubGuidedActionClicked(GuidedAction action) {
+ long actionId = action.getId();
+ if (actionId == SUB_ACTION_ID_CHANNEL_ALL) {
+ mChannelOption = SeriesRecording.OPTION_CHANNEL_ALL;
+ mSelectedChannelId = Channel.INVALID_ID;
+ updateChannelsGuidedAction(true);
+ return true;
+ } else if (actionId > SUB_ACTION_ID_CHANNEL_ONE_BASE) {
+ mChannelOption = SeriesRecording.OPTION_CHANNEL_ONE;
+ mSelectedChannelId = actionId - SUB_ACTION_ID_CHANNEL_ONE_BASE;
+ updateChannelsGuidedAction(true);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public GuidedActionsStylist onCreateButtonActionsStylist() {
+ return new DvrGuidedActionsStylist(true);
+ }
+
+ private void updateChannelsGuidedAction(boolean notifyActionChanged) {
+ if (mChannelOption == SeriesRecording.OPTION_CHANNEL_ALL) {
+ mChannelsGuidedAction.setDescription(mChannelsActionAllText);
+ } else {
+ mChannelsGuidedAction.setDescription(mId2Channel.get(mSelectedChannelId)
+ .getDisplayText());
+ }
+ if (notifyActionChanged) {
+ notifyActionChanged(findActionPositionById(ACTION_ID_CHANNEL));
+ }
+ }
+
+ private void updatePriorityGuidedAction(boolean notifyActionChanged) {
+ int totalSeriesCount = 0;
+ int priorityOrder = 0;
+ for (SeriesRecording seriesRecording : mDvrDataManager.getSeriesRecordings()) {
+ if (seriesRecording.getState() == SeriesRecording.STATE_SERIES_NORMAL
+ || seriesRecording.getId() == mSeriesRecording.getId()) {
+ ++totalSeriesCount;
+ }
+ if (seriesRecording.getState() == SeriesRecording.STATE_SERIES_NORMAL
+ && seriesRecording.getId() != mSeriesRecording.getId()
+ && seriesRecording.getPriority() > mSeriesRecording.getPriority()) {
+ ++priorityOrder;
+ }
+ }
+ if (priorityOrder == 0) {
+ mPriorityGuidedAction.setDescription(mProrityActionHighestText);
+ } else if (priorityOrder >= totalSeriesCount - 1) {
+ mPriorityGuidedAction.setDescription(mProrityActionLowestText);
+ } else {
+ mPriorityGuidedAction.setDescription(getString(
+ R.string.dvr_series_settings_priority_rank, priorityOrder + 1));
+ }
+ if (notifyActionChanged) {
+ notifyActionChanged(findActionPositionById(ACTION_ID_PRIORITY));
+ }
+ }
+
+ private void collectChannelsInBackground() {
+ if (mEpisodicProgramLoadTask != null) {
+ mEpisodicProgramLoadTask.cancel(true);
+ }
+ mEpisodicProgramLoadTask = new EpisodicProgramLoadTask(getContext(), mSeriesRecording) {
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ mEpisodicProgramLoadTask = null;
+ Set<Long> channelIds = new HashSet<>();
+ for (Program program : programs) {
+ channelIds.add(program.getChannelId());
+ }
+ boolean channelAdded = false;
+ for (Long channelId : channelIds) {
+ if (mId2Channel.get(channelId) != null) {
+ continue;
+ }
+ Channel channel = mChannelDataManager.getChannel(channelId);
+ if (channel != null) {
+ channelAdded = true;
+ mId2Channel.put(channelId, channel);
+ mChannels.add(channel);
+ if (DEBUG) Log.d(TAG, "Added channel: " + channel);
+ }
+ }
+ if (!channelAdded) {
+ return;
+ }
+ mChannels.sort(mChannelComparator);
+ mChannelsGuidedAction.setSubActions(buildChannelSubAction());
+ notifyActionChanged(findActionPositionById(ACTION_ID_CHANNEL));
+ if (DEBUG) Log.d(TAG, "Complete EpisodicProgramLoadTask");
+ }
+ }.setLoadCurrentProgram(true)
+ .setLoadDisallowedProgram(true)
+ .setLoadScheduledEpisode(true)
+ .setIgnoreChannelOption(true);
+ mEpisodicProgramLoadTask.execute();
+ }
+
+ private List<GuidedAction> buildChannelSubAction() {
+ List<GuidedAction> channelSubActions = new ArrayList<>();
+ channelSubActions.add(new GuidedAction.Builder(getActivity())
+ .id(SUB_ACTION_ID_CHANNEL_ALL)
+ .title(mChannelsActionAllText)
+ .build());
+ for (Channel channel : mChannels) {
+ channelSubActions.add(new GuidedAction.Builder(getActivity())
+ .id(SUB_ACTION_ID_CHANNEL_ONE_BASE + channel.getId())
+ .title(channel.getDisplayText())
+ .build());
+ }
+ return channelSubActions;
+ }
+
+ private void showConfirmDialog() {
+ DvrUiHelper.StartSeriesScheduledDialogActivity(
+ getContext(), mSeriesRecording, mShowViewScheduleOptionInDialog);
+ finishGuidedStepFragments();
+ }
+
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) { }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording seriesRecording : seriesRecordings) {
+ if (seriesRecording.getId() == mSeriesRecordingId) {
+ mSeriesRecording = seriesRecording;
+ updatePriorityGuidedAction(true);
+ return;
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/SortedArrayAdapter.java b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java
index 8a8bcdeb..393a5ff3 100644
--- a/src/com/android/tv/dvr/ui/SortedArrayAdapter.java
+++ b/src/com/android/tv/dvr/ui/SortedArrayAdapter.java
@@ -16,7 +16,8 @@
package com.android.tv.dvr.ui;
-import android.support.v17.leanback.widget.ObjectAdapter;
+import android.support.annotation.VisibleForTesting;
+import android.support.v17.leanback.widget.ArrayObjectAdapter;
import android.support.v17.leanback.widget.PresenterSelector;
import java.util.ArrayList;
@@ -26,168 +27,155 @@ import java.util.Comparator;
import java.util.List;
/**
- * Keeps a set of {@code T} items sorted, but leaving a {@link EmptyHolder}
- * if there is no items.
+ * Keeps a set of items sorted
*
* <p>{@code T} must have stable IDs.
*/
-abstract class SortedArrayAdapter<T> extends ObjectAdapter {
- private final List<T> mItems = new ArrayList<>();
+public abstract class SortedArrayAdapter<T> extends ArrayObjectAdapter {
private final Comparator<T> mComparator;
+ private final int mMaxItemCount;
+ private int mExtraItemCount;
SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator) {
- super(presenterSelector);
- mComparator = comparator;
- setHasStableIds(true);
- }
-
- @Override
- public final int size() {
- return mItems.isEmpty() ? 1 : mItems.size();
- }
-
- @Override
- public final Object get(int position) {
- return isEmpty() ? EmptyHolder.EMPTY_HOLDER : getItem(position);
+ this(presenterSelector, comparator, Integer.MAX_VALUE);
}
- @Override
- public final long getId(int position) {
- if (isEmpty()) {
- return NO_ID;
- }
- T item = mItems.get(position);
- return item == null ? NO_ID : getId(item);
- }
-
- /**
- * Returns the id of the the given {@code item}.
- *
- * The id must be stable.
- */
- abstract long getId(T item);
-
- /**
- * Returns the item at the given {@code position}.
- *
- * @throws IndexOutOfBoundsException if the position is out of range
- * (<tt>position &lt; 0 || position &gt;= size()</tt>)
- */
- final T getItem(int position) {
- return mItems.get(position);
+ SortedArrayAdapter(PresenterSelector presenterSelector, Comparator<T> comparator,
+ int maxItemCount) {
+ super(presenterSelector);
+ mComparator = comparator;
+ mMaxItemCount = maxItemCount;
}
/**
- * Returns {@code true} if the list of items is empty.
+ * Sets the objects in the given collection to the adapter keeping the elements sorted.
*
- * <p><b>NOTE</b> when the item list is empty the adapter has a size of 1 and
- * {@link EmptyHolder#EMPTY_HOLDER} at position 0;
+ * @param items A {@link Collection} of items to be set.
*/
- final boolean isEmpty() {
- return mItems.isEmpty();
+ @VisibleForTesting
+ final void setInitialItems(List<T> items) {
+ List<T> itemsCopy = new ArrayList<>(items);
+ Collections.sort(itemsCopy, mComparator);
+ addAll(0, itemsCopy.subList(0, Math.min(mMaxItemCount, itemsCopy.size())));
}
/**
- * Removes all elements from the list.
+ * Adds an item in sorted order to the adapter.
*
- * <p><b>NOTE</b> when the item list is empty the adapter has a size of 1 and
- * {@link EmptyHolder#EMPTY_HOLDER} at position 0;
+ * @param item The item to add in sorted order to the adapter.
*/
- final void clear() {
- mItems.clear();
- notifyChanged();
+ @Override
+ public final void add(Object item) {
+ add((T) item, false);
}
- /**
- * Adds the objects in the given collection to the adapter keeping the elements sorted.
- * If the index is >= {@link #size} an exception will be thrown.
- *
- * @param items A {@link Collection} of items to insert.
- */
- final void addAll(Collection<T> items) {
- mItems.addAll(items);
- Collections.sort(mItems, mComparator);
- notifyChanged();
+ public boolean isEmpty() {
+ return size() == 0;
}
/**
* Adds an item in sorted order to the adapter.
*
* @param item The item to add in sorted order to the adapter.
+ * @param insertToEnd If items are inserted in a more or less sorted fashion,
+ * sets this parameter to {@code true} to search insertion position from
+ * the end to save search time.
*/
- final void add(T item) {
- int i = findWhereToInsert(item);
- mItems.add(i, item);
- if (mItems.size() == 1) {
- notifyItemRangeChanged(0, 1);
+ public final void add(T item, boolean insertToEnd) {
+ int i;
+ if (insertToEnd) {
+ i = findInsertPosition(item);
} else {
- notifyItemRangeInserted(i, 1);
+ i = findInsertPositionBinary(item);
+ }
+ super.add(i, item);
+ if (size() > mMaxItemCount + mExtraItemCount) {
+ removeItems(mMaxItemCount, size() - mMaxItemCount - mExtraItemCount);
}
}
/**
- * Remove an item from the list
- *
- * @param item The item to remove from the adapter.
+ * Adds an extra item to the end of the adapter. The items will not be subjected to the sorted
+ * order or the maximum number of items. One or more extra items can be added to the adapter.
+ * They will be presented in their insertion order.
*/
- final void remove(T item) {
- int index = indexOf(item);
- if (index != -1) {
- mItems.remove(index);
- if (mItems.isEmpty()) {
- notifyItemRangeChanged(0, 1);
- } else {
- notifyItemRangeRemoved(index, 1);
- }
- }
+ public int addExtraItem(T item) {
+ super.add(item);
+ return ++mExtraItemCount;
+ }
+
+ /**
+ * Removes an item which has the same ID as {@code item}.
+ */
+ public boolean removeWithId(T item) {
+ int index = indexWithTypeAndId(item);
+ return index >= 0 && index < size() && remove(get(index));
}
/**
* Change an item in the list.
* @param item The item to change.
*/
- final void change(T item) {
- int oldIndex = indexOf(item);
+ public final void change(T item) {
+ int oldIndex = indexWithTypeAndId(item);
if (oldIndex != -1) {
- T old = mItems.get(oldIndex);
+ T old = (T) get(oldIndex);
if (mComparator.compare(old, item) == 0) {
- mItems.set(oldIndex, item);
- notifyItemRangeChanged(oldIndex, 1);
+ replace(oldIndex, item);
return;
}
- mItems.remove(oldIndex);
- }
- int newIndex = findWhereToInsert(item);
- mItems.add(newIndex, item);
-
- if (oldIndex != -1) {
- notifyItemRangeRemoved(oldIndex, 1);
- }
- if (newIndex != -1) {
- notifyItemRangeInserted(newIndex, 1);
+ removeItems(oldIndex, 1);
}
+ add(item);
}
- private int indexOf(T item) {
+ /**
+ * Returns the id of the the given {@code item}, which will be used in {@link #change} to
+ * decide if the given item is already existed in the adapter.
+ *
+ * The id must be stable.
+ */
+ abstract long getId(T item);
+
+ private int indexWithTypeAndId(T item) {
long id = getId(item);
- for (int i = 0; i < mItems.size(); i++) {
- T r = mItems.get(i);
- if (getId(r) == id) {
+ for (int i = 0; i < size() - mExtraItemCount; i++) {
+ T r = (T) get(i);
+ if (r.getClass() == item.getClass() && getId(r) == id) {
return i;
}
}
return -1;
}
- private int findWhereToInsert(T item) {
- int i;
- int size = mItems.size();
- for (i = 0; i < size; i++) {
- T r = mItems.get(i);
- if (mComparator.compare(r, item) > 0) {
- return i;
+ /**
+ * Finds the position that the given item should be inserted to keep the sorted order.
+ */
+ public int findInsertPosition(T item) {
+ for (int i = size() - mExtraItemCount - 1; i >=0; i--) {
+ T r = (T) get(i);
+ if (mComparator.compare(r, item) <= 0) {
+ return i + 1;
+ }
+ }
+ return 0;
+ }
+
+ private int findInsertPositionBinary(T item) {
+ int lb = 0;
+ int ub = size() - mExtraItemCount - 1;
+ while (lb <= ub) {
+ int mid = (lb + ub) / 2;
+ T r = (T) get(mid);
+ int compareResult = mComparator.compare(item, r);
+ if (compareResult == 0) {
+ return mid;
+ } else if (compareResult > 0) {
+ lb = mid + 1;
+ } else {
+ ub = mid - 1;
}
}
- return size;
+ return lb;
}
-}
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java
new file mode 100644
index 00000000..d28f026c
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/BaseDvrSchedulesFragment.java
@@ -0,0 +1,178 @@
+/*
+* Copyright (C) 2016 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License
+*/
+
+package com.android.tv.dvr.ui.list;
+
+import android.os.Bundle;
+import android.support.v17.leanback.app.DetailsFragment;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.ScheduledRecording;
+
+/**
+ * A base fragment to show the list of schedule recordings.
+ */
+public abstract class BaseDvrSchedulesFragment extends DetailsFragment
+ implements DvrDataManager.ScheduledRecordingListener,
+ DvrScheduleManager.OnConflictStateChangeListener {
+ /**
+ * The key for scheduled recording which has be selected in the list.
+ */
+ public static String SCHEDULES_KEY_SCHEDULED_RECORDING = "schedules_key_scheduled_recording";
+
+ private ScheduleRowAdapter mRowsAdapter;
+ private TextView mEmptyInfoScreenView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ ClassPresenterSelector presenterSelector = new ClassPresenterSelector();
+ presenterSelector.addClassPresenter(SchedulesHeaderRow.class, onCreateHeaderRowPresenter());
+ presenterSelector.addClassPresenter(ScheduleRow.class, onCreateRowPresenter());
+ mRowsAdapter = onCreateRowsAdapter(presenterSelector);
+ setAdapter(mRowsAdapter);
+ mRowsAdapter.start();
+ ApplicationSingletons singletons = TvApplication.getSingletons(getContext());
+ singletons.getDvrDataManager().addScheduledRecordingListener(this);
+ singletons.getDvrScheduleManager().addOnConflictStateChangeListener(this);
+ mEmptyInfoScreenView = (TextView) getActivity().findViewById(R.id.empty_info_screen);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = super.onCreateView(inflater, container, savedInstanceState);
+ int firstItemPosition = getFirstItemPosition();
+ if (firstItemPosition != -1) {
+ getRowsFragment().setSelectedPosition(firstItemPosition, false);
+ }
+ return view;
+ }
+
+ /**
+ * Returns rows adapter.
+ */
+ protected ScheduleRowAdapter getRowsAdapter() {
+ return mRowsAdapter;
+ }
+
+ /**
+ * Shows the empty message.
+ */
+ void showEmptyMessage(int messageId) {
+ mEmptyInfoScreenView.setText(messageId);
+ if (mEmptyInfoScreenView.getVisibility() != View.VISIBLE) {
+ mEmptyInfoScreenView.setVisibility(View.VISIBLE);
+ }
+ }
+
+ /**
+ * Hides the empty message.
+ */
+ void hideEmptyMessage() {
+ if (mEmptyInfoScreenView.getVisibility() == View.VISIBLE) {
+ mEmptyInfoScreenView.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public View onInflateTitleView(LayoutInflater inflater, ViewGroup parent,
+ Bundle savedInstanceState) {
+ // Workaround of b/31046014
+ return null;
+ }
+
+ @Override
+ public void onDestroy() {
+ ApplicationSingletons singletons = TvApplication.getSingletons(getContext());
+ singletons.getDvrScheduleManager().removeOnConflictStateChangeListener(this);
+ singletons.getDvrDataManager().removeScheduledRecordingListener(this);
+ mRowsAdapter.stop();
+ super.onDestroy();
+ }
+
+ /**
+ * Creates header row presenter.
+ */
+ public abstract SchedulesHeaderRowPresenter onCreateHeaderRowPresenter();
+
+ /**
+ * Creates rows presenter.
+ */
+ public abstract ScheduleRowPresenter onCreateRowPresenter();
+
+ /**
+ * Creates rows adapter.
+ */
+ public abstract ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelecor);
+
+ /**
+ * Gets the first focus position in schedules list.
+ */
+ protected int getFirstItemPosition() {
+ for (int i = 0; i < mRowsAdapter.size(); i++) {
+ if (mRowsAdapter.get(i) instanceof ScheduleRow) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ if (mRowsAdapter != null) {
+ for (ScheduledRecording recording : scheduledRecordings) {
+ mRowsAdapter.onScheduledRecordingAdded(recording);
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ if (mRowsAdapter != null) {
+ for (ScheduledRecording recording : scheduledRecordings) {
+ mRowsAdapter.onScheduledRecordingRemoved(recording);
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ if (mRowsAdapter != null) {
+ for (ScheduledRecording recording : scheduledRecordings) {
+ mRowsAdapter.onScheduledRecordingUpdated(recording, false);
+ }
+ }
+ }
+
+ @Override
+ public void onConflictStateChange(boolean conflict, ScheduledRecording... schedules) {
+ if (mRowsAdapter != null) {
+ for (ScheduledRecording recording : schedules) {
+ mRowsAdapter.onScheduledRecordingUpdated(recording, true);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java
new file mode 100644
index 00000000..c906c62a
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFocusView.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+
+import com.android.tv.R;
+
+/**
+ * A view used for focus in schedules list.
+ */
+public class DvrSchedulesFocusView extends View {
+ private final Paint mPaint;
+ private final RectF mRoundRectF = new RectF();
+ private final int mRoundRectRadius;
+
+ private final String mViewTag;
+ private final String mHeaderFocusViewTag;
+ private final String mItemFocusViewTag;
+
+ public DvrSchedulesFocusView(Context context) {
+ this(context, null, 0);
+ }
+
+ public DvrSchedulesFocusView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public DvrSchedulesFocusView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ mHeaderFocusViewTag = getContext().getString(R.string.dvr_schedules_header_focus_view);
+ mItemFocusViewTag = getContext().getString(R.string.dvr_schedules_item_focus_view);
+ mViewTag = (String) getTag();
+ mPaint = createPaint(context);
+ mRoundRectRadius = getRoundRectRadius();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ if (TextUtils.equals(mViewTag, mHeaderFocusViewTag)) {
+ mRoundRectF.set(0, 0, getWidth(), getHeight());
+ } else if (TextUtils.equals(mViewTag, mItemFocusViewTag)) {
+ int drawHeight = 2 * mRoundRectRadius;
+ int drawOffset = (drawHeight - getHeight()) / 2;
+ mRoundRectF.set(0, -drawOffset, getWidth(), getHeight() + drawOffset);
+ }
+ canvas.drawRoundRect(mRoundRectF, mRoundRectRadius, mRoundRectRadius, mPaint);
+ }
+
+ private Paint createPaint(Context context) {
+ Paint paint = new Paint();
+ paint.setColor(context.getColor(R.color.dvr_schedules_list_item_selector));
+ return paint;
+ }
+
+ private int getRoundRectRadius() {
+ if (TextUtils.equals(mViewTag, mHeaderFocusViewTag)) {
+ return getResources().getDimensionPixelSize(
+ R.dimen.dvr_schedules_header_selector_radius);
+ } else if (TextUtils.equals(mViewTag, mItemFocusViewTag)) {
+ return getResources().getDimensionPixelSize(R.dimen.dvr_schedules_selector_radius);
+ }
+ return 0;
+ }
+}
+
+
diff --git a/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java
new file mode 100644
index 00000000..722c9b6e
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/DvrSchedulesFragment.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.os.Bundle;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.R;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.DateHeaderRowPresenter;
+
+/**
+ * A fragment to show the list of schedule recordings.
+ */
+public class DvrSchedulesFragment extends BaseDvrSchedulesFragment {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (getRowsAdapter().size() == 0) {
+ showEmptyMessage(R.string.dvr_schedules_empty_state);
+ }
+ }
+
+ @Override
+ public SchedulesHeaderRowPresenter onCreateHeaderRowPresenter() {
+ return new DateHeaderRowPresenter(getContext());
+ }
+
+ @Override
+ public ScheduleRowPresenter onCreateRowPresenter() {
+ return new ScheduleRowPresenter(getContext());
+ }
+
+ @Override
+ public ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelecor) {
+ return new ScheduleRowAdapter(getContext(), presenterSelecor);
+ }
+
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ super.onScheduledRecordingAdded(scheduledRecordings);
+ if (getRowsAdapter().size() > 0) {
+ hideEmptyMessage();
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ super.onScheduledRecordingRemoved(scheduledRecordings);
+ if (getRowsAdapter().size() == 0) {
+ showEmptyMessage(R.string.dvr_schedules_empty_state);
+ }
+ }
+
+ @Override
+ protected int getFirstItemPosition() {
+ Bundle args = getArguments();
+ ScheduledRecording recording = null;
+ if (args != null) {
+ recording = args.getParcelable(SCHEDULES_KEY_SCHEDULED_RECORDING);
+ }
+ final int selectedPostion = getRowsAdapter().indexOf(
+ getRowsAdapter().findRowByScheduledRecording(recording));
+ if (selectedPostion != -1) {
+ return selectedPostion;
+ }
+ return super.getFirstItemPosition();
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java
new file mode 100644
index 00000000..42a1e72b
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/DvrSeriesSchedulesFragment.java
@@ -0,0 +1,208 @@
+/*
+* Copyright (C) 2016 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License
+*/
+
+package com.android.tv.dvr.ui.list;
+
+import android.annotation.TargetApi;
+import android.database.ContentObserver;
+import android.media.tv.TvContract.Programs;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.transition.Fade;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager.SeriesRecordingListener;
+import com.android.tv.dvr.EpisodicProgramLoadTask;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.ui.list.SchedulesHeaderRowPresenter.SeriesRecordingHeaderRowPresenter;
+
+import java.util.List;
+
+/**
+ * A fragment to show the list of series schedule recordings.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class DvrSeriesSchedulesFragment extends BaseDvrSchedulesFragment {
+ private static final String TAG = "DvrSeriesSchedulesFragment";
+ /**
+ * The key for series recording whose scheduled recording list will be displayed.
+ */
+ public static final String SERIES_SCHEDULES_KEY_SERIES_RECORDING =
+ "series_schedules_key_series_recording";
+ /**
+ * The key for programs belong to the series recording whose scheduled recording
+ * list will be displayed.
+ */
+ public static final String SERIES_SCHEDULES_KEY_SERIES_PROGRAMS =
+ "series_schedules_key_series_programs";
+
+ private ChannelDataManager mChannelDataManager;
+ private SeriesRecording mSeriesRecording;
+ private List<Program> mPrograms;
+ private EpisodicProgramLoadTask mProgramLoadTask;
+
+ private final SeriesRecordingListener mSeriesRecordingListener =
+ new SeriesRecordingListener() {
+ @Override
+ public void onSeriesRecordingAdded(SeriesRecording... seriesRecordings) { }
+
+ @Override
+ public void onSeriesRecordingRemoved(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording r : seriesRecordings) {
+ if (r.getId() == mSeriesRecording.getId()) {
+ getActivity().finish();
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void onSeriesRecordingChanged(SeriesRecording... seriesRecordings) {
+ for (SeriesRecording r : seriesRecordings) {
+ if (r.getId() == mSeriesRecording.getId()
+ && getRowsAdapter() instanceof SeriesScheduleRowAdapter) {
+ ((SeriesScheduleRowAdapter) getRowsAdapter())
+ .onSeriesRecordingUpdated(r);
+ return;
+ }
+ }
+ }
+ };
+
+ private final ContentObserver mContentObserver =
+ new ContentObserver(new Handler(Looper.getMainLooper())) {
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ super.onChange(selfChange, uri);
+ executeProgramLoadingTask();
+ }
+ };
+
+ private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() {
+ @Override
+ public void onLoadFinished() { }
+
+ @Override
+ public void onChannelListUpdated() {
+ executeProgramLoadingTask();
+ }
+
+ @Override
+ public void onChannelBrowsableChanged() { }
+ };
+
+ public DvrSeriesSchedulesFragment() {
+ setEnterTransition(new Fade(Fade.IN));
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ Bundle args = getArguments();
+ if (args != null) {
+ mSeriesRecording = args.getParcelable(SERIES_SCHEDULES_KEY_SERIES_RECORDING);
+ mPrograms = args.getParcelableArrayList(SERIES_SCHEDULES_KEY_SERIES_PROGRAMS);
+ }
+ super.onCreate(savedInstanceState);
+ ApplicationSingletons singletons = TvApplication.getSingletons(getContext());
+ singletons.getDvrDataManager().addSeriesRecordingListener(mSeriesRecordingListener);
+ mChannelDataManager = singletons.getChannelDataManager();
+ mChannelDataManager.addListener(mChannelListener);
+ getContext().getContentResolver().registerContentObserver(Programs.CONTENT_URI, true,
+ mContentObserver);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ onProgramsUpdated();
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ private void onProgramsUpdated() {
+ ((SeriesScheduleRowAdapter) getRowsAdapter()).setPrograms(mPrograms);
+ if (mPrograms == null || mPrograms.isEmpty()) {
+ showEmptyMessage(R.string.dvr_series_schedules_empty_state);
+ } else {
+ hideEmptyMessage();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (mProgramLoadTask != null) {
+ mProgramLoadTask.cancel(true);
+ mProgramLoadTask = null;
+ }
+ getContext().getContentResolver().unregisterContentObserver(mContentObserver);
+ mChannelDataManager.removeListener(mChannelListener);
+ TvApplication.getSingletons(getContext()).getDvrDataManager()
+ .removeSeriesRecordingListener(mSeriesRecordingListener);
+ super.onDestroy();
+ }
+
+ @Override
+ public SchedulesHeaderRowPresenter onCreateHeaderRowPresenter() {
+ return new SeriesRecordingHeaderRowPresenter(getContext());
+ }
+
+ @Override
+ public ScheduleRowPresenter onCreateRowPresenter() {
+ return new SeriesScheduleRowPresenter(getContext());
+ }
+
+ @Override
+ public ScheduleRowAdapter onCreateRowsAdapter(ClassPresenterSelector presenterSelector) {
+ return new SeriesScheduleRowAdapter(getContext(), presenterSelector, mSeriesRecording);
+ }
+
+ @Override
+ protected int getFirstItemPosition() {
+ if (mSeriesRecording != null
+ && mSeriesRecording.getState() == SeriesRecording.STATE_SERIES_STOPPED) {
+ return 0;
+ }
+ return super.getFirstItemPosition();
+ }
+
+ private void executeProgramLoadingTask() {
+ if (mProgramLoadTask != null) {
+ mProgramLoadTask.cancel(true);
+ }
+ mProgramLoadTask = new EpisodicProgramLoadTask(getContext(), mSeriesRecording) {
+ @Override
+ protected void onPostExecute(List<Program> programs) {
+ mPrograms = programs;
+ onProgramsUpdated();
+ }
+ };
+ mProgramLoadTask.setLoadCurrentProgram(true)
+ .setLoadDisallowedProgram(true)
+ .setLoadScheduledEpisode(true)
+ .setIgnoreChannelOption(true)
+ .execute();
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java b/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java
new file mode 100644
index 00000000..23aebf59
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/EpisodicProgramRow.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.content.Context;
+
+import com.android.tv.data.Program;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ScheduledRecording.Builder;
+
+/**
+ * A class for the episodic program.
+ */
+public class EpisodicProgramRow extends ScheduleRow {
+ private final String mInputId;
+ private final Program mProgram;
+
+ public EpisodicProgramRow(String inputId, Program program, ScheduledRecording recording,
+ SchedulesHeaderRow headerRow) {
+ super(recording, headerRow);
+ mInputId = inputId;
+ mProgram = program;
+ }
+
+ /**
+ * Returns the program.
+ */
+ public Program getProgram() {
+ return mProgram;
+ }
+
+ @Override
+ public long getChannelId() {
+ return mProgram.getChannelId();
+ }
+
+ @Override
+ public long getStartTimeMs() {
+ return mProgram.getStartTimeUtcMillis();
+ }
+
+ @Override
+ public long getEndTimeMs() {
+ return mProgram.getEndTimeUtcMillis();
+ }
+
+ @Override
+ public Builder createNewScheduleBuilder() {
+ return ScheduledRecording.builder(mInputId, mProgram);
+ }
+
+ @Override
+ public String getProgramTitleWithEpisodeNumber(Context context) {
+ return mProgram.getTitleWithEpisodeNumber(context);
+ }
+
+ @Override
+ public String getEpisodeDisplayTitle(Context context) {
+ return mProgram.getEpisodeDisplayTitle(context);
+ }
+
+ @Override
+ public boolean matchSchedule(ScheduledRecording schedule) {
+ return schedule.getType() == ScheduledRecording.TYPE_PROGRAM
+ && mProgram.getId() == schedule.getProgramId();
+ }
+
+ @Override
+ public String toString() {
+ return super.toString()
+ + "(inputId=" + mInputId
+ + ",program=" + mProgram
+ + ")";
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRow.java b/src/com/android/tv/dvr/ui/list/ScheduleRow.java
new file mode 100644
index 00000000..3fc92e8a
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/ScheduleRow.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.ScheduledRecording;
+
+/**
+ * A class for schedule recording row.
+ */
+public class ScheduleRow {
+ private final SchedulesHeaderRow mHeaderRow;
+ @Nullable private ScheduledRecording mSchedule;
+ private boolean mStopRecordingRequested;
+ private boolean mStartRecordingRequested;
+
+ public ScheduleRow(@Nullable ScheduledRecording recording, SchedulesHeaderRow headerRow) {
+ mSchedule = recording;
+ mHeaderRow = headerRow;
+ }
+
+ /**
+ * Gets which {@link SchedulesHeaderRow} this schedule row belongs to.
+ */
+ public SchedulesHeaderRow getHeaderRow() {
+ return mHeaderRow;
+ }
+
+ /**
+ * Returns the recording schedule.
+ */
+ @Nullable
+ public ScheduledRecording getSchedule() {
+ return mSchedule;
+ }
+
+ /**
+ * Checks if the stop recording has been requested or not.
+ */
+ public boolean isStopRecordingRequested() {
+ return mStopRecordingRequested;
+ }
+
+ /**
+ * Sets the flag of stop recording request.
+ */
+ public void setStopRecordingRequested(boolean stopRecordingRequested) {
+ SoftPreconditions.checkState(!mStartRecordingRequested);
+ mStopRecordingRequested = stopRecordingRequested;
+ }
+
+ /**
+ * Checks if the start recording has been requested or not.
+ */
+ public boolean isStartRecordingRequested() {
+ return mStartRecordingRequested;
+ }
+
+ /**
+ * Sets the flag of start recording request.
+ */
+ public void setStartRecordingRequested(boolean startRecordingRequested) {
+ SoftPreconditions.checkState(!mStopRecordingRequested);
+ mStartRecordingRequested = startRecordingRequested;
+ }
+
+ /**
+ * Sets the recording schedule.
+ */
+ public void setSchedule(@Nullable ScheduledRecording schedule) {
+ mSchedule = schedule;
+ }
+
+ /**
+ * Returns the channel ID.
+ */
+ public long getChannelId() {
+ return mSchedule != null ? mSchedule.getChannelId() : -1;
+ }
+
+ /**
+ * Returns the start time.
+ */
+ public long getStartTimeMs() {
+ return mSchedule != null ? mSchedule.getStartTimeMs() : -1;
+ }
+
+ /**
+ * Returns the end time.
+ */
+ public long getEndTimeMs() {
+ return mSchedule != null ? mSchedule.getEndTimeMs() : -1;
+ }
+
+ /**
+ * Returns the duration.
+ */
+ public final long getDuration() {
+ return getEndTimeMs() - getStartTimeMs();
+ }
+
+ /**
+ * Checks if the program is on air.
+ */
+ public final boolean isOnAir() {
+ long currentTimeMs = System.currentTimeMillis();
+ return getStartTimeMs() <= currentTimeMs && getEndTimeMs() > currentTimeMs;
+ }
+
+ /**
+ * Checks if the schedule is not started.
+ */
+ public final boolean isRecordingNotStarted() {
+ return mSchedule != null
+ && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED;
+ }
+
+ /**
+ * Checks if the schedule is in progress.
+ */
+ public final boolean isRecordingInProgress() {
+ return mSchedule != null
+ && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS;
+ }
+
+ /**
+ * Checks if the schedule has been canceled or not.
+ */
+ public final boolean isScheduleCanceled() {
+ return mSchedule != null
+ && mSchedule.getState() == ScheduledRecording.STATE_RECORDING_CANCELED;
+ }
+
+ public boolean isRecordingFinished() {
+ return mSchedule != null
+ && (mSchedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED
+ || mSchedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED
+ || mSchedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED);
+ }
+
+ /**
+ * Creates and returns the new schedule with the existing information.
+ */
+ public ScheduledRecording.Builder createNewScheduleBuilder() {
+ return mSchedule != null ? ScheduledRecording.buildFrom(mSchedule) : null;
+ }
+
+ /**
+ * Returns the program title with episode number.
+ */
+ public String getProgramTitleWithEpisodeNumber(Context context) {
+ return mSchedule != null ? mSchedule.getProgramTitleWithEpisodeNumber(context) : null;
+ }
+
+ /**
+ * Returns the program title including the season/episode number.
+ */
+ public String getEpisodeDisplayTitle(Context context) {
+ return mSchedule != null ? mSchedule.getEpisodeDisplayTitle(context) : null;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName()
+ + "(schedule=" + mSchedule
+ + ",stopRecordingRequested=" + mStopRecordingRequested
+ + ",startRecordingRequested=" + mStartRecordingRequested
+ + ")";
+ }
+
+ /**
+ * Checks if the {@code schedule} is for the program or channel.
+ */
+ public boolean matchSchedule(ScheduledRecording schedule) {
+ if (mSchedule == null) {
+ return false;
+ }
+ if (mSchedule.getType() == ScheduledRecording.TYPE_TIMED) {
+ return mSchedule.getChannelId() == schedule.getChannelId()
+ && mSchedule.getStartTimeMs() == schedule.getStartTimeMs()
+ && mSchedule.getEndTimeMs() == schedule.getEndTimeMs();
+ } else {
+ return mSchedule.getProgramId() == schedule.getProgramId();
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java
new file mode 100644
index 00000000..9cc82653
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/ScheduleRowAdapter.java
@@ -0,0 +1,425 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.support.v17.leanback.widget.ArrayObjectAdapter;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.text.format.DateUtils;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow;
+import com.android.tv.util.Utils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * An adapter for {@link ScheduleRow}.
+ */
+public class ScheduleRowAdapter extends ArrayObjectAdapter {
+ private static final String TAG = "ScheduleRowAdapter";
+ private static final boolean DEBUG = false;
+
+ private final static long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1);
+
+ private static final int MSG_UPDATE_ROW = 1;
+
+ private Context mContext;
+ private final List<String> mTitles = new ArrayList<>();
+ private final Set<ScheduleRow> mPendingUpdate = new ArraySet<>();
+
+ private final Handler mHandler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == MSG_UPDATE_ROW) {
+ long currentTimeMs = System.currentTimeMillis();
+ handleUpdateRow(currentTimeMs);
+ sendNextUpdateMessage(currentTimeMs);
+ }
+ }
+ };
+
+ public ScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector) {
+ super(classPresenterSelector);
+ mContext = context;
+ mTitles.add(mContext.getString(R.string.dvr_date_today));
+ mTitles.add(mContext.getString(R.string.dvr_date_tomorrow));
+ }
+
+ /**
+ * Returns context.
+ */
+ protected Context getContext() {
+ return mContext;
+ }
+
+ /**
+ * Starts schedule row adapter.
+ */
+ public void start() {
+ clear();
+ List<ScheduledRecording> recordingList = TvApplication.getSingletons(mContext)
+ .getDvrDataManager().getNonStartedScheduledRecordings();
+ recordingList.addAll(TvApplication.getSingletons(mContext).getDvrDataManager()
+ .getStartedRecordings());
+ Collections.sort(recordingList,
+ ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR);
+ long deadLine = Utils.getLastMillisecondOfDay(System.currentTimeMillis());
+ for (int i = 0; i < recordingList.size();) {
+ ArrayList<ScheduledRecording> section = new ArrayList<>();
+ while (i < recordingList.size() && recordingList.get(i).getStartTimeMs() < deadLine) {
+ section.add(recordingList.get(i++));
+ }
+ if (!section.isEmpty()) {
+ SchedulesHeaderRow headerRow = new DateHeaderRow(calculateHeaderDate(deadLine),
+ mContext.getResources().getQuantityString(
+ R.plurals.dvr_schedules_section_subtitle, section.size(), section.size()),
+ section.size(), deadLine);
+ add(headerRow);
+ for(ScheduledRecording recording : section){
+ add(new ScheduleRow(recording, headerRow));
+ }
+ }
+ deadLine += ONE_DAY_MS;
+ }
+ sendNextUpdateMessage(System.currentTimeMillis());
+ }
+
+ private String calculateHeaderDate(long deadLine) {
+ int titleIndex = (int) ((deadLine -
+ Utils.getLastMillisecondOfDay(System.currentTimeMillis())) / ONE_DAY_MS);
+ String headerDate;
+ if (titleIndex < mTitles.size()) {
+ headerDate = mTitles.get(titleIndex);
+ } else {
+ headerDate = DateUtils.formatDateTime(getContext(), deadLine,
+ DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_SHOW_DATE
+ | DateUtils.FORMAT_ABBREV_MONTH);
+ }
+ return headerDate;
+ }
+
+ /**
+ * Stops schedules row adapter.
+ */
+ public void stop() {
+ mHandler.removeCallbacksAndMessages(null);
+ DvrManager dvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
+ for (int i = 0; i < size(); i++) {
+ if (get(i) instanceof ScheduleRow) {
+ ScheduleRow row = (ScheduleRow) get(i);
+ if (row.isScheduleCanceled()) {
+ dvrManager.removeScheduledRecording(row.getSchedule());
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets which {@link ScheduleRow} the {@link ScheduledRecording} belongs to.
+ */
+ public ScheduleRow findRowByScheduledRecording(ScheduledRecording recording) {
+ if (recording == null) {
+ return null;
+ }
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (item instanceof ScheduleRow && ((ScheduleRow) item).getSchedule() != null) {
+ if (((ScheduleRow) item).getSchedule().getId() == recording.getId()) {
+ return (ScheduleRow) item;
+ }
+ }
+ }
+ return null;
+ }
+
+ private ScheduleRow findRowWithStartRequest(ScheduledRecording schedule) {
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (!(item instanceof ScheduleRow)) {
+ continue;
+ }
+ ScheduleRow row = (ScheduleRow) item;
+ if (row.getSchedule() != null && row.isStartRecordingRequested()
+ && row.matchSchedule(schedule)) {
+ return row;
+ }
+ }
+ return null;
+ }
+
+ private void addScheduleRow(ScheduledRecording recording) {
+ // This method must not be called from inherited class.
+ SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class));
+ if (recording != null) {
+ int pre = -1;
+ int index = 0;
+ for (; index < size(); index++) {
+ if (get(index) instanceof ScheduleRow) {
+ ScheduleRow scheduleRow = (ScheduleRow) get(index);
+ if (ScheduledRecording.START_TIME_THEN_PRIORITY_THEN_ID_COMPARATOR.compare(
+ scheduleRow.getSchedule(), recording) > 0) {
+ break;
+ }
+ pre = index;
+ }
+ }
+ long deadLine = Utils.getLastMillisecondOfDay(recording.getStartTimeMs());
+ if (pre >= 0 && getHeaderRow(pre).getDeadLineMs() == deadLine) {
+ SchedulesHeaderRow headerRow = ((ScheduleRow) get(pre)).getHeaderRow();
+ headerRow.setItemCount(headerRow.getItemCount() + 1);
+ ScheduleRow addedRow = new ScheduleRow(recording, headerRow);
+ add(++pre, addedRow);
+ updateHeaderDescription(headerRow);
+ } else if (index < size() && getHeaderRow(index).getDeadLineMs() == deadLine) {
+ SchedulesHeaderRow headerRow = ((ScheduleRow) get(index)).getHeaderRow();
+ headerRow.setItemCount(headerRow.getItemCount() + 1);
+ ScheduleRow addedRow = new ScheduleRow(recording, headerRow);
+ add(index, addedRow);
+ updateHeaderDescription(headerRow);
+ } else {
+ SchedulesHeaderRow headerRow = new DateHeaderRow(calculateHeaderDate(deadLine),
+ mContext.getResources().getQuantityString(
+ R.plurals.dvr_schedules_section_subtitle, 1, 1), 1, deadLine);
+ add(++pre, headerRow);
+ ScheduleRow addedRow = new ScheduleRow(recording, headerRow);
+ add(pre, addedRow);
+ }
+ }
+ }
+
+ private DateHeaderRow getHeaderRow(int index) {
+ return ((DateHeaderRow) ((ScheduleRow) get(index)).getHeaderRow());
+ }
+
+ private void removeScheduleRow(ScheduleRow scheduleRow) {
+ // This method must not be called from inherited class.
+ SoftPreconditions.checkState(getClass().equals(ScheduleRowAdapter.class));
+ if (scheduleRow != null) {
+ scheduleRow.setSchedule(null);
+ SchedulesHeaderRow headerRow = scheduleRow.getHeaderRow();
+ remove(scheduleRow);
+ // Changes the count information of header which the removed row belongs to.
+ if (headerRow != null) {
+ int currentCount = headerRow.getItemCount();
+ headerRow.setItemCount(--currentCount);
+ if (headerRow.getItemCount() == 0) {
+ remove(headerRow);
+ } else {
+ replace(indexOf(headerRow), headerRow);
+ updateHeaderDescription(headerRow);
+ }
+ }
+ }
+ }
+
+ private void updateHeaderDescription(SchedulesHeaderRow headerRow) {
+ headerRow.setDescription(mContext.getResources().getQuantityString(
+ R.plurals.dvr_schedules_section_subtitle,
+ headerRow.getItemCount(), headerRow.getItemCount()));
+ }
+
+ /**
+ * Called when a schedule recording is added to dvr date manager.
+ */
+ public void onScheduledRecordingAdded(ScheduledRecording schedule) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + schedule);
+ ScheduleRow row = findRowWithStartRequest(schedule);
+ // If the start recording is requested, onScheduledRecordingAdded is called with NOT_STARTED
+ // state. And then onScheduleRecordingUpdated will be called with IN_PROGRESS.
+ // It happens in a short time and causes blinking. To avoid this intermediate state change,
+ // update the row in onScheduleRecordingUpdated when the state changes to IN_PROGRESS
+ // instead of in this method.
+ if (row == null) {
+ addScheduleRow(schedule);
+ sendNextUpdateMessage(System.currentTimeMillis());
+ }
+ }
+
+ /**
+ * Called when a schedule recording is removed from dvr date manager.
+ */
+ public void onScheduledRecordingRemoved(ScheduledRecording schedule) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + schedule);
+ ScheduleRow row = findRowByScheduledRecording(schedule);
+ if (row != null) {
+ removeScheduleRow(row);
+ notifyArrayItemRangeChanged(indexOf(row), 1);
+ sendNextUpdateMessage(System.currentTimeMillis());
+ }
+ }
+
+ /**
+ * Called when a schedule recording is updated in dvr date manager.
+ */
+ public void onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingUpdated: " + schedule);
+ ScheduleRow row = findRowByScheduledRecording(schedule);
+ if (row != null) {
+ if (conflictChange && isStartOrStopRequested()) {
+ // Delay the conflict update until it gets the response of the start/stop request.
+ // The purpose is to avoid the intermediate conflict change.
+ addPendingUpdate(row);
+ return;
+ }
+ if (row.isStopRecordingRequested()) {
+ // Wait until the recording is finished
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED) {
+ row.setStopRecordingRequested(false);
+ if (!isStartOrStopRequested()) {
+ executePendingUpdate();
+ }
+ row.setSchedule(schedule);
+ }
+ } else {
+ row.setSchedule(schedule);
+ if (!willBeKept(schedule)) {
+ removeScheduleRow(row);
+ }
+ }
+ notifyArrayItemRangeChanged(indexOf(row), 1);
+ sendNextUpdateMessage(System.currentTimeMillis());
+ } else {
+ row = findRowWithStartRequest(schedule);
+ // When the start recording was requested, we give the highest priority. So it is
+ // guaranteed that the state will be changed from NOT_STARTED to the other state.
+ // Update the row with the next state not to show the intermediate state which causes
+ // blinking.
+ if (row != null
+ && schedule.getState() != ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ // This can be called multiple times, so do not call
+ // ScheduleRow.setStartRecordingRequested(false) here.
+ row.setStartRecordingRequested(false);
+ if (!isStartOrStopRequested()) {
+ executePendingUpdate();
+ }
+ row.setSchedule(schedule);
+ notifyArrayItemRangeChanged(indexOf(row), 1);
+ sendNextUpdateMessage(System.currentTimeMillis());
+ }
+ }
+ }
+
+ /**
+ * Checks if there is a row which requested start/stop recording.
+ */
+ protected boolean isStartOrStopRequested() {
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (item instanceof ScheduleRow) {
+ ScheduleRow row = (ScheduleRow) item;
+ if (row.isStartRecordingRequested() || row.isStopRecordingRequested()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Delays update of the row.
+ */
+ protected void addPendingUpdate(ScheduleRow row) {
+ mPendingUpdate.add(row);
+ }
+
+ /**
+ * Executes the pending updates.
+ */
+ protected void executePendingUpdate() {
+ for (ScheduleRow row : mPendingUpdate) {
+ int index = indexOf(row);
+ if (index != -1) {
+ notifyArrayItemRangeChanged(index, 1);
+ }
+ }
+ mPendingUpdate.clear();
+ }
+
+ /**
+ * To check whether the recording should be kept or not.
+ */
+ protected boolean willBeKept(ScheduledRecording schedule) {
+ // CANCELED state means that the schedule was removed temporarily, which should be shown
+ // in the list so that the user can reschedule it.
+ return schedule.getEndTimeMs() > System.currentTimeMillis()
+ && (schedule.getState() == ScheduledRecording.STATE_RECORDING_IN_PROGRESS
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_NOT_STARTED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_CANCELED);
+ }
+
+ /**
+ * Handle the message to update/remove rows.
+ */
+ protected void handleUpdateRow(long currentTimeMs) {
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (item instanceof ScheduleRow) {
+ ScheduleRow row = (ScheduleRow) item;
+ if (row.getEndTimeMs() <= currentTimeMs) {
+ removeScheduleRow(row);
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the next update time. Return {@link Long#MAX_VALUE} if no timer is necessary.
+ */
+ protected long getNextTimerMs(long currentTimeMs) {
+ long earliest = Long.MAX_VALUE;
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (item instanceof ScheduleRow) {
+ // If the schedule was finished earlier than the end time, it should be removed
+ // when it reaches the end time in this class.
+ ScheduleRow row = (ScheduleRow) item;
+ if (earliest > row.getEndTimeMs()) {
+ earliest = row.getEndTimeMs();
+ }
+ }
+ }
+ return earliest;
+ }
+
+ /**
+ * Send update message at the time returned by {@link #getNextTimerMs}.
+ */
+ protected final void sendNextUpdateMessage(long currentTimeMs) {
+ mHandler.removeMessages(MSG_UPDATE_ROW);
+ long nextTime = getNextTimerMs(currentTimeMs);
+ if (nextTime != Long.MAX_VALUE) {
+ mHandler.sendEmptyMessageDelayed(MSG_UPDATE_ROW,
+ nextTime - System.currentTimeMillis());
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java
new file mode 100644
index 00000000..1257e725
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/ScheduleRowPresenter.java
@@ -0,0 +1,795 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Build;
+import android.support.annotation.IntDef;
+import android.support.v17.leanback.widget.RowPresenter;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnFocusChangeListener;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.DvrStopRecordingFragment;
+import com.android.tv.dvr.ui.HalfSizedDialogFragment;
+import com.android.tv.util.ToastUtils;
+import com.android.tv.util.Utils;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A RowPresenter for {@link ScheduleRow}.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class ScheduleRowPresenter extends RowPresenter {
+ private static final String TAG = "ScheduleRowPresenter";
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ACTION_START_RECORDING, ACTION_STOP_RECORDING, ACTION_CREATE_SCHEDULE,
+ ACTION_REMOVE_SCHEDULE})
+ public @interface ScheduleRowAction {}
+ /** An action to start recording. */
+ public static final int ACTION_START_RECORDING = 1;
+ /** An action to stop recording. */
+ public static final int ACTION_STOP_RECORDING = 2;
+ /** An action to create schedule for the row. */
+ public static final int ACTION_CREATE_SCHEDULE = 3;
+ /** An action to remove the schedule. */
+ public static final int ACTION_REMOVE_SCHEDULE = 4;
+
+ private final Context mContext;
+ private final DvrManager mDvrManager;
+ private final DvrScheduleManager mDvrScheduleManager;
+
+ private final String mTunerConflictWillNotBeRecordedInfo;
+ private final String mTunerConflictWillBePartiallyRecordedInfo;
+ private final int mAnimationDuration;
+
+ private int mLastFocusedViewId;
+
+ /**
+ * A ViewHolder for {@link ScheduleRow}
+ */
+ public static class ScheduleRowViewHolder extends RowPresenter.ViewHolder {
+ private ScheduleRowPresenter mPresenter;
+ @ScheduleRowAction private int[] mActions;
+ private boolean mLtr;
+ private LinearLayout mInfoContainer;
+ // The first action is on the right of the second action.
+ private RelativeLayout mSecondActionContainer;
+ private RelativeLayout mFirstActionContainer;
+ private View mSelectorView;
+ private TextView mTimeView;
+ private TextView mProgramTitleView;
+ private TextView mInfoSeparatorView;
+ private TextView mChannelNameView;
+ private TextView mConflictInfoView;
+ private ImageView mSecondActionView;
+ private ImageView mFirstActionView;
+
+ private Runnable mPendingAnimationRunnable;
+
+ private final int mSelectorTranslationDelta;
+ private final int mSelectorWidthDelta;
+ private final int mInfoContainerTargetWidthWithNoAction;
+ private final int mInfoContainerTargetWidthWithOneAction;
+ private final int mInfoContainerTargetWidthWithTwoAction;
+ private final int mRoundRectRadius;
+
+ private final OnFocusChangeListener mOnFocusChangeListener =
+ new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View view, boolean focused) {
+ view.post(new Runnable() {
+ @Override
+ public void run() {
+ if (view.isFocused()) {
+ mPresenter.mLastFocusedViewId = view.getId();
+ }
+ updateSelector();
+ }
+ });
+ }
+ };
+
+ public ScheduleRowViewHolder(View view, ScheduleRowPresenter presenter) {
+ super(view);
+ mPresenter = presenter;
+ mLtr = view.getContext().getResources().getConfiguration().getLayoutDirection()
+ == View.LAYOUT_DIRECTION_LTR;
+ mInfoContainer = (LinearLayout) view.findViewById(R.id.info_container);
+ mSecondActionContainer = (RelativeLayout) view.findViewById(
+ R.id.action_second_container);
+ mSecondActionView = (ImageView) view.findViewById(R.id.action_second);
+ mFirstActionContainer = (RelativeLayout) view.findViewById(
+ R.id.action_first_container);
+ mFirstActionView = (ImageView) view.findViewById(R.id.action_first);
+ mSelectorView = view.findViewById(R.id.selector);
+ mTimeView = (TextView) view.findViewById(R.id.time);
+ mProgramTitleView = (TextView) view.findViewById(R.id.program_title);
+ mInfoSeparatorView = (TextView) view.findViewById(R.id.info_separator);
+ mChannelNameView = (TextView) view.findViewById(R.id.channel_name);
+ mConflictInfoView = (TextView) view.findViewById(R.id.conflict_info);
+ Resources res = view.getResources();
+ mSelectorTranslationDelta =
+ res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
+ - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_focus_translation_delta);
+ mSelectorWidthDelta = res.getDimensionPixelSize(
+ R.dimen.dvr_schedules_item_focus_width_delta);
+ mRoundRectRadius = res.getDimensionPixelSize(R.dimen.dvr_schedules_selector_radius);
+ int fullWidth = res.getDimensionPixelSize(
+ R.dimen.dvr_schedules_item_width)
+ - 2 * res.getDimensionPixelSize(R.dimen.dvr_schedules_layout_padding);
+ mInfoContainerTargetWidthWithNoAction = fullWidth + 2 * mRoundRectRadius;
+ mInfoContainerTargetWidthWithOneAction = fullWidth
+ - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
+ - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_delete_width)
+ + mRoundRectRadius + mSelectorWidthDelta;
+ mInfoContainerTargetWidthWithTwoAction = mInfoContainerTargetWidthWithOneAction
+ - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_section_margin)
+ - res.getDimensionPixelSize(R.dimen.dvr_schedules_item_icon_size);
+
+ mInfoContainer.setOnFocusChangeListener(mOnFocusChangeListener);
+ mFirstActionContainer.setOnFocusChangeListener(mOnFocusChangeListener);
+ mSecondActionContainer.setOnFocusChangeListener(mOnFocusChangeListener);
+ }
+
+ /**
+ * Returns time view.
+ */
+ public TextView getTimeView() {
+ return mTimeView;
+ }
+
+ /**
+ * Returns title view.
+ */
+ public TextView getProgramTitleView() {
+ return mProgramTitleView;
+ }
+
+ private void updateSelector() {
+ int animationDuration = mSelectorView.getResources().getInteger(
+ android.R.integer.config_shortAnimTime);
+ DecelerateInterpolator interpolator = new DecelerateInterpolator();
+
+ if (mInfoContainer.isFocused() || mSecondActionContainer.isFocused()
+ || mFirstActionContainer.isFocused()) {
+ final ViewGroup.LayoutParams lp = mSelectorView.getLayoutParams();
+ final int targetWidth;
+ if (mInfoContainer.isFocused()) {
+ // Use actions to check the visibility of the actions instead of calling
+ // View.getVisibility() because the view could be on the hiding animation.
+ if (mActions == null || mActions.length == 0) {
+ targetWidth = mInfoContainerTargetWidthWithNoAction;
+ } else if (mActions.length == 1) {
+ targetWidth = mInfoContainerTargetWidthWithOneAction;
+ } else {
+ targetWidth = mInfoContainerTargetWidthWithTwoAction;
+ }
+ } else if (mSecondActionContainer.isFocused()) {
+ targetWidth = Math.max(mSecondActionContainer.getWidth(), 2 * mRoundRectRadius);
+ } else {
+ targetWidth = mFirstActionContainer.getWidth() + mRoundRectRadius
+ + mSelectorTranslationDelta;
+ }
+
+ float targetTranslationX;
+ if (mInfoContainer.isFocused()) {
+ targetTranslationX = mLtr ? mInfoContainer.getLeft() - mRoundRectRadius
+ - mSelectorView.getLeft() :
+ mInfoContainer.getRight() + mRoundRectRadius - mSelectorView.getRight();
+ } else if (mSecondActionContainer.isFocused()) {
+ if (mSecondActionContainer.getWidth() > 2 * mRoundRectRadius) {
+ targetTranslationX = mLtr ? mSecondActionContainer.getLeft() -
+ mSelectorView.getLeft()
+ : mSecondActionContainer.getRight() - mSelectorView.getRight();
+ } else {
+ targetTranslationX = mLtr ? mSecondActionContainer.getLeft() -
+ (mRoundRectRadius - mSecondActionContainer.getWidth() / 2) -
+ mSelectorView.getLeft()
+ : mSecondActionContainer.getRight() +
+ (mRoundRectRadius - mSecondActionContainer.getWidth() / 2) -
+ mSelectorView.getRight();
+ }
+ } else {
+ targetTranslationX = mLtr ? mFirstActionContainer.getLeft()
+ - mSelectorTranslationDelta - mSelectorView.getLeft()
+ : mFirstActionContainer.getRight() + mSelectorTranslationDelta
+ - mSelectorView.getRight();
+ }
+
+ if (mSelectorView.getAlpha() == 0) {
+ mSelectorView.setTranslationX(targetTranslationX);
+ lp.width = targetWidth;
+ mSelectorView.requestLayout();
+ }
+
+ // animate the selector in and to the proper width and translation X.
+ final float deltaWidth = lp.width - targetWidth;
+ mSelectorView.animate().cancel();
+ mSelectorView.animate().translationX(targetTranslationX).alpha(1f)
+ .setUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ // Set width to the proper width for this animation step.
+ lp.width = targetWidth + Math.round(
+ deltaWidth * (1f - animation.getAnimatedFraction()));
+ mSelectorView.requestLayout();
+ }
+ }).setDuration(animationDuration).setInterpolator(interpolator).start();
+ if (mPendingAnimationRunnable != null) {
+ mPendingAnimationRunnable.run();
+ mPendingAnimationRunnable = null;
+ }
+ } else {
+ mSelectorView.animate().cancel();
+ mSelectorView.animate().alpha(0f).setDuration(animationDuration)
+ .setInterpolator(interpolator).setUpdateListener(null).start();
+ }
+ }
+
+ /**
+ * Grey out the information body.
+ */
+ public void greyOutInfo() {
+ mTimeView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info_grey, null));
+ mProgramTitleView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info_grey, null));
+ mInfoSeparatorView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info_grey, null));
+ mChannelNameView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info_grey, null));
+ mConflictInfoView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info_grey, null));
+ }
+
+ /**
+ * Reverse grey out operation.
+ */
+ public void whiteBackInfo() {
+ mTimeView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info, null));
+ mProgramTitleView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_main, null));
+ mInfoSeparatorView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info, null));
+ mChannelNameView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info, null));
+ mConflictInfoView.setTextColor(mInfoContainer.getResources().getColor(R.color
+ .dvr_schedules_item_info, null));
+ }
+ }
+
+ public ScheduleRowPresenter(Context context) {
+ setHeaderPresenter(null);
+ setSelectEffectEnabled(false);
+ mContext = context;
+ mDvrManager = TvApplication.getSingletons(context).getDvrManager();
+ mDvrScheduleManager = TvApplication.getSingletons(context).getDvrScheduleManager();
+ mTunerConflictWillNotBeRecordedInfo = mContext.getString(
+ R.string.dvr_schedules_tuner_conflict_will_not_be_recorded_info);
+ mTunerConflictWillBePartiallyRecordedInfo = mContext.getString(
+ R.string.dvr_schedules_tuner_conflict_will_be_partially_recorded);
+ mAnimationDuration = mContext.getResources().getInteger(
+ android.R.integer.config_shortAnimTime);
+ }
+
+ @Override
+ public ViewHolder createRowViewHolder(ViewGroup parent) {
+ return onGetScheduleRowViewHolder(LayoutInflater.from(mContext)
+ .inflate(R.layout.dvr_schedules_item, parent, false));
+ }
+
+ /**
+ * Returns context.
+ */
+ protected Context getContext() {
+ return mContext;
+ }
+
+ /**
+ * Returns DVR manager.
+ */
+ protected DvrManager getDvrManager() {
+ return mDvrManager;
+ }
+
+ @Override
+ protected void onBindRowViewHolder(RowPresenter.ViewHolder vh, Object item) {
+ super.onBindRowViewHolder(vh, item);
+ ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh;
+ ScheduleRow row = (ScheduleRow) item;
+ @ScheduleRowAction int[] actions = getAvailableActions(row);
+ viewHolder.mActions = actions;
+ viewHolder.mInfoContainer.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ onInfoClicked(row);
+ }
+ });
+
+ viewHolder.mFirstActionContainer.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ onActionClicked(actions[0], row);
+ }
+ });
+
+ viewHolder.mSecondActionContainer.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ onActionClicked(actions[1], row);
+ }
+ });
+
+ viewHolder.mTimeView.setText(onGetRecordingTimeText(row));
+ String programInfoText = onGetProgramInfoText(row);
+ if (TextUtils.isEmpty(programInfoText)) {
+ int durationMins =
+ Math.max((int) TimeUnit.MILLISECONDS.toMinutes(row.getDuration()), 1);
+ programInfoText = mContext.getResources().getQuantityString(
+ R.plurals.dvr_schedules_recording_duration, durationMins, durationMins);
+ }
+ String channelName = getChannelNameText(row);
+ viewHolder.mProgramTitleView.setText(programInfoText);
+ viewHolder.mInfoSeparatorView.setVisibility((!TextUtils.isEmpty(programInfoText)
+ && !TextUtils.isEmpty(channelName)) ? View.VISIBLE : View.GONE);
+ viewHolder.mChannelNameView.setText(channelName);
+ if (actions != null) {
+ switch (actions.length) {
+ case 2:
+ viewHolder.mSecondActionView.setImageResource(getImageForAction(actions[1]));
+ // pass through
+ case 1:
+ viewHolder.mFirstActionView.setImageResource(getImageForAction(actions[0]));
+ break;
+ }
+ }
+ if (mDvrManager.isConflicting(row.getSchedule())) {
+ String conflictInfo;
+ if (mDvrScheduleManager.isPartiallyConflicting(row.getSchedule())) {
+ conflictInfo = mTunerConflictWillBePartiallyRecordedInfo;
+ } else {
+ conflictInfo = mTunerConflictWillNotBeRecordedInfo;
+ }
+ viewHolder.mConflictInfoView.setText(conflictInfo);
+ viewHolder.mConflictInfoView.setVisibility(View.VISIBLE);
+ } else {
+ viewHolder.mConflictInfoView.setVisibility(View.GONE);
+ }
+ if (shouldBeGrayedOut(row)) {
+ viewHolder.greyOutInfo();
+ } else {
+ viewHolder.whiteBackInfo();
+ }
+ updateActionContainer(viewHolder, viewHolder.isSelected());
+ }
+
+ private int getImageForAction(@ScheduleRowAction int action) {
+ switch (action) {
+ case ACTION_START_RECORDING:
+ return R.drawable.ic_record_start;
+ case ACTION_STOP_RECORDING:
+ return R.drawable.ic_record_stop;
+ case ACTION_CREATE_SCHEDULE:
+ return R.drawable.ic_scheduled_recording;
+ case ACTION_REMOVE_SCHEDULE:
+ return R.drawable.ic_dvr_cancel;
+ default:
+ return 0;
+ }
+ }
+
+ /**
+ * Returns view holder for schedule row.
+ */
+ protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) {
+ return new ScheduleRowViewHolder(view, this);
+ }
+
+ /**
+ * Returns time text for time view from scheduled recording.
+ */
+ protected String onGetRecordingTimeText(ScheduleRow row) {
+ return Utils.getDurationString(mContext, row.getStartTimeMs(), row.getEndTimeMs(), true,
+ false, true, 0);
+ }
+
+ /**
+ * Returns program info text for program title view.
+ */
+ protected String onGetProgramInfoText(ScheduleRow row) {
+ return row.getProgramTitleWithEpisodeNumber(mContext);
+ }
+
+ private String getChannelNameText(ScheduleRow row) {
+ Channel channel = TvApplication.getSingletons(mContext).getChannelDataManager()
+ .getChannel(row.getChannelId());
+ return channel == null ? null :
+ TextUtils.isEmpty(channel.getDisplayName()) ? channel.getDisplayNumber() :
+ channel.getDisplayName().trim() + " " + channel.getDisplayNumber();
+ }
+
+ /**
+ * Called when user click Info in {@link ScheduleRow}.
+ */
+ protected void onInfoClicked(ScheduleRow scheduleRow) {
+ ScheduledRecording schedule = scheduleRow.getSchedule();
+ if (schedule != null) {
+ DvrUiHelper.startDetailsActivity((Activity) mContext, schedule, null, true);
+ }
+ }
+
+ /**
+ * Called when the button in a row is clicked.
+ */
+ protected void onActionClicked(@ScheduleRowAction final int action, ScheduleRow row) {
+ switch (action) {
+ case ACTION_START_RECORDING:
+ onStartRecording(row);
+ break;
+ case ACTION_STOP_RECORDING:
+ onStopRecording(row);
+ break;
+ case ACTION_CREATE_SCHEDULE:
+ onCreateSchedule(row);
+ break;
+ case ACTION_REMOVE_SCHEDULE:
+ onRemoveSchedule(row);
+ break;
+ }
+ }
+
+ /**
+ * Action handler for {@link #ACTION_START_RECORDING}.
+ */
+ protected void onStartRecording(ScheduleRow row) {
+ ScheduledRecording schedule = row.getSchedule();
+ if (schedule == null) {
+ // This row has been deleted.
+ return;
+ }
+ // Checks if there are current recordings that will be stopped by schedule this program.
+ // If so, shows confirmation dialog to users.
+ List<ScheduledRecording> conflictSchedules = mDvrScheduleManager.getConflictingSchedules(
+ schedule.getChannelId(), System.currentTimeMillis(), schedule.getEndTimeMs());
+ for (int i = conflictSchedules.size() - 1; i >= 0; i--) {
+ ScheduledRecording conflictSchedule = conflictSchedules.get(i);
+ if (conflictSchedule.isInProgress()) {
+ DvrUiHelper.showStopRecordingDialog((Activity) mContext,
+ conflictSchedule.getChannelId(),
+ DvrStopRecordingFragment.REASON_ON_CONFLICT,
+ new HalfSizedDialogFragment.OnActionClickListener() {
+ @Override
+ public void onActionClick(long actionId) {
+ if (actionId == DvrStopRecordingFragment.ACTION_STOP) {
+ onStartRecordingInternal(row);
+ }
+ }
+ });
+ return;
+ }
+ }
+ onStartRecordingInternal(row);
+ }
+
+ private void onStartRecordingInternal(ScheduleRow row) {
+ if (row.isOnAir() && !row.isRecordingInProgress() && !row.isStartRecordingRequested()) {
+ row.setStartRecordingRequested(true);
+ if (row.isRecordingNotStarted()) {
+ mDvrManager.setHighestPriority(row.getSchedule());
+ } else if (row.isRecordingFinished()) {
+ mDvrManager.addSchedule(ScheduledRecording.buildFrom(row.getSchedule())
+ .setId(ScheduledRecording.ID_NOT_SET)
+ .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED)
+ .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule()))
+ .build());
+ } else {
+ SoftPreconditions.checkState(false, TAG, "Invalid row state to start recording: "
+ + row);
+ return;
+ }
+ String msg = mContext.getString(R.string.dvr_msg_current_program_scheduled,
+ row.getSchedule().getProgramTitle(),
+ Utils.toTimeString(row.getEndTimeMs(), false));
+ ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT);
+ }
+ }
+
+ /**
+ * Action handler for {@link #ACTION_STOP_RECORDING}.
+ */
+ protected void onStopRecording(ScheduleRow row) {
+ if (row.getSchedule() == null) {
+ // This row has been deleted.
+ return;
+ }
+ if (row.isOnAir() && row.isRecordingInProgress() && !row.isStopRecordingRequested()) {
+ row.setStopRecordingRequested(true);
+ mDvrManager.stopRecording(row.getSchedule());
+ CharSequence deletedInfo = onGetProgramInfoText(row);
+ if (TextUtils.isEmpty(deletedInfo)) {
+ deletedInfo = getChannelNameText(row);
+ }
+ ToastUtils.show(mContext, mContext.getResources()
+ .getString(R.string.dvr_schedules_deletion_info, deletedInfo),
+ Toast.LENGTH_SHORT);
+ }
+ }
+
+ /**
+ * Action handler for {@link #ACTION_CREATE_SCHEDULE}.
+ */
+ protected void onCreateSchedule(ScheduleRow row) {
+ if (row.getSchedule() == null) {
+ // This row has been deleted.
+ return;
+ }
+ if (!row.isOnAir()) {
+ if (row.isScheduleCanceled()) {
+ mDvrManager.updateScheduledRecording(ScheduledRecording.buildFrom(row.getSchedule())
+ .setState(ScheduledRecording.STATE_RECORDING_NOT_STARTED)
+ .setPriority(mDvrManager.suggestHighestPriority(row.getSchedule()))
+ .build());
+ String msg = mContext.getString(R.string.dvr_msg_program_scheduled,
+ row.getSchedule().getProgramTitle());
+ ToastUtils.show(mContext, msg, Toast.LENGTH_SHORT);
+ } else if (mDvrManager.isConflicting(row.getSchedule())) {
+ mDvrManager.setHighestPriority(row.getSchedule());
+ }
+ }
+ }
+
+ /**
+ * Action handler for {@link #ACTION_REMOVE_SCHEDULE}.
+ */
+ protected void onRemoveSchedule(ScheduleRow row) {
+ if (row.getSchedule() == null) {
+ // This row has been deleted.
+ return;
+ }
+ CharSequence deletedInfo = null;
+ if (row.isOnAir()) {
+ if (row.isRecordingNotStarted()) {
+ deletedInfo = getDeletedInfo(row);
+ mDvrManager.removeScheduledRecording(row.getSchedule());
+ }
+ } else {
+ if (mDvrManager.isConflicting(row.getSchedule())
+ && !shouldKeepScheduleAfterRemoving()) {
+ deletedInfo = getDeletedInfo(row);
+ mDvrManager.removeScheduledRecording(row.getSchedule());
+ } else if (row.isRecordingNotStarted()) {
+ deletedInfo = getDeletedInfo(row);
+ mDvrManager.updateScheduledRecording(ScheduledRecording.buildFrom(row.getSchedule())
+ .setState(ScheduledRecording.STATE_RECORDING_CANCELED)
+ .build());
+ }
+ }
+ if (deletedInfo != null) {
+ ToastUtils.show(mContext, mContext.getResources()
+ .getString(R.string.dvr_schedules_deletion_info, deletedInfo),
+ Toast.LENGTH_SHORT);
+ }
+ }
+
+ private CharSequence getDeletedInfo(ScheduleRow row) {
+ CharSequence deletedInfo = onGetProgramInfoText(row);
+ if (TextUtils.isEmpty(deletedInfo)) {
+ return getChannelNameText(row);
+ }
+ return deletedInfo;
+ }
+
+ @Override
+ protected void onRowViewSelected(ViewHolder vh, boolean selected) {
+ super.onRowViewSelected(vh, selected);
+ updateActionContainer(vh, selected);
+ }
+
+ /**
+ * Internal method for onRowViewSelected, can be customized by subclass.
+ */
+ private void updateActionContainer(ViewHolder vh, boolean selected) {
+ ScheduleRowViewHolder viewHolder = (ScheduleRowViewHolder) vh;
+ viewHolder.mSecondActionContainer.animate().setListener(null).cancel();
+ viewHolder.mFirstActionContainer.animate().setListener(null).cancel();
+ if (selected && viewHolder.mActions != null) {
+ switch (viewHolder.mActions.length) {
+ case 2:
+ prepareShowActionView(viewHolder.mSecondActionContainer);
+ prepareShowActionView(viewHolder.mFirstActionContainer);
+ viewHolder.mPendingAnimationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ showActionView(viewHolder.mSecondActionContainer);
+ showActionView(viewHolder.mFirstActionContainer);
+ }
+ };
+ break;
+ case 1:
+ prepareShowActionView(viewHolder.mFirstActionContainer);
+ viewHolder.mPendingAnimationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ hideActionView(viewHolder.mSecondActionContainer, View.GONE);
+ showActionView(viewHolder.mFirstActionContainer);
+ }
+ };
+ if (mLastFocusedViewId == R.id.action_second_container) {
+ mLastFocusedViewId = R.id.info_container;
+ }
+ break;
+ case 0:
+ default:
+ viewHolder.mPendingAnimationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ hideActionView(viewHolder.mSecondActionContainer, View.GONE);
+ hideActionView(viewHolder.mFirstActionContainer, View.GONE);
+ }
+ };
+ if (mLastFocusedViewId == R.id.action_first_container
+ || mLastFocusedViewId == R.id.action_second_container) {
+ mLastFocusedViewId = R.id.info_container;
+ }
+ break;
+ }
+ View view = viewHolder.view.findViewById(mLastFocusedViewId);
+ if (view != null && view.getVisibility() == View.VISIBLE) {
+ // When the row is selected, information container gets the initial focus.
+ // To give the focus to the same control as the previous row, we need to call
+ // requestFocus() explicitly.
+ if (view.hasFocus()) {
+ viewHolder.mPendingAnimationRunnable.run();
+ } else {
+ view.requestFocus();
+ }
+ }
+ } else {
+ viewHolder.mPendingAnimationRunnable = null;
+ hideActionView(viewHolder.mFirstActionContainer, View.GONE);
+ hideActionView(viewHolder.mSecondActionContainer, View.GONE);
+ }
+ }
+
+ private void prepareShowActionView(View view) {
+ if (view.getVisibility() != View.VISIBLE) {
+ view.setAlpha(0.0f);
+ }
+ view.setVisibility(View.VISIBLE);
+ }
+
+ /**
+ * Add animation when view is visible.
+ */
+ private void showActionView(View view) {
+ view.animate().alpha(1.0f).setInterpolator(new DecelerateInterpolator())
+ .setDuration(mAnimationDuration).start();
+ }
+
+ /**
+ * Add animation when view change to invisible.
+ */
+ private void hideActionView(View view, int visibility) {
+ if (view.getVisibility() != View.VISIBLE) {
+ if (view.getVisibility() != visibility) {
+ view.setVisibility(visibility);
+ }
+ return;
+ }
+ view.animate().alpha(0.0f).setInterpolator(new DecelerateInterpolator())
+ .setDuration(mAnimationDuration)
+ .setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ view.setVisibility(visibility);
+ view.animate().setListener(null);
+ }
+ }).start();
+ }
+
+ /**
+ * Returns the available actions according to the row's state. It should be the reverse order
+ * with that in the screen.
+ */
+ @ScheduleRowAction
+ protected int[] getAvailableActions(ScheduleRow row) {
+ if (row.getSchedule() != null) {
+ if (row.isOnAir()) {
+ if (row.isRecordingInProgress()) {
+ return new int[] {ACTION_STOP_RECORDING};
+ } else if (row.isRecordingNotStarted()) {
+ if (canResolveConflict()) {
+ // The "START" action can change the conflict states.
+ return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_START_RECORDING};
+ } else {
+ return new int[] {ACTION_REMOVE_SCHEDULE};
+ }
+ } else if (row.isRecordingFinished()) {
+ return new int[] {ACTION_START_RECORDING};
+ } else {
+ SoftPreconditions.checkState(false, TAG, "Invalid row state in checking the"
+ + " available actions(on air): " + row);
+ }
+ } else {
+ if (row.isScheduleCanceled()) {
+ return new int[] {ACTION_CREATE_SCHEDULE};
+ } else if (mDvrManager.isConflicting(row.getSchedule()) && canResolveConflict()) {
+ return new int[] {ACTION_REMOVE_SCHEDULE, ACTION_CREATE_SCHEDULE};
+ } else if (row.isRecordingNotStarted()) {
+ return new int[] {ACTION_REMOVE_SCHEDULE};
+ } else {
+ SoftPreconditions.checkState(false, TAG, "Invalid row state in checking the"
+ + " available actions(future schedule): " + row);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Check if the conflict can be resolved in this screen.
+ */
+ protected boolean canResolveConflict() {
+ return true;
+ }
+
+ /**
+ * Check if the schedule should be kept after removing it.
+ */
+ protected boolean shouldKeepScheduleAfterRemoving() {
+ return false;
+ }
+
+ /**
+ * Checks if the row should be grayed out.
+ */
+ protected boolean shouldBeGrayedOut(ScheduleRow row) {
+ return row.getSchedule() == null
+ || (row.isOnAir() && !row.isRecordingInProgress())
+ || mDvrManager.isConflicting(row.getSchedule())
+ || row.isScheduleCanceled();
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java
new file mode 100644
index 00000000..0fb0924d
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRow.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import com.android.tv.dvr.SeriesRecording;
+
+/**
+ * A base class for the rows for schedules' header.
+ */
+public abstract class SchedulesHeaderRow {
+ private String mTitle;
+ private String mDescription;
+ private int mItemCount;
+
+ public SchedulesHeaderRow(String title, String description, int itemCount) {
+ mTitle = title;
+ mItemCount = itemCount;
+ mDescription = description;
+ }
+
+ /**
+ * Sets title.
+ */
+ public void setTitle(String title) {
+ mTitle = title;
+ }
+
+ /**
+ * Sets description.
+ */
+ public void setDescription(String description) {
+ mDescription = description;
+ }
+
+ /**
+ * Sets count of items.
+ */
+ public void setItemCount(int itemCount) {
+ mItemCount = itemCount;
+ }
+
+ /**
+ * Returns title.
+ */
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /**
+ * Returns description.
+ */
+ public String getDescription() {
+ return mDescription;
+ }
+
+ /**
+ * Returns count of items.
+ */
+ public int getItemCount() {
+ return mItemCount;
+ }
+
+ /**
+ * The header row which represent the date.
+ */
+ public static class DateHeaderRow extends SchedulesHeaderRow {
+ private long mDeadLineMs;
+
+ public DateHeaderRow(String title, String description, int itemCount, long deadLineMs) {
+ super(title, description, itemCount);
+ mDeadLineMs = deadLineMs;
+ }
+
+ /**
+ * Returns the latest time of the list which belongs to the header row.
+ */
+ public long getDeadLineMs() {
+ return mDeadLineMs;
+ }
+ }
+
+ /**
+ * The header row which represent the series recording.
+ */
+ public static class SeriesRecordingHeaderRow extends SchedulesHeaderRow {
+ private SeriesRecording mSeriesRecording;
+
+ public SeriesRecordingHeaderRow(String title, String description, int itemCount,
+ SeriesRecording series) {
+ super(title, description, itemCount);
+ mSeriesRecording = series;
+ }
+
+ /**
+ * Returns the series recording, it is for series schedules list.
+ */
+ public SeriesRecording getSeriesRecording() {
+ return mSeriesRecording;
+ }
+
+ /**
+ * Sets the series recording.
+ */
+ public void setSeriesRecording(SeriesRecording seriesRecording) {
+ mSeriesRecording = seriesRecording;
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java
new file mode 100644
index 00000000..69c33a96
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/SchedulesHeaderRowPresenter.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.dvr.ui.list;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.support.v17.leanback.widget.RowPresenter;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnFocusChangeListener;
+import android.view.ViewGroup;
+import android.view.animation.DecelerateInterpolator;
+import android.widget.TextView;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.ui.DvrSchedulesActivity;
+import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow;
+
+/**
+ * A base class for RowPresenter for {@link SchedulesHeaderRow}
+ */
+public abstract class SchedulesHeaderRowPresenter extends RowPresenter {
+ private Context mContext;
+
+ public SchedulesHeaderRowPresenter(Context context) {
+ setHeaderPresenter(null);
+ setSelectEffectEnabled(false);
+ mContext = context;
+ }
+
+ /**
+ * Returns the context.
+ */
+ Context getContext() {
+ return mContext;
+ }
+
+ /**
+ * A ViewHolder for {@link SchedulesHeaderRow}.
+ */
+ public static class SchedulesHeaderRowViewHolder extends RowPresenter.ViewHolder {
+ private TextView mTitle;
+ private TextView mDescription;
+
+ public SchedulesHeaderRowViewHolder(Context context, ViewGroup parent) {
+ super(LayoutInflater.from(context).inflate(R.layout.dvr_schedules_header, parent,
+ false));
+ mTitle = (TextView) view.findViewById(R.id.header_title);
+ mDescription = (TextView) view.findViewById(R.id.header_description);
+ }
+ }
+
+ @Override
+ protected void onBindRowViewHolder(RowPresenter.ViewHolder viewHolder, Object item) {
+ super.onBindRowViewHolder(viewHolder, item);
+ SchedulesHeaderRowViewHolder headerViewHolder = (SchedulesHeaderRowViewHolder) viewHolder;
+ SchedulesHeaderRow header = (SchedulesHeaderRow) item;
+ headerViewHolder.mTitle.setText(header.getTitle());
+ headerViewHolder.mDescription.setText(header.getDescription());
+ }
+
+ /**
+ * A presenter for {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}.
+ */
+ public static class DateHeaderRowPresenter extends SchedulesHeaderRowPresenter {
+ public DateHeaderRowPresenter(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected ViewHolder createRowViewHolder(ViewGroup parent) {
+ return new DateHeaderRowViewHolder(getContext(), parent);
+ }
+
+ /**
+ * A ViewHolder for
+ * {@link com.android.tv.dvr.ui.list.SchedulesHeaderRow.DateHeaderRow}.
+ */
+ public static class DateHeaderRowViewHolder extends SchedulesHeaderRowViewHolder {
+ public DateHeaderRowViewHolder(Context context, ViewGroup parent) {
+ super(context, parent);
+ }
+ }
+ }
+
+ /**
+ * A presenter for {@link SeriesRecordingHeaderRow}.
+ */
+ public static class SeriesRecordingHeaderRowPresenter extends SchedulesHeaderRowPresenter {
+ private final boolean mLtr;
+ private final Drawable mSettingsDrawable;
+ private final Drawable mCancelDrawable;
+ private final Drawable mResumeDrawable;
+
+ private final String mSettingsInfo;
+ private final String mCancelAllInfo;
+ private final String mResumeInfo;
+
+ public SeriesRecordingHeaderRowPresenter(Context context) {
+ super(context);
+ mLtr = context.getResources().getConfiguration().getLayoutDirection()
+ == View.LAYOUT_DIRECTION_LTR;
+ mSettingsDrawable = context.getDrawable(R.drawable.ic_settings);
+ mCancelDrawable = context.getDrawable(R.drawable.ic_dvr_cancel_large);
+ mResumeDrawable = context.getDrawable(R.drawable.ic_record_start);
+ mSettingsInfo = context.getString(R.string.dvr_series_schedules_settings);
+ mCancelAllInfo = context.getString(R.string.dvr_series_schedules_stop);
+ mResumeInfo = context.getString(R.string.dvr_series_schedules_start);
+ }
+
+ @Override
+ protected ViewHolder createRowViewHolder(ViewGroup parent) {
+ return new SeriesHeaderRowViewHolder(getContext(), parent);
+ }
+
+ @Override
+ protected void onBindRowViewHolder(RowPresenter.ViewHolder viewHolder, Object item) {
+ super.onBindRowViewHolder(viewHolder, item);
+ SeriesHeaderRowViewHolder headerViewHolder =
+ (SeriesHeaderRowViewHolder) viewHolder;
+ SeriesRecordingHeaderRow header = (SeriesRecordingHeaderRow) item;
+ headerViewHolder.mSeriesSettingsButton.setVisibility(
+ header.getSeriesRecording().isStopped() ? View.INVISIBLE : View.VISIBLE);
+ headerViewHolder.mSeriesSettingsButton.setText(mSettingsInfo);
+ setTextDrawable(headerViewHolder.mSeriesSettingsButton, mSettingsDrawable);
+ if (header.getSeriesRecording().isStopped()) {
+ headerViewHolder.mToggleStartStopButton.setText(mResumeInfo);
+ setTextDrawable(headerViewHolder.mToggleStartStopButton, mResumeDrawable);
+ } else {
+ headerViewHolder.mToggleStartStopButton.setText(mCancelAllInfo);
+ setTextDrawable(headerViewHolder.mToggleStartStopButton, mCancelDrawable);
+ }
+ headerViewHolder.mSeriesSettingsButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ // TODO: pass channel list for settings.
+ DvrUiHelper.startSeriesSettingsActivity(getContext(),
+ header.getSeriesRecording().getId(), null, false, false, false);
+ }
+ });
+ headerViewHolder.mToggleStartStopButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (header.getSeriesRecording().isStopped()) {
+ // Reset priority to the highest.
+ SeriesRecording seriesRecording = SeriesRecording
+ .buildFrom(header.getSeriesRecording())
+ .setPriority(TvApplication.getSingletons(getContext())
+ .getDvrScheduleManager().suggestNewSeriesPriority())
+ .build();
+ TvApplication.getSingletons(getContext()).getDvrManager()
+ .updateSeriesRecording(seriesRecording);
+ // TODO: pass channel list for settings.
+ DvrUiHelper.startSeriesSettingsActivity(getContext(),
+ header.getSeriesRecording().getId(), null, false, false, false);
+ } else {
+ DvrUiHelper.showCancelAllSeriesRecordingDialog(
+ (DvrSchedulesActivity) view.getContext(),
+ header.getSeriesRecording());
+ }
+ }
+ });
+ }
+
+ private void setTextDrawable(TextView textView, Drawable drawableStart) {
+ if (mLtr) {
+ textView.setCompoundDrawablesWithIntrinsicBounds(drawableStart, null, null, null);
+ } else {
+ textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawableStart, null);
+ }
+ }
+
+ /**
+ * A ViewHolder for {@link SeriesRecordingHeaderRow}.
+ */
+ public static class SeriesHeaderRowViewHolder extends SchedulesHeaderRowViewHolder {
+ private final TextView mSeriesSettingsButton;
+ private final TextView mToggleStartStopButton;
+ private final boolean mLtr;
+
+ private final View mSelector;
+
+ private View mLastFocusedView;
+ public SeriesHeaderRowViewHolder(Context context, ViewGroup parent) {
+ super(context, parent);
+ mLtr = context.getResources().getConfiguration().getLayoutDirection()
+ == View.LAYOUT_DIRECTION_LTR;
+ view.findViewById(R.id.button_container).setVisibility(View.VISIBLE);
+ mSeriesSettingsButton = (TextView) view.findViewById(R.id.series_settings);
+ mToggleStartStopButton =
+ (TextView) view.findViewById(R.id.series_toggle_start_stop);
+ mSelector = view.findViewById(R.id.selector);
+ OnFocusChangeListener onFocusChangeListener = new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View view, boolean focused) {
+ view.post(new Runnable() {
+ @Override
+ public void run() {
+ updateSelector(view);
+ }
+ });
+ }
+ };
+ mSeriesSettingsButton.setOnFocusChangeListener(onFocusChangeListener);
+ mToggleStartStopButton.setOnFocusChangeListener(onFocusChangeListener);
+ }
+
+ private void updateSelector(View focusedView) {
+ int animationDuration = mSelector.getContext().getResources()
+ .getInteger(android.R.integer.config_shortAnimTime);
+ DecelerateInterpolator interpolator = new DecelerateInterpolator();
+
+ if (focusedView.hasFocus()) {
+ ViewGroup.LayoutParams lp = mSelector.getLayoutParams();
+ final int targetWidth = focusedView.getWidth();
+ float targetTranslationX;
+ if (mLtr) {
+ targetTranslationX = focusedView.getLeft() - mSelector.getLeft();
+ } else {
+ targetTranslationX = focusedView.getRight() - mSelector.getRight();
+ }
+
+ // if the selector is invisible, set the width and translation X directly -
+ // don't animate.
+ if (mSelector.getAlpha() == 0) {
+ mSelector.setTranslationX(targetTranslationX);
+ lp.width = targetWidth;
+ mSelector.requestLayout();
+ }
+
+ // animate the selector in and to the proper width and translation X.
+ final float deltaWidth = lp.width - targetWidth;
+ mSelector.animate().cancel();
+ mSelector.animate().translationX(targetTranslationX).alpha(1f)
+ .setUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ // Set width to the proper width for this animation step.
+ lp.width = targetWidth + Math.round(
+ deltaWidth * (1f - animation.getAnimatedFraction()));
+ mSelector.requestLayout();
+ }
+ }).setDuration(animationDuration).setInterpolator(interpolator).start();
+ mLastFocusedView = focusedView;
+ } else if (mLastFocusedView == focusedView) {
+ mSelector.animate().setUpdateListener(null).cancel();
+ mSelector.animate().alpha(0f).setDuration(animationDuration)
+ .setInterpolator(interpolator).start();
+ mLastFocusedView = null;
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java
new file mode 100644
index 00000000..3b493774
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowAdapter.java
@@ -0,0 +1,269 @@
+/*
+* Copyright (C) 2016 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License
+*/
+
+package com.android.tv.dvr.ui.list;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.tv.TvInputInfo;
+import android.os.Build;
+import android.support.v17.leanback.widget.ClassPresenterSelector;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.SeriesRecording;
+import com.android.tv.dvr.ui.list.SchedulesHeaderRow.SeriesRecordingHeaderRow;
+import com.android.tv.util.Utils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * An adapter for series schedule row.
+ */
+@TargetApi(Build.VERSION_CODES.N)
+public class SeriesScheduleRowAdapter extends ScheduleRowAdapter {
+ private static final String TAG = "SeriesRowAdapter";
+ private static final boolean DEBUG = false;
+
+ private final SeriesRecording mSeriesRecording;
+ private final String mInputId;
+ private final DvrManager mDvrManager;
+ private final DvrDataManager mDataManager;
+ private final Map<Long, Program> mPrograms = new ArrayMap<>();
+ private SeriesRecordingHeaderRow mHeaderRow;
+
+ public SeriesScheduleRowAdapter(Context context, ClassPresenterSelector classPresenterSelector,
+ SeriesRecording seriesRecording) {
+ super(context, classPresenterSelector);
+ mSeriesRecording = seriesRecording;
+ TvInputInfo input = Utils.getTvInputInfoForInputId(context, mSeriesRecording.getInputId());
+ if (SoftPreconditions.checkNotNull(input) != null) {
+ mInputId = input.getId();
+ } else {
+ mInputId = null;
+ }
+ ApplicationSingletons singletons = TvApplication.getSingletons(context);
+ mDvrManager = singletons.getDvrManager();
+ mDataManager = singletons.getDvrDataManager();
+ setHasStableIds(true);
+ }
+
+ @Override
+ public void start() {
+ setPrograms(Collections.emptyList());
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ }
+
+ /**
+ * Sets the programs to show.
+ */
+ public void setPrograms(List<Program> programs) {
+ if (programs == null) {
+ programs = Collections.emptyList();
+ }
+ clear();
+ mPrograms.clear();
+ List<Program> sortedPrograms = new ArrayList<>(programs);
+ Collections.sort(sortedPrograms);
+ List<EpisodicProgramRow> rows = new ArrayList<>();
+ mHeaderRow = new SeriesRecordingHeaderRow(mSeriesRecording.getTitle(),
+ null, sortedPrograms.size(), mSeriesRecording);
+ for (Program program : sortedPrograms) {
+ ScheduledRecording schedule =
+ mDataManager.getScheduledRecordingForProgramId(program.getId());
+ if (schedule != null && !willBeKept(schedule)) {
+ schedule = null;
+ }
+ rows.add(new EpisodicProgramRow(mInputId, program, schedule, mHeaderRow));
+ mPrograms.put(program.getId(), program);
+ }
+ mHeaderRow.setDescription(getDescription());
+ add(mHeaderRow);
+ for (EpisodicProgramRow row : rows) {
+ add(row);
+ }
+ sendNextUpdateMessage(System.currentTimeMillis());
+ }
+
+ private String getDescription() {
+ int conflicts = 0;
+ for (long programId : mPrograms.keySet()) {
+ if (mDvrManager.isConflicting(
+ mDataManager.getScheduledRecordingForProgramId(programId))) {
+ ++conflicts;
+ }
+ }
+ return conflicts == 0 ? null : getContext().getResources().getQuantityString(
+ R.plurals.dvr_series_schedules_header_description, conflicts, conflicts);
+ }
+
+ @Override
+ public long getId(int position) {
+ Object obj = get(position);
+ if (obj instanceof EpisodicProgramRow) {
+ return ((EpisodicProgramRow) obj).getProgram().getId();
+ }
+ if (obj instanceof SeriesRecordingHeaderRow) {
+ return 0;
+ }
+ return super.getId(position);
+ }
+
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording schedule) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingAdded: " + schedule);
+ int index = findRowIndexByProgramId(schedule.getProgramId());
+ if (index != -1) {
+ EpisodicProgramRow row = (EpisodicProgramRow) get(index);
+ if (!row.isStartRecordingRequested()) {
+ row.setSchedule(schedule);
+ notifyArrayItemRangeChanged(index, 1);
+ }
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording schedule) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingRemoved: " + schedule);
+ int index = findRowIndexByProgramId(schedule.getProgramId());
+ if (index != -1) {
+ EpisodicProgramRow row = (EpisodicProgramRow) get(index);
+ row.setSchedule(null);
+ notifyArrayItemRangeChanged(index, 1);
+ }
+ }
+
+ @Override
+ public void onScheduledRecordingUpdated(ScheduledRecording schedule, boolean conflictChange) {
+ if (DEBUG) Log.d(TAG, "onScheduledRecordingUpdated: " + schedule);
+ int index = findRowIndexByProgramId(schedule.getProgramId());
+ if (index != -1) {
+ EpisodicProgramRow row = (EpisodicProgramRow) get(index);
+ if (conflictChange && isStartOrStopRequested()) {
+ // Delay the conflict update until it gets the response of the start/stop request.
+ // The purpose is to avoid the intermediate conflict change.
+ addPendingUpdate(row);
+ return;
+ }
+ if (row.isStopRecordingRequested()) {
+ // Wait until the recording is finished
+ if (schedule.getState() == ScheduledRecording.STATE_RECORDING_FINISHED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_CLIPPED
+ || schedule.getState() == ScheduledRecording.STATE_RECORDING_FAILED) {
+ row.setStopRecordingRequested(false);
+ if (!isStartOrStopRequested()) {
+ executePendingUpdate();
+ }
+ row.setSchedule(null);
+ }
+ } else if (row.isStartRecordingRequested()) {
+ // When the start recording was requested, we give the highest priority. So it is
+ // guaranteed that the state will be changed from NOT_STARTED to the other state.
+ // Update the row with the next state not to show the intermediate state to avoid
+ // blinking.
+ if (schedule.getState() != ScheduledRecording.STATE_RECORDING_NOT_STARTED) {
+ row.setStartRecordingRequested(false);
+ if (!isStartOrStopRequested()) {
+ executePendingUpdate();
+ }
+ row.setSchedule(schedule);
+ }
+ } else if (willBeKept(schedule)) {
+ row.setSchedule(schedule);
+ } else {
+ row.setSchedule(null);
+ }
+ notifyArrayItemRangeChanged(index, 1);
+ }
+ }
+
+ public void onSeriesRecordingUpdated(SeriesRecording seriesRecording) {
+ if (seriesRecording.getId() == mSeriesRecording.getId()) {
+ mHeaderRow.setSeriesRecording(seriesRecording);
+ notifyArrayItemRangeChanged(0, 1);
+ }
+ }
+
+ private int findRowIndexByProgramId(long programId) {
+ for (int i = 0; i < size(); i++) {
+ Object item = get(i);
+ if (item instanceof EpisodicProgramRow) {
+ if (((EpisodicProgramRow) item).getProgram().getId() == programId) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public void notifyArrayItemRangeChanged(int positionStart, int itemCount) {
+ mHeaderRow.setDescription(getDescription());
+ super.notifyArrayItemRangeChanged(0, 1);
+ super.notifyArrayItemRangeChanged(positionStart, itemCount);
+ }
+
+ @Override
+ protected void handleUpdateRow(long currentTimeMs) {
+ for (Iterator<Program> iter = mPrograms.values().iterator(); iter.hasNext(); ) {
+ Program program = iter.next();
+ if (program.getEndTimeUtcMillis() <= currentTimeMs) {
+ // Remove the old program.
+ removeItems(findRowIndexByProgramId(program.getId()), 1);
+ iter.remove();
+ } else if (program.getStartTimeUtcMillis() < currentTimeMs) {
+ // Change the button "START RECORDING"
+ notifyItemRangeChanged(findRowIndexByProgramId(program.getId()), 1);
+ }
+ }
+ }
+
+ /**
+ * Should take the current time argument which is the time when the programs are checked in
+ * handler.
+ */
+ @Override
+ protected long getNextTimerMs(long currentTimeMs) {
+ long earliest = Long.MAX_VALUE;
+ for (Program program : mPrograms.values()) {
+ if (earliest > program.getStartTimeUtcMillis()
+ && program.getStartTimeUtcMillis() >= currentTimeMs) {
+ // Need the button from "CREATE SCHEDULE" to "START RECORDING"
+ earliest = program.getStartTimeUtcMillis();
+ } else if (earliest > program.getEndTimeUtcMillis()) {
+ // Need to remove the row.
+ earliest = program.getEndTimeUtcMillis();
+ }
+ }
+ return earliest;
+ }
+}
diff --git a/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java
new file mode 100644
index 00000000..5d88579a
--- /dev/null
+++ b/src/com/android/tv/dvr/ui/list/SeriesScheduleRowPresenter.java
@@ -0,0 +1,143 @@
+/*
+* Copyright (C) 2016 The Android Open Source Project
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License
+*/
+
+package com.android.tv.dvr.ui.list;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.R;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.util.Utils;
+
+/**
+ * A RowPresenter for series schedule row.
+ */
+public class SeriesScheduleRowPresenter extends ScheduleRowPresenter {
+ private static final String TAG = "SeriesRowPresenter";
+
+ private boolean mLtr;
+
+ public SeriesScheduleRowPresenter(Context context) {
+ super(context);
+ mLtr = context.getResources().getConfiguration().getLayoutDirection()
+ == View.LAYOUT_DIRECTION_LTR;
+ }
+
+ public static class SeriesScheduleRowViewHolder extends ScheduleRowViewHolder {
+ public SeriesScheduleRowViewHolder(View view, ScheduleRowPresenter presenter) {
+ super(view, presenter);
+ ViewGroup.LayoutParams lp = getTimeView().getLayoutParams();
+ lp.width = view.getResources().getDimensionPixelSize(
+ R.dimen.dvr_series_schedules_item_time_width);
+ getTimeView().setLayoutParams(lp);
+ }
+ }
+
+ @Override
+ protected ScheduleRowViewHolder onGetScheduleRowViewHolder(View view) {
+ return new SeriesScheduleRowViewHolder(view, this);
+ }
+
+ @Override
+ protected String onGetRecordingTimeText(ScheduleRow row) {
+ return Utils.getDurationString(getContext(), row.getStartTimeMs(), row.getEndTimeMs(),
+ false, true, true, 0);
+ }
+
+ @Override
+ protected String onGetProgramInfoText(ScheduleRow row) {
+ return row.getEpisodeDisplayTitle(getContext());
+ }
+
+ @Override
+ protected void onBindRowViewHolder(ViewHolder vh, Object item) {
+ super.onBindRowViewHolder(vh, item);
+ SeriesScheduleRowViewHolder viewHolder = (SeriesScheduleRowViewHolder) vh;
+ EpisodicProgramRow row = (EpisodicProgramRow) item;
+ if (getDvrManager().isConflicting(row.getSchedule())) {
+ viewHolder.getProgramTitleView().setCompoundDrawablePadding(getContext()
+ .getResources().getDimensionPixelOffset(
+ R.dimen.dvr_schedules_warning_icon_padding));
+ if (mLtr) {
+ viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds(
+ R.drawable.ic_warning_gray600_36dp, 0, 0, 0);
+ } else {
+ viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds(
+ 0, 0, R.drawable.ic_warning_gray600_36dp, 0);
+ }
+ } else {
+ viewHolder.getProgramTitleView().setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
+ }
+ }
+
+ @Override
+ protected void onInfoClicked(ScheduleRow row) {
+ if (row.getSchedule() != null) {
+ DvrUiHelper.startSchedulesActivity(getContext(), row.getSchedule());
+ }
+ }
+
+ @Override
+ protected void onStartRecording(ScheduleRow row) {
+ SoftPreconditions.checkState(row.getSchedule() == null, TAG,
+ "Start request with the existing schedule: " + row);
+ row.setStartRecordingRequested(true);
+ getDvrManager().addScheduleWithHighestPriority(((EpisodicProgramRow) row).getProgram());
+ }
+
+ @Override
+ protected void onStopRecording(ScheduleRow row) {
+ SoftPreconditions.checkState(row.getSchedule() != null, TAG,
+ "Stop request with the null schedule: " + row);
+ row.setStopRecordingRequested(true);
+ getDvrManager().stopRecording(row.getSchedule());
+ }
+
+ @Override
+ protected void onCreateSchedule(ScheduleRow row) {
+ if (row.getSchedule() == null) {
+ getDvrManager().addScheduleWithHighestPriority(((EpisodicProgramRow) row).getProgram());
+ } else {
+ super.onCreateSchedule(row);
+ }
+ }
+
+ @Override
+ @ScheduleRowAction
+ protected int[] getAvailableActions(ScheduleRow row) {
+ if (row.getSchedule() == null) {
+ if (row.isOnAir()) {
+ return new int[] {ACTION_START_RECORDING};
+ } else {
+ return new int[] {ACTION_CREATE_SCHEDULE};
+ }
+ }
+ return super.getAvailableActions(row);
+ }
+
+ @Override
+ protected boolean canResolveConflict() {
+ return false;
+ }
+
+ @Override
+ protected boolean shouldKeepScheduleAfterRemoving() {
+ return true;
+ }
+}
diff --git a/src/com/android/tv/experiments/ExperimentFlag.java b/src/com/android/tv/experiments/ExperimentFlag.java
new file mode 100644
index 00000000..8f60c2b5
--- /dev/null
+++ b/src/com/android/tv/experiments/ExperimentFlag.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.experiments;
+
+
+/**
+ * Experiments return values based on user, device and other criteria.
+ */
+public final class ExperimentFlag<T> {
+ private final T mDefaultValue;
+
+ /** Returns a boolean experiment */
+ public static ExperimentFlag<Boolean> createFlag(
+ boolean defaultValue) {
+ return new ExperimentFlag<>(
+ defaultValue);
+ }
+
+ private ExperimentFlag(
+ T defaultValue) {
+ mDefaultValue = defaultValue;
+ }
+
+ /** Returns value for this experiment */
+ public T get() {
+ return mDefaultValue;
+ }
+}
diff --git a/src/com/android/tv/experiments/Experiments.java b/src/com/android/tv/experiments/Experiments.java
new file mode 100644
index 00000000..f16c8d1e
--- /dev/null
+++ b/src/com/android/tv/experiments/Experiments.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.experiments;
+
+import static com.android.tv.experiments.ExperimentFlag.createFlag;
+
+import com.android.tv.common.BuildConfig;
+
+/**
+ * Set of experiments visible in AOSP.
+ *
+ * <p>
+ * This file is maintained by hand.
+ */
+public final class Experiments {
+ public static final ExperimentFlag<Boolean> CLOUD_EPG = createFlag(
+ false);
+
+ /**
+ * Allow developer features such as the dev menu and other aids.
+ *
+ * <p>These features are available to select users(aka fishfooders) on production builds.
+ */
+ public static final ExperimentFlag<Boolean> ENABLE_DEVELOPER_FEATURES = createFlag(
+ BuildConfig.ENG);
+
+ private Experiments() {}
+}
diff --git a/src/com/android/tv/guide/ProgramGuide.java b/src/com/android/tv/guide/ProgramGuide.java
index bfcb8b0d..120b3dba 100644
--- a/src/com/android/tv/guide/ProgramGuide.java
+++ b/src/com/android/tv/guide/ProgramGuide.java
@@ -22,7 +22,7 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
-import android.animation.ValueAnimator;
+import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Point;
@@ -42,6 +42,7 @@ import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityManager;
import com.android.tv.ChannelTuner;
import com.android.tv.Features;
@@ -54,7 +55,9 @@ import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.GenreItems;
import com.android.tv.data.ProgramDataManager;
import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrScheduleManager;
import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter;
+import com.android.tv.ui.ViewUtils;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
@@ -89,6 +92,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
private final MainActivity mActivity;
private final ProgramManager mProgramManager;
+ private final AccessibilityManager mAccessibilityManager;
private final ChannelTuner mChannelTuner;
private final Tracker mTracker;
private final DurationTimer mVisibleDuration = new DurationTimer();
@@ -163,10 +167,11 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
public ProgramGuide(MainActivity activity, ChannelTuner channelTuner,
TvInputManagerHelper tvInputManagerHelper, ChannelDataManager channelDataManager,
ProgramDataManager programDataManager, @Nullable DvrDataManager dvrDataManager,
- Tracker tracker, Runnable preShowRunnable, Runnable postHideRunnable) {
+ @Nullable DvrScheduleManager dvrScheduleManager, Tracker tracker,
+ Runnable preShowRunnable, Runnable postHideRunnable) {
mActivity = activity;
mProgramManager = new ProgramManager(tvInputManagerHelper, channelDataManager,
- programDataManager, dvrDataManager);
+ programDataManager, dvrDataManager, dvrScheduleManager);
mChannelTuner = channelTuner;
mTracker = tracker;
mPreShowRunnable = preShowRunnable;
@@ -372,7 +377,10 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
mProgramTableFadeInAnimator.setTarget(mTable);
mProgramTableFadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(mTable));
mSharedPreference = PreferenceManager.getDefaultSharedPreferences(mActivity);
- mShowGuidePartial = mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true);
+ mAccessibilityManager =
+ (AccessibilityManager) mActivity.getSystemService(Context.ACCESSIBILITY_SERVICE);
+ mShowGuidePartial = mAccessibilityManager.isEnabled()
+ || mSharedPreference.getBoolean(KEY_SHOW_GUIDE_PARTIAL, true);
}
private void updateGuidePosition() {
@@ -604,7 +612,9 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
}
private void startFull() {
- if (isFull()) {
+ if (isFull() || mAccessibilityManager.isEnabled()) {
+ // If accessibility service is enabled, focus cannot be moved to side panel due to it's
+ // hidden. Therefore, we don't hide side panel when accessibility service is enabled.
return;
}
mShowGuidePartial = false;
@@ -741,7 +751,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
View detailView = row.findViewById(R.id.detail);
detailView.findViewById(R.id.detail_content_full).setAlpha(1);
detailView.findViewById(R.id.detail_content_full).setTranslationY(0);
- setLayoutHeight(detailView, mDetailHeight);
+ ViewUtils.setLayoutHeight(detailView, mDetailHeight);
detailView.setVisibility(View.VISIBLE);
final ProgramRow programRow = (ProgramRow) row.findViewById(R.id.row);
@@ -783,8 +793,8 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
fadeOutAnimator.setDuration(mAnimationDuration);
fadeOutAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(outDetailContent));
- Animator collapseAnimator =
- createHeightAnimator(outDetail, getLayoutHeight(outDetail), 0);
+ Animator collapseAnimator = ViewUtils
+ .createHeightAnimator(outDetail, ViewUtils.getLayoutHeight(outDetail), 0);
collapseAnimator.setStartDelay(mAnimationDuration);
collapseAnimator.setDuration(mTableFadeAnimDuration);
collapseAnimator.addListener(new AnimatorListenerAdapter() {
@@ -815,7 +825,7 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
if (inDetail != null) {
final View inDetailContent = inDetail.findViewById(R.id.detail_content_full);
- Animator expandAnimator = createHeightAnimator(inDetail, 0, mDetailHeight);
+ Animator expandAnimator = ViewUtils.createHeightAnimator(inDetail, 0, mDetailHeight);
expandAnimator.setStartDelay(mAnimationDuration);
expandAnimator.setDuration(mTableFadeAnimDuration);
expandAnimator.addListener(new AnimatorListenerAdapter() {
@@ -830,17 +840,15 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
inDetailContent.setAlpha(0);
}
});
-
Animator fadeInAnimator = ObjectAnimator.ofPropertyValuesHolder(inDetailContent,
PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f),
PropertyValuesHolder.ofFloat(View.TRANSLATION_Y,
direction * -mDetailPadding, 0f));
- fadeInAnimator.setStartDelay(mAnimationDuration + mTableFadeAnimDuration);
fadeInAnimator.setDuration(mAnimationDuration);
fadeInAnimator.addListener(new HardwareLayerAnimatorListenerAdapter(inDetailContent));
AnimatorSet inAnimator = new AnimatorSet();
- inAnimator.playTogether(expandAnimator, fadeInAnimator);
+ inAnimator.playSequentially(expandAnimator, fadeInAnimator);
inAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animator) {
@@ -852,41 +860,6 @@ public class ProgramGuide implements ProgramGrid.ChildFocusListener {
}
}
- private Animator createHeightAnimator(
- final View target, int initialHeight, int targetHeight) {
- ValueAnimator animator = ValueAnimator.ofInt(initialHeight, targetHeight);
- animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- int value = (Integer) animation.getAnimatedValue();
- if (value == 0) {
- if (target.getVisibility() != View.GONE) {
- target.setVisibility(View.GONE);
- }
- } else {
- if (target.getVisibility() != View.VISIBLE) {
- target.setVisibility(View.VISIBLE);
- }
- setLayoutHeight(target, value);
- }
- }
- });
- return animator;
- }
-
- private int getLayoutHeight(View view) {
- LayoutParams layoutParams = view.getLayoutParams();
- return layoutParams.height;
- }
-
- private void setLayoutHeight(View view, int height) {
- LayoutParams layoutParams = view.getLayoutParams();
- if (height != layoutParams.height) {
- layoutParams.height = height;
- view.setLayoutParams(layoutParams);
- }
- }
-
private class GlobalFocusChangeListener implements
ViewTreeObserver.OnGlobalFocusChangeListener {
private static final int UNKNOWN = 0;
diff --git a/src/com/android/tv/guide/ProgramItemView.java b/src/com/android/tv/guide/ProgramItemView.java
index 172ee070..4c7a4404 100644
--- a/src/com/android/tv/guide/ProgramItemView.java
+++ b/src/com/android/tv/guide/ProgramItemView.java
@@ -16,6 +16,7 @@
package com.android.tv.guide;
+import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Resources;
@@ -24,7 +25,6 @@ import android.graphics.drawable.LayerDrawable;
import android.graphics.drawable.StateListDrawable;
import android.os.Handler;
import android.os.SystemClock;
-import android.support.v4.os.BuildCompat;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
@@ -34,6 +34,7 @@ import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
+import android.widget.Toast;
import com.android.tv.ApplicationSingletons;
import com.android.tv.MainActivity;
@@ -43,10 +44,10 @@ import com.android.tv.analytics.Tracker;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.data.Channel;
import com.android.tv.dvr.DvrManager;
-import com.android.tv.dvr.ui.DvrDialogFragment;
-import com.android.tv.dvr.ui.DvrRecordDeleteFragment;
-import com.android.tv.dvr.ui.DvrRecordScheduleFragment;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
import com.android.tv.guide.ProgramManager.TableEntry;
+import com.android.tv.util.ToastUtils;
import com.android.tv.util.Utils;
import java.lang.reflect.InvocationTargetException;
@@ -66,11 +67,13 @@ public class ProgramItemView extends TextView {
private static int sVisibleThreshold;
private static int sItemPadding;
+ private static int sCompoundDrawablePadding;
private static TextAppearanceSpan sProgramTitleStyle;
private static TextAppearanceSpan sGrayedOutProgramTitleStyle;
private static TextAppearanceSpan sEpisodeTitleStyle;
private static TextAppearanceSpan sGrayedOutEpisodeTitleStyle;
+ private DvrManager mDvrManager;
private TableEntry mTableEntry;
private int mMaxWidthForRipple;
private int mTextWidth;
@@ -91,10 +94,9 @@ public class ProgramItemView extends TextView {
ApplicationSingletons singletons = TvApplication.getSingletons(view.getContext());
Tracker tracker = singletons.getTracker();
tracker.sendEpgItemClicked();
+ final MainActivity tvActivity = (MainActivity) view.getContext();
+ final Channel channel = tvActivity.getChannelDataManager().getChannel(entry.channelId);
if (entry.isCurrentProgram()) {
- final MainActivity tvActivity = (MainActivity) view.getContext();
- final Channel channel = tvActivity.getChannelDataManager()
- .getChannel(entry.channelId);
view.postDelayed(new Runnable() {
@Override
public void run() {
@@ -104,37 +106,30 @@ public class ProgramItemView extends TextView {
}, entry.getWidth() > ((ProgramItemView) view).mMaxWidthForRipple ? 0
: view.getResources()
.getInteger(R.integer.program_guide_ripple_anim_duration));
- } else if (CommonFeatures.DVR.isEnabled(view.getContext()) && BuildCompat
- .isAtLeastN()) {
- final MainActivity tvActivity = (MainActivity) view.getContext();
- final DvrManager dvrManager = singletons.getDvrManager();
- final Channel channel = tvActivity.getChannelDataManager()
- .getChannel(entry.channelId);
- if (dvrManager.canRecord(channel.getInputId()) && entry.program != null) {
+ } else if (CommonFeatures.DVR.isEnabled(view.getContext())) {
+ DvrManager dvrManager = singletons.getDvrManager();
+ if (entry.entryStartUtcMillis > System.currentTimeMillis()
+ && dvrManager.isProgramRecordable(entry.program)) {
if (entry.scheduledRecording == null) {
- showDvrDialog(view, entry);
+ if (DvrUiHelper.checkStorageStatusAndShowErrorMessage(tvActivity,
+ channel.getInputId())
+ && DvrUiHelper.handleCreateSchedule(tvActivity, entry.program)) {
+ String msg = view.getContext().getString(
+ R.string.dvr_msg_program_scheduled, entry.program.getTitle());
+ ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
+ }
} else {
- showRecordDeleteDialog(view, entry);
+ dvrManager.removeScheduledRecording(entry.scheduledRecording);
+ String msg = view.getResources().getString(
+ R.string.dvr_schedules_deletion_info, entry.program.getTitle());
+ ToastUtils.show(view.getContext(), msg, Toast.LENGTH_SHORT);
}
+ } else {
+ ToastUtils.show(view.getContext(), view.getResources()
+ .getString(R.string.dvr_msg_cannot_record_program), Toast.LENGTH_SHORT);
}
}
}
-
- private void showDvrDialog(final View view, TableEntry entry) {
- Utils.showToastMessageForDeveloperFeature(view.getContext());
- DvrRecordScheduleFragment dvrRecordScheduleFragment =
- new DvrRecordScheduleFragment(entry);
- DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(dvrRecordScheduleFragment);
- ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment(
- DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true);
- }
-
- private void showRecordDeleteDialog(final View view, final TableEntry entry) {
- DvrRecordDeleteFragment recordDeleteDialogFragment = new DvrRecordDeleteFragment(entry);
- DvrDialogFragment dvrDialogFragment = new DvrDialogFragment(recordDeleteDialogFragment);
- ((MainActivity) view.getContext()).getOverlayManager().showDialogFragment(
- DvrDialogFragment.DIALOG_TAG, dvrDialogFragment, true, true);
- }
};
private static final View.OnFocusChangeListener ON_FOCUS_CHANGED =
@@ -185,6 +180,7 @@ public class ProgramItemView extends TextView {
super(context, attrs, defStyle);
setOnClickListener(ON_CLICKED);
setOnFocusChangeListener(ON_FOCUS_CHANGED);
+ mDvrManager = TvApplication.getSingletons(getContext()).getDvrManager();
}
private void initIfNeeded() {
@@ -197,15 +193,18 @@ public class ProgramItemView extends TextView {
R.dimen.program_guide_table_item_visible_threshold);
sItemPadding = res.getDimensionPixelOffset(R.dimen.program_guide_table_item_padding);
-
- ColorStateList programTitleColor = ColorStateList.valueOf(Utils.getColor(res,
- R.color.program_guide_table_item_program_title_text_color));
- ColorStateList grayedOutProgramTitleColor = Utils.getColorStateList(res,
- R.color.program_guide_table_item_grayed_out_program_text_color);
- ColorStateList episodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
- R.color.program_guide_table_item_program_episode_title_text_color));
- ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(Utils.getColor(res,
- R.color.program_guide_table_item_grayed_out_program_episode_title_text_color));
+ sCompoundDrawablePadding = res.getDimensionPixelOffset(
+ R.dimen.program_guide_table_item_compound_drawable_padding);
+
+ ColorStateList programTitleColor = ColorStateList.valueOf(res.getColor(
+ R.color.program_guide_table_item_program_title_text_color, null));
+ ColorStateList grayedOutProgramTitleColor = res.getColorStateList(
+ R.color.program_guide_table_item_grayed_out_program_text_color, null);
+ ColorStateList episodeTitleColor = ColorStateList.valueOf(res.getColor(
+ R.color.program_guide_table_item_program_episode_title_text_color, null));
+ ColorStateList grayedOutEpisodeTitleColor = ColorStateList.valueOf(res.getColor(
+ R.color.program_guide_table_item_grayed_out_program_episode_title_text_color,
+ null));
int programTitleSize = res.getDimensionPixelSize(
R.dimen.program_guide_table_item_program_title_font_size);
int episodeTitleSize = res.getDimensionPixelSize(
@@ -247,6 +246,7 @@ public class ProgramItemView extends TextView {
return mTableEntry;
}
+ @SuppressLint("SwitchIntDef")
public void setValues(TableEntry entry, int selectedGenreId, long fromUtcMillis,
long toUtcMillis, String gapTitle) {
mTableEntry = entry;
@@ -275,11 +275,6 @@ public class ProgramItemView extends TextView {
if (TextUtils.isEmpty(title)) {
title = getResources().getString(R.string.program_title_for_no_information);
}
- if (mTableEntry.scheduledRecording != null) {
- //TODO(dvr): use a proper icon for UI status.
- title = "®" + title;
- }
-
SpannableStringBuilder description = new SpannableStringBuilder();
description.append(title);
if (!TextUtils.isEmpty(episode)) {
@@ -302,18 +297,48 @@ public class ProgramItemView extends TextView {
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
setText(description);
+
+ // Sets recording icons if needed.
+ int iconResId = 0;
+ if (mTableEntry.scheduledRecording != null) {
+ if (mDvrManager.isConflicting(mTableEntry.scheduledRecording)) {
+ iconResId = R.drawable.ic_warning_white_18dp;
+ } else {
+ switch (mTableEntry.scheduledRecording.getState()) {
+ case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
+ iconResId = R.drawable.ic_scheduled_recording;
+ break;
+ case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
+ iconResId = R.drawable.ic_recording_program;
+ break;
+ }
+ }
+ }
+ setCompoundDrawablePadding(iconResId != 0 ? sCompoundDrawablePadding : 0);
+ setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconResId, 0);
}
measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
mTextWidth = getMeasuredWidth() - getPaddingStart() - getPaddingEnd();
- int start = GuideUtils.convertMillisToPixel(entry.entryStartUtcMillis);
- int guideStart = GuideUtils.convertMillisToPixel(fromUtcMillis);
- layoutVisibleArea(guideStart - start);
-
// Maximum width for us to use a ripple
mMaxWidthForRipple = GuideUtils.convertMillisToPixel(fromUtcMillis, toUtcMillis);
}
/**
+ * Update programItemView to handle alignments of text.
+ */
+ public void updateVisibleArea() {
+ View parentView = ((View) getParent());
+ if (parentView == null) {
+ return;
+ }
+ if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
+ layoutVisibleArea(parentView.getLeft() - getLeft(), getRight() - parentView.getRight());
+ } else {
+ layoutVisibleArea(getRight() - parentView.getRight(), parentView.getLeft() - getLeft());
+ }
+ }
+
+ /**
* Layout title and episode according to visible area.
*
* Here's the spec.
@@ -322,19 +347,25 @@ public class ProgramItemView extends TextView {
* but do not wrap text less than 30min.
* 3. Episode title is visible only if title isn't multi-line.
*
- * @param offset Offset of the start position from the enclosing view's start position.
+ * @param startOffset Offset of the start position from the enclosing view's start position.
+ * @param endOffset Offset of the end position from the enclosing view's end position.
*/
- public void layoutVisibleArea(int offset) {
+ private void layoutVisibleArea(int startOffset, int endOffset) {
int width = mTableEntry.getWidth();
- int startPadding = Math.max(0, offset);
+ int startPadding = Math.max(0, startOffset);
+ int endPadding = Math.max(0, endOffset);
int minWidth = Math.min(width, mTextWidth + 2 * sItemPadding);
if (startPadding > 0 && width - startPadding < minWidth) {
startPadding = Math.max(0, width - minWidth);
}
+ if (endPadding > 0 && width - endPadding < minWidth) {
+ endPadding = Math.max(0, width - minWidth);
+ }
- if (startPadding + sItemPadding != getPaddingStart()) {
+ if (startPadding + sItemPadding != getPaddingStart()
+ || endPadding + sItemPadding != getPaddingEnd()) {
mPreventParentRelayout = true; // The size of this view is kept, no need to tell parent.
- setPaddingRelative(startPadding + sItemPadding, 0, sItemPadding, 0);
+ setPaddingRelative(startPadding + sItemPadding, 0, endPadding + sItemPadding, 0);
mPreventParentRelayout = false;
}
}
diff --git a/src/com/android/tv/guide/ProgramManager.java b/src/com/android/tv/guide/ProgramManager.java
index fe1a981f..e3d919df 100644
--- a/src/com/android/tv/guide/ProgramManager.java
+++ b/src/com/android/tv/guide/ProgramManager.java
@@ -27,6 +27,8 @@ import com.android.tv.data.GenreItems;
import com.android.tv.data.Program;
import com.android.tv.data.ProgramDataManager;
import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrScheduleManager;
+import com.android.tv.dvr.DvrScheduleManager.OnConflictStateChangeListener;
import com.android.tv.dvr.ScheduledRecording;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
@@ -59,12 +61,12 @@ public class ProgramManager {
private final ChannelDataManager mChannelDataManager;
private final ProgramDataManager mProgramDataManager;
private final DvrDataManager mDvrDataManager; // Only set if DVR is enabled
+ private final DvrScheduleManager mDvrScheduleManager;
private long mStartUtcMillis;
private long mEndUtcMillis;
private long mFromUtcMillis;
private long mToUtcMillis;
- private Program mSelectedProgram;
/**
* Entry for program guide table. An "entry" can be either an actual program or a gap between
@@ -177,27 +179,42 @@ public class ProgramManager {
// Channel list after applying genre filter.
// Should be matched with mSelectedGenreId always.
private List<Channel> mFilteredChannels = mChannels;
+ private boolean mChannelDataLoaded;
private final Set<Listener> mListeners = new ArraySet<>();
private final Set<TableEntriesUpdatedListener> mTableEntriesUpdatedListeners = new ArraySet<>();
private final Set<TableEntryChangedListener> mTableEntryChangedListeners = new ArraySet<>();
+ private final DvrDataManager.OnDvrScheduleLoadFinishedListener mDvrLoadedListener =
+ new DvrDataManager.OnDvrScheduleLoadFinishedListener() {
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ if (mChannelDataLoaded) {
+ for (ScheduledRecording r : mDvrDataManager.getAllScheduledRecordings()) {
+ mScheduledRecordingListener.onScheduledRecordingAdded(r);
+ }
+ }
+ mDvrDataManager.removeDvrScheduleLoadFinishedListener(this);
+ }
+ };
+
private final ChannelDataManager.Listener mChannelDataManagerListener =
new ChannelDataManager.Listener() {
@Override
public void onLoadFinished() {
- updateChannels(true, false);
+ mChannelDataLoaded = true;
+ updateChannels(false);
}
@Override
public void onChannelListUpdated() {
- updateChannels(true, false);
+ updateChannels(false);
}
@Override
public void onChannelBrowsableChanged() {
- updateChannels(true, false);
+ updateChannels(false);
}
};
@@ -205,53 +222,75 @@ public class ProgramManager {
new ProgramDataManager.Listener() {
@Override
public void onProgramUpdated() {
- updateTableEntries(true, true);
+ updateTableEntries(true);
}
};
private final DvrDataManager.ScheduledRecordingListener mScheduledRecordingListener =
new DvrDataManager.ScheduledRecordingListener() {
@Override
- public void onScheduledRecordingAdded(ScheduledRecording scheduledRecording) {
- TableEntry oldEntry = getTableEntry(scheduledRecording);
- if (oldEntry != null) {
- TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program,
- scheduledRecording, oldEntry.entryStartUtcMillis,
- oldEntry.entryEndUtcMillis, oldEntry.isBlocked());
- updateEntry(oldEntry, newEntry);
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ TableEntry oldEntry = getTableEntry(schedule);
+ if (oldEntry != null) {
+ TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program,
+ schedule, oldEntry.entryStartUtcMillis,
+ oldEntry.entryEndUtcMillis, oldEntry.isBlocked());
+ updateEntry(oldEntry, newEntry);
+ }
}
}
@Override
- public void onScheduledRecordingRemoved(ScheduledRecording scheduledRecording) {
- TableEntry oldEntry = getTableEntry(scheduledRecording);
- if (oldEntry != null) {
- TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, null,
- oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis,
- oldEntry.isBlocked());
- updateEntry(oldEntry, newEntry);
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ TableEntry oldEntry = getTableEntry(schedule);
+ if (oldEntry != null) {
+ TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program, null,
+ oldEntry.entryStartUtcMillis, oldEntry.entryEndUtcMillis,
+ oldEntry.isBlocked());
+ updateEntry(oldEntry, newEntry);
+ }
}
}
@Override
- public void onScheduledRecordingStatusChanged(ScheduledRecording scheduledRecording) {
- TableEntry oldEntry = getTableEntry(scheduledRecording);
- if (oldEntry != null) {
- TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program,
- scheduledRecording, oldEntry.entryStartUtcMillis,
- oldEntry.entryEndUtcMillis, oldEntry.isBlocked());
- updateEntry(oldEntry, newEntry);
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ TableEntry oldEntry = getTableEntry(schedule);
+ if (oldEntry != null) {
+ TableEntry newEntry = new TableEntry(oldEntry.channelId, oldEntry.program,
+ schedule, oldEntry.entryStartUtcMillis,
+ oldEntry.entryEndUtcMillis, oldEntry.isBlocked());
+ updateEntry(oldEntry, newEntry);
+ }
}
}
};
+ private final OnConflictStateChangeListener mOnConflictStateChangeListener =
+ new OnConflictStateChangeListener() {
+ @Override
+ public void onConflictStateChange(boolean conflict,
+ ScheduledRecording... schedules) {
+ for (ScheduledRecording schedule : schedules) {
+ TableEntry entry = getTableEntry(schedule);
+ if (entry != null) {
+ notifyTableEntryUpdated(entry);
+ }
+ }
+ }
+ };
+
public ProgramManager(TvInputManagerHelper tvInputManagerHelper,
ChannelDataManager channelDataManager, ProgramDataManager programDataManager,
- @Nullable DvrDataManager dvrDataManager) {
+ @Nullable DvrDataManager dvrDataManager,
+ @Nullable DvrScheduleManager dvrScheduleManager) {
mTvInputManagerHelper = tvInputManagerHelper;
mChannelDataManager = channelDataManager;
mProgramDataManager = programDataManager;
mDvrDataManager = dvrDataManager;
+ mDvrScheduleManager = dvrScheduleManager;
}
public void programGuideVisibilityChanged(boolean visible) {
@@ -260,14 +299,26 @@ public class ProgramManager {
mChannelDataManager.addListener(mChannelDataManagerListener);
mProgramDataManager.addListener(mProgramDataManagerListener);
if (mDvrDataManager != null) {
+ if (!mDvrDataManager.isDvrScheduleLoadFinished()) {
+ mDvrDataManager.addDvrScheduleLoadFinishedListener(mDvrLoadedListener);
+ }
mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
}
+ if (mDvrScheduleManager != null) {
+ mDvrScheduleManager.addOnConflictStateChangeListener(
+ mOnConflictStateChangeListener);
+ }
} else {
mChannelDataManager.removeListener(mChannelDataManagerListener);
mProgramDataManager.removeListener(mProgramDataManagerListener);
if (mDvrDataManager != null) {
+ mDvrDataManager.removeDvrScheduleLoadFinishedListener(mDvrLoadedListener);
mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
}
+ if (mDvrScheduleManager != null) {
+ mDvrScheduleManager.removeOnConflictStateChangeListener(
+ mOnConflictStateChangeListener);
+ }
}
}
@@ -325,7 +376,7 @@ public class ProgramManager {
mGenreChannelList.clear();
for (int i = 0; i < GenreItems.getGenreCount(); i++) {
- mGenreChannelList.add(new ArrayList<Channel>());
+ mGenreChannelList.add(new ArrayList<>());
}
for (Channel channel : mChannels) {
// TODO: Use programs in visible area instead of using current programs only.
@@ -383,18 +434,16 @@ public class ProgramManager {
// Note that This can be happens only if program guide isn't shown
// because an user has to select channels as browsable through UI.
- private void updateChannels(boolean notify, boolean clearPreviousTableEntries) {
+ private void updateChannels(boolean clearPreviousTableEntries) {
if (DEBUG) Log.d(TAG, "updateChannels");
mChannels = mChannelDataManager.getBrowsableChannelList();
mSelectedGenreId = GenreItems.ID_ALL_CHANNELS;
mFilteredChannels = mChannels;
- if (notify) {
- notifyChannelsUpdated();
- }
- updateTableEntries(notify, clearPreviousTableEntries);
+ notifyChannelsUpdated();
+ updateTableEntries(clearPreviousTableEntries);
}
- private void updateTableEntries(boolean notify, boolean clear) {
+ private void updateTableEntries(boolean clear) {
if (clear) {
mChannelIdEntriesMap.clear();
}
@@ -443,9 +492,7 @@ public class ProgramManager {
}
}
- if (notify) {
- notifyTableEntriesUpdated();
- }
+ notifyTableEntriesUpdated();
buildGenreFilters();
}
@@ -528,7 +575,7 @@ public class ProgramManager {
}
mProgramDataManager.setPrefetchTimeRange(mStartUtcMillis);
- updateChannels(true, true);
+ updateChannels(true);
setTimeRange(startUtcMillis, endUtcMillis);
}
@@ -643,20 +690,6 @@ public class ProgramManager {
return entries;
}
- /**
- * Get the currently selected channel.
- */
- public Channel getSelectedChannel() {
- return mChannelDataManager.getChannel(mSelectedProgram.getChannelId());
- }
-
- /**
- * Get the currently selected program.
- */
- public Program getSelectedProgram() {
- return mSelectedProgram;
- }
-
public interface Listener {
void onGenresUpdated();
void onChannelsUpdated();
diff --git a/src/com/android/tv/guide/ProgramRow.java b/src/com/android/tv/guide/ProgramRow.java
index 54b864db..2c98ab2d 100644
--- a/src/com/android/tv/guide/ProgramRow.java
+++ b/src/com/android/tv/guide/ProgramRow.java
@@ -22,8 +22,7 @@ import android.support.v7.widget.LinearLayoutManager;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
-import android.view.ViewParent;
-import android.view.ViewTreeObserver;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import com.android.tv.data.Channel;
import com.android.tv.guide.ProgramManager.TableEntry;
@@ -45,25 +44,26 @@ public class ProgramRow extends TimelineGridView {
interface ChildFocusListener {
/**
- * Is called after focus is moved. Only children to {@code ProgramRow} will be passed.
+ * Is called after focus is moved. It used {@link ChildFocusListener#isChild} to decide if
+ * old and new focuses are listener's children.
* See {@code ProgramRow#setChildFocusListener(ChildFocusListener)}.
*/
void onChildFocus(View oldFocus, View newFocus);
}
- private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener =
- new ViewTreeObserver.OnGlobalFocusChangeListener() {
- @Override
- public void onGlobalFocusChanged(View oldFocus, View newFocus) {
- updateCurrentFocus(oldFocus, newFocus);
- }
- };
-
/**
* Used only for debugging.
*/
private Channel mChannel;
+ private final OnGlobalLayoutListener mLayoutListener = new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ updateChildVisibleArea();
+ }
+ };
+
public ProgramRow(Context context) {
this(context, null);
}
@@ -84,21 +84,25 @@ public class ProgramRow extends TimelineGridView {
}
@Override
+ public void onViewAdded(View child) {
+ super.onViewAdded(child);
+ ProgramItemView itemView = (ProgramItemView) child;
+ if (getLeft() <= itemView.getRight() && itemView.getLeft() <= getRight()) {
+ itemView.updateVisibleArea();
+ }
+ }
+
+ @Override
public void onScrolled(int dx, int dy) {
+ // Remove callback to prevent updateChildVisibleArea being called twice.
+ getViewTreeObserver().removeOnGlobalLayoutListener(mLayoutListener);
super.onScrolled(dx, dy);
- int childCount = getChildCount();
if (DEBUG) {
Log.d(TAG, "onScrolled by " + dx);
- Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + childCount);
+ Log.d(TAG, "channelId=" + mChannel.getId() + ", childCount=" + getChildCount());
Log.d(TAG, "ProgramRow {" + Utils.toRectString(this) + "}");
}
- for (int i = 0; i < childCount; ++i) {
- ProgramItemView child = (ProgramItemView) getChildAt(i);
- if (getLeft() <= child.getRight() && child.getLeft() <= getRight()) {
- child.layoutVisibleArea(getLayoutDirection() == LAYOUT_DIRECTION_LTR
- ? getLeft() - child.getLeft() : child.getRight() - getRight());
- }
- }
+ updateChildVisibleArea();
}
/**
@@ -109,29 +113,9 @@ public class ProgramRow extends TimelineGridView {
if (currentProgram == null) {
currentProgram = getChildAt(0);
}
- updateCurrentFocus(null, currentProgram);
- }
-
- private void updateCurrentFocus(View oldFocus, View newFocus) {
- if (mChildFocusListener == null) {
- return;
- }
-
- mChildFocusListener.onChildFocus(isChild(oldFocus) ? oldFocus : null,
- isChild(newFocus) ? newFocus : null);
- }
-
- private boolean isChild(View view) {
- if (view == null) {
- return false;
+ if (mChildFocusListener != null) {
+ mChildFocusListener.onChildFocus(null, currentProgram);
}
-
- for (ViewParent p = view.getParent(); p != null; p = p.getParent()) {
- if (p == this) {
- return true;
- }
- }
- return false;
}
// Call this API after RTL is resolved. (i.e. View is measured.)
@@ -160,7 +144,7 @@ public class ProgramRow extends TimelineGridView {
return focused;
}
} else if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
- if (focusedEntry.entryEndUtcMillis > toMillis + ONE_HOUR_MILLIS) {
+ if (focusedEntry.entryEndUtcMillis >= toMillis + ONE_HOUR_MILLIS) {
// The current entry ends outside of the view; Scroll to the right.
scrollByTime(ONE_HOUR_MILLIS);
return focused;
@@ -172,7 +156,7 @@ public class ProgramRow extends TimelineGridView {
if (isDirectionEnd(direction) || direction == View.FOCUS_FORWARD) {
if (focusedEntry.entryEndUtcMillis != toMillis) {
// The focused entry is the last entry; Align to the right edge.
- scrollByTime(focusedEntry.entryEndUtcMillis - mProgramManager.getToUtcMillis());
+ scrollByTime(focusedEntry.entryEndUtcMillis - toMillis);
return focused;
}
}
@@ -208,23 +192,21 @@ public class ProgramRow extends TimelineGridView {
}
@Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- getViewTreeObserver().addOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
- }
-
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- getViewTreeObserver().removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
- }
-
- @Override
public void onChildDetachedFromWindow(View child) {
if (child.hasFocus()) {
// Focused view can be detached only if it's updated.
TableEntry entry = ((ProgramItemView) child).getTableEntry();
- if (entry.isCurrentProgram()) {
+ if (entry.program == null) {
+ // The focus is lost due to information loaded. Requests focus immediately.
+ // (Because this entry is detached after real entries attached, we can't take
+ // the below approach to resume focus on entry being attached.)
+ post(new Runnable() {
+ @Override
+ public void run() {
+ requestFocus();
+ }
+ });
+ } else if (entry.isCurrentProgram()) {
if (DEBUG) Log.d(TAG, "Keep focus to the current program");
// Current program is visible in the guide.
// Updated entries including current program's will be attached again soon
@@ -242,13 +224,13 @@ public class ProgramRow extends TimelineGridView {
if (mKeepFocusToCurrentProgram) {
TableEntry entry = ((ProgramItemView) child).getTableEntry();
if (entry.isCurrentProgram()) {
+ mKeepFocusToCurrentProgram = false;
post(new Runnable() {
@Override
public void run() {
requestFocus();
}
});
- mKeepFocusToCurrentProgram = false;
}
}
}
@@ -316,6 +298,22 @@ public class ProgramRow extends TimelineGridView {
mProgramManager.getStartTime(), entry.entryStartUtcMillis) - scrollOffset;
((LinearLayoutManager) getLayoutManager())
.scrollToPositionWithOffset(position, offset);
+ // Workaround to b/31598505. When a program's duration is too long,
+ // RecyclerView.onScrolled() will not be called after scrollToPositionWithOffset().
+ // Therefore we have to update children's visible areas by ourselves in theis case.
+ // Since scrollToPositionWithOffset() will call requestLayout(), we can listen to this
+ // behavior to ensure program items' visible areas are correctly updated after layouts
+ // are adjusted, i.e., scrolling is over.
+ getViewTreeObserver().addOnGlobalLayoutListener(mLayoutListener);
+ }
+ }
+
+ private void updateChildVisibleArea() {
+ for (int i = 0; i < getChildCount(); ++i) {
+ ProgramItemView child = (ProgramItemView) getChildAt(i);
+ if (getLeft() < child.getRight() && child.getLeft() < getRight()) {
+ child.updateVisibleArea();
+ }
}
}
}
diff --git a/src/com/android/tv/guide/ProgramTableAdapter.java b/src/com/android/tv/guide/ProgramTableAdapter.java
index 83755b5f..e4a67972 100644
--- a/src/com/android/tv/guide/ProgramTableAdapter.java
+++ b/src/com/android/tv/guide/ProgramTableAdapter.java
@@ -32,6 +32,7 @@ import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.RecycledViewPool;
+import android.text.Html;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
@@ -41,13 +42,22 @@ import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.view.ViewTreeObserver;
+import android.view.accessibility.AccessibilityManager;
import android.widget.ImageView;
+import android.widget.LinearLayout;
import android.widget.TextView;
import com.android.tv.R;
import com.android.tv.TvApplication;
+import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.data.Channel;
import com.android.tv.data.Program;
+import com.android.tv.data.Program.CriticScore;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
import com.android.tv.guide.ProgramManager.TableEntriesUpdatedListener;
import com.android.tv.parental.ParentalControlSettings;
import com.android.tv.ui.HardwareLayerAnimatorListenerAdapter;
@@ -70,11 +80,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
private final Context mContext;
private final TvInputManagerHelper mTvInputManagerHelper;
+ private final DvrManager mDvrManager;
+ private final DvrDataManager mDvrDataManager;
private final ProgramManager mProgramManager;
+ private final AccessibilityManager mAccessibilityManager;
private final ProgramGuide mProgramGuide;
private final Handler mHandler = new Handler();
private final List<ProgramListAdapter> mProgramListAdapters = new ArrayList<>();
private final RecycledViewPool mRecycledViewPool;
+ // views to be be reused when displaying critic scores
+ private final List<LinearLayout> mCriticScoreViews;
private final int mChannelLogoWidth;
private final int mChannelLogoHeight;
@@ -89,11 +104,27 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
private final int mAnimationDuration;
private final int mDetailPadding;
private final TextAppearanceSpan mEpisodeTitleStyle;
+ private final String mProgramRecordableText;
+ private final String mRecordingScheduledText;
+ private final String mRecordingConflictText;
+ private final String mRecordingFailedText;
+ private final String mRecordingInProgressText;
+ private final int mDvrPaddingStartWithTrack;
+ private final int mDvrPaddingStartWithOutTrack;
public ProgramTableAdapter(Context context, ProgramManager programManager,
ProgramGuide programGuide) {
mContext = context;
+ mAccessibilityManager =
+ (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
mTvInputManagerHelper = TvApplication.getSingletons(context).getTvInputManagerHelper();
+ if (CommonFeatures.DVR.isEnabled(context)) {
+ mDvrManager = TvApplication.getSingletons(context).getDvrManager();
+ mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ } else {
+ mDvrManager = null;
+ mDvrDataManager = null;
+ }
mProgramManager = programManager;
mProgramGuide = programGuide;
@@ -110,26 +141,36 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
R.string.program_title_for_no_information);
mProgramTitleForBlockedChannel = res.getString(
R.string.program_title_for_blocked_channel);
- mChannelTextColor = Utils.getColor(res,
- R.color.program_guide_table_header_column_channel_number_text_color);
- mChannelBlockedTextColor = Utils.getColor(res,
- R.color.program_guide_table_header_column_channel_number_blocked_text_color);
- mDetailTextColor = Utils.getColor(res,
- R.color.program_guide_table_detail_title_text_color);
- mDetailGrayedTextColor = Utils.getColor(res,
- R.color.program_guide_table_detail_title_grayed_text_color);
+ mChannelTextColor = res.getColor(
+ R.color.program_guide_table_header_column_channel_number_text_color, null);
+ mChannelBlockedTextColor = res.getColor(
+ R.color.program_guide_table_header_column_channel_number_blocked_text_color, null);
+ mDetailTextColor = res.getColor(
+ R.color.program_guide_table_detail_title_text_color, null);
+ mDetailGrayedTextColor = res.getColor(
+ R.color.program_guide_table_detail_title_grayed_text_color, null);
mAnimationDuration =
res.getInteger(R.integer.program_guide_table_detail_fade_anim_duration);
mDetailPadding = res.getDimensionPixelOffset(
R.dimen.program_guide_table_detail_padding);
+ mProgramRecordableText = res.getString(R.string.dvr_epg_program_recordable);
+ mRecordingScheduledText = res.getString(R.string.dvr_epg_program_recording_scheduled);
+ mRecordingConflictText = res.getString(R.string.dvr_epg_program_recording_conflict);
+ mRecordingFailedText = res.getString(R.string.dvr_epg_program_recording_failed);
+ mRecordingInProgressText = res.getString(R.string.dvr_epg_program_recording_in_progress);
+ mDvrPaddingStartWithTrack = res.getDimensionPixelOffset(
+ R.dimen.program_guide_table_detail_dvr_margin_start);
+ mDvrPaddingStartWithOutTrack = res.getDimensionPixelOffset(
+ R.dimen.program_guide_table_detail_dvr_margin_start_without_track);
int episodeTitleSize = res.getDimensionPixelSize(
R.dimen.program_guide_table_detail_episode_title_text_size);
ColorStateList episodeTitleColor = ColorStateList.valueOf(
- Utils.getColor(res, R.color.program_guide_table_detail_episode_title_text_color));
+ res.getColor(R.color.program_guide_table_detail_episode_title_text_color, null));
mEpisodeTitleStyle = new TextAppearanceSpan(null, 0, episodeTitleSize,
episodeTitleColor, null);
+ mCriticScoreViews = new ArrayList<>();
mRecycledViewPool = new RecycledViewPool();
mRecycledViewPool.setMaxRecycledViews(R.layout.program_guide_table_item,
context.getResources().getInteger(
@@ -137,12 +178,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mProgramManager.addListener(new ProgramManager.ListenerAdapter() {
@Override
public void onChannelsUpdated() {
- mHandler.post(new Runnable() {
- @Override
- public void run() {
- update();
- }
- });
+ update();
}
});
update();
@@ -180,6 +216,15 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
}
@Override
+ public void onBindViewHolder(ProgramRowHolder holder, int position, List<Object> payloads) {
+ if (!payloads.isEmpty()) {
+ holder.updateDetailView();
+ } else {
+ super.onBindViewHolder(holder, position, payloads);
+ }
+ }
+
+ @Override
public ProgramRowHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View itemView = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false);
ProgramRow programRow = (ProgramRow) itemView.findViewById(R.id.row);
@@ -193,6 +238,17 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
int pos = mProgramManager.getProgramIdIndex(tableEntry.channelId, tableEntry.getId());
if (DEBUG) Log.d(TAG, "update(" + channelIndex + ", " + pos + ")");
mProgramListAdapters.get(channelIndex).notifyItemChanged(pos, tableEntry);
+ notifyItemChanged(channelIndex, true);
+ }
+
+ @Override
+ public void onViewAttachedToWindow(ProgramRowHolder holder) {
+ holder.onAttachedToWindow();
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(ProgramRowHolder holder) {
+ holder.onDetachedFromWindow();
}
// TODO: make it static
@@ -222,15 +278,29 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
}
};
+ private final ViewTreeObserver.OnGlobalFocusChangeListener mGlobalFocusChangeListener =
+ new ViewTreeObserver.OnGlobalFocusChangeListener() {
+ @Override
+ public void onGlobalFocusChanged(View oldFocus, View newFocus) {
+ onChildFocus(isChild(oldFocus) ? oldFocus : null,
+ isChild(newFocus) ? newFocus : null);
+ }
+ };
+
// Members of Program Details
private final ViewGroup mDetailView;
private final ImageView mImageView;
private final ImageView mBlockView;
private final TextView mTitleView;
private final TextView mTimeView;
+ private final LinearLayout mCriticScoresLayout;
private final TextView mDescriptionView;
private final TextView mAspectRatioView;
private final TextView mResolutionView;
+ private final ImageView mDvrIconView;
+ private final TextView mDvrTextIconView;
+ private final TextView mDvrStatusView;
+ private final ViewGroup mDvrIndicator;
// Members of Channel Header
private Channel mChannel;
@@ -257,6 +327,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mDescriptionView = (TextView) mDetailView.findViewById(R.id.desc);
mAspectRatioView = (TextView) mDetailView.findViewById(R.id.aspect_ratio);
mResolutionView = (TextView) mDetailView.findViewById(R.id.resolution);
+ mDvrIconView = (ImageView) mDetailView.findViewById(R.id.dvr_icon);
+ mDvrTextIconView = (TextView) mDetailView.findViewById(R.id.dvr_text_icon);
+ mDvrStatusView = (TextView) mDetailView.findViewById(R.id.dvr_status);
+ mDvrIndicator = (ViewGroup) mContainer.findViewById(R.id.dvr_indicator);
+ mCriticScoresLayout = (LinearLayout) mDetailView.findViewById(R.id.critic_scores);
mChannelHeaderView = mContainer.findViewById(R.id.header_column);
mChannelNumberView = (TextView) mContainer.findViewById(R.id.channel_number);
@@ -264,6 +339,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mChannelLogoView = (ImageView) mContainer.findViewById(R.id.channel_logo);
mChannelBlockView = (ImageView) mContainer.findViewById(R.id.channel_block);
mInputLogoView = (ImageView) mContainer.findViewById(R.id.input_logo);
+ mDetailView.setFocusable(mAccessibilityManager.isEnabled());
+ mChannelHeaderView.setFocusable(mAccessibilityManager.isEnabled());
+ mAccessibilityManager.addAccessibilityStateChangeListener(
+ new AccessibilityManager.AccessibilityStateChangeListener() {
+ @Override
+ public void onAccessibilityStateChanged(boolean enable) {
+ mDetailView.setFocusable(enable);
+ mChannelHeaderView.setFocusable(enable);
+ }
+ });
}
public void onBind(int position) {
@@ -331,12 +416,32 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
}
}
+ public boolean isChild(View view) {
+ if (view == null) {
+ return false;
+ }
+ for (ViewParent p = view.getParent(); p != null; p = p.getParent()) {
+ if (p == mContainer) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public void onChildFocus(View oldFocus, View newFocus) {
if (newFocus == null) {
return;
}
- mSelectedEntry = ((ProgramItemView) newFocus).getTableEntry();
+ // When the accessibility service is enabled, focus might be put on channel's header or
+ // detail view, besides program items.
+ if (newFocus == mChannelHeaderView) {
+ mSelectedEntry = ((ProgramItemView) mProgramRow.getChildAt(0)).getTableEntry();
+ } else if (newFocus == mDetailView) {
+ return;
+ } else {
+ mSelectedEntry = ((ProgramItemView) newFocus).getTableEntry();
+ }
if (oldFocus == null) {
updateDetailView();
return;
@@ -403,7 +508,23 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
});
}
+ private void onAttachedToWindow() {
+ mContainer.getViewTreeObserver()
+ .addOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
+ }
+
+ private void onDetachedFromWindow() {
+ mContainer.getViewTreeObserver()
+ .removeOnGlobalFocusChangeListener(mGlobalFocusChangeListener);
+ }
+
private void updateDetailView() {
+ if (mSelectedEntry == null) {
+ // The view holder is never on focus before.
+ return;
+ }
+ if (DEBUG) Log.d(TAG, "updateDetailView");
+ mCriticScoresLayout.removeAllViews();
if (Program.isValid(mSelectedEntry.program)) {
mTitleView.setTextColor(mDetailTextColor);
Context context = itemView.getContext();
@@ -417,11 +538,11 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
createProgramPosterArtCallback(this, program));
}
- if (TextUtils.isEmpty(program.getEpisodeTitle())) {
+ String episodeTitle = program.getEpisodeDisplayTitle(mContext);
+ if (TextUtils.isEmpty(episodeTitle)) {
mTitleView.setText(program.getTitle());
} else {
String title = program.getTitle();
- String episodeTitle = program.getEpisodeDisplayTitle(mContext);
String fullTitle = title + " " + episodeTitle;
SpannableString text = new SpannableString(fullTitle);
@@ -435,6 +556,65 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
program.getStartTimeUtcMillis(),
program.getEndTimeUtcMillis(), false));
+ boolean trackMetaDataVisible = false;
+ trackMetaDataVisible |=
+ updateTextView(mAspectRatioView, Utils.getAspectRatioString(
+ program.getVideoWidth(), program.getVideoHeight()));
+
+ int videoDefinitionLevel = Utils.getVideoDefinitionLevelFromSize(
+ program.getVideoWidth(), program.getVideoHeight());
+ trackMetaDataVisible |=
+ updateTextView(mResolutionView, Utils.getVideoDefinitionLevelString(
+ context, videoDefinitionLevel));
+
+ if (mDvrManager != null && mDvrManager.isProgramRecordable(program)) {
+ ScheduledRecording scheduledRecording =
+ mDvrDataManager.getScheduledRecordingForProgramId(program.getId());
+ String statusText = mProgramRecordableText;
+ int iconResId = 0;
+ if (scheduledRecording != null) {
+ if (mDvrManager.isConflicting(scheduledRecording)) {
+ iconResId = R.drawable.ic_warning_white_12dp;
+ statusText = mRecordingConflictText;
+ } else {
+ switch (scheduledRecording.getState()) {
+ case ScheduledRecording.STATE_RECORDING_IN_PROGRESS:
+ iconResId = R.drawable.ic_recording_program;
+ statusText = mRecordingInProgressText;
+ break;
+ case ScheduledRecording.STATE_RECORDING_NOT_STARTED:
+ iconResId = R.drawable.ic_scheduled_white;
+ statusText = mRecordingScheduledText;
+ break;
+ case ScheduledRecording.STATE_RECORDING_FAILED:
+ iconResId = R.drawable.ic_warning_white_12dp;
+ statusText = mRecordingFailedText;
+ break;
+ default:
+ iconResId = 0;
+ }
+ }
+ }
+ if (iconResId == 0) {
+ mDvrIconView.setVisibility(View.GONE);
+ mDvrTextIconView.setVisibility(View.VISIBLE);
+ } else {
+ mDvrTextIconView.setVisibility(View.GONE);
+ mDvrIconView.setImageResource(iconResId);
+ mDvrIconView.setVisibility(View.VISIBLE);
+ }
+ if (!trackMetaDataVisible) {
+ mDvrIndicator.setPaddingRelative(mDvrPaddingStartWithOutTrack, 0, 0, 0);
+ } else {
+ mDvrIndicator.setPaddingRelative(mDvrPaddingStartWithTrack, 0, 0, 0);
+ }
+ mDvrIndicator.setVisibility(View.VISIBLE);
+ mDvrStatusView.setText(statusText);
+ } else {
+ mDvrIndicator.setVisibility(View.GONE);
+ }
+
+
if (blockedRating == null) {
mBlockView.setVisibility(View.GONE);
updateTextView(mDescriptionView, program.getDescription());
@@ -442,14 +622,6 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mBlockView.setVisibility(View.VISIBLE);
updateTextView(mDescriptionView, getBlockedDescription(blockedRating));
}
-
- updateTextView(mAspectRatioView, Utils.getAspectRatioString(
- program.getVideoWidth(), program.getVideoHeight()));
-
- int videoDefinitionLevel = Utils.getVideoDefinitionLevelFromSize(
- program.getVideoWidth(), program.getVideoHeight());
- updateTextView(mResolutionView, Utils.getVideoDefinitionLevelString(
- context, videoDefinitionLevel));
} else {
mTitleView.setTextColor(mDetailGrayedTextColor);
if (mSelectedEntry.isBlocked()) {
@@ -460,6 +632,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mImageView.setVisibility(View.GONE);
mBlockView.setVisibility(View.GONE);
mTimeView.setVisibility(View.GONE);
+ mDvrIndicator.setVisibility(View.GONE);
mDescriptionView.setVisibility(View.GONE);
mAspectRatioView.setVisibility(View.GONE);
mResolutionView.setVisibility(View.GONE);
@@ -526,12 +699,16 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
}
}
- private void updateTextView(TextView textView, String text) {
+ // The return value of this method will indicate the target view is visible (true)
+ // or gone (false).
+ private boolean updateTextView(TextView textView, String text) {
if (!TextUtils.isEmpty(text)) {
textView.setVisibility(View.VISIBLE);
textView.setText(text);
+ return true;
} else {
textView.setVisibility(View.GONE);
+ return false;
}
}
@@ -554,6 +731,26 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
mInputLogoView.setVisibility(View.VISIBLE);
}
+ private void updateCriticScoreView(ProgramRowHolder holder, final long programId,
+ CriticScore criticScore, View view) {
+ TextView criticScoreSource = (TextView) view.findViewById(R.id.critic_score_source);
+ TextView criticScoreText = (TextView) view.findViewById(R.id.critic_score_score);
+ ImageView criticScoreLogo = (ImageView) view.findViewById(R.id.critic_score_logo);
+
+ //set the appropriate information in the views
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
+ criticScoreSource.setText(Html.fromHtml(criticScore.source,
+ Html.FROM_HTML_MODE_LEGACY));
+ } else {
+ criticScoreSource.setText(Html.fromHtml(criticScore.source));
+ }
+ criticScoreText.setText(criticScore.score);
+ criticScoreSource.setVisibility(View.VISIBLE);
+ criticScoreText.setVisibility(View.VISIBLE);
+ ImageLoader.loadBitmap(mContext, criticScore.logoUrl,
+ createCriticScoreLogoCallback(holder, programId, criticScoreLogo));
+ }
+
private void onHorizontalScrolled() {
if (mDetailInAnimator != null) {
mHandler.removeCallbacks(mDetailInStarter);
@@ -562,6 +759,23 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
}
}
+ private static ImageLoaderCallback<ProgramRowHolder> createCriticScoreLogoCallback(
+ ProgramRowHolder holder, final long programId, ImageView logoView) {
+ return new ImageLoaderCallback<ProgramRowHolder>(holder) {
+ @Override
+ public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logoImage) {
+ if (logoImage == null || holder.mSelectedEntry == null
+ || holder.mSelectedEntry.program == null
+ || holder.mSelectedEntry.program.getId() != programId) {
+ logoView.setVisibility(View.GONE);
+ } else {
+ logoView.setImageBitmap(logoImage);
+ logoView.setVisibility(View.VISIBLE);
+ }
+ }
+ };
+ }
+
private static ImageLoaderCallback<ProgramRowHolder> createProgramPosterArtCallback(
ProgramRowHolder holder, final Program program) {
return new ImageLoaderCallback<ProgramRowHolder>(holder) {
@@ -599,7 +813,7 @@ public class ProgramTableAdapter extends RecyclerView.Adapter<ProgramTableAdapte
return new ImageLoaderCallback<ProgramRowHolder>(holder) {
@Override
public void onBitmapLoaded(ProgramRowHolder holder, @Nullable Bitmap logo) {
- if (logo != null && info.getId()
+ if (logo != null && holder.mChannel != null && info.getId()
.equals(holder.mChannel.getInputId())) {
holder.updateInputLogoInternal(logo);
}
diff --git a/src/com/android/tv/menu/ActionCardView.java b/src/com/android/tv/menu/ActionCardView.java
index 2d72b06f..54892cac 100644
--- a/src/com/android/tv/menu/ActionCardView.java
+++ b/src/com/android/tv/menu/ActionCardView.java
@@ -69,11 +69,13 @@ public class ActionCardView extends FrameLayout implements ItemListRowView.CardV
mStateView.setText(action.getActionDescription(getContext()));
if (action.isEnabled()) {
setEnabled(true);
+ setFocusable(true);
mIconView.setAlpha(OPACITY_ENABLED);
mLabelView.setAlpha(OPACITY_ENABLED);
mStateView.setAlpha(OPACITY_ENABLED);
} else {
setEnabled(false);
+ setFocusable(false);
mIconView.setAlpha(OPACITY_DISABLED);
mLabelView.setAlpha(OPACITY_DISABLED);
mStateView.setAlpha(OPACITY_DISABLED);
diff --git a/src/com/android/tv/menu/AppLinkCardView.java b/src/com/android/tv/menu/AppLinkCardView.java
index 74375da4..bfb5e3f1 100644
--- a/src/com/android/tv/menu/AppLinkCardView.java
+++ b/src/com/android/tv/menu/AppLinkCardView.java
@@ -30,7 +30,6 @@ import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
-import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
@@ -40,7 +39,6 @@ import com.android.tv.data.Channel;
import com.android.tv.util.BitmapUtils;
import com.android.tv.util.ImageLoader;
import com.android.tv.util.TvInputManagerHelper;
-import com.android.tv.util.Utils;
/**
* A view to render an app link card.
@@ -49,10 +47,6 @@ public class AppLinkCardView extends BaseCardView<Channel> {
private static final String TAG = MenuView.TAG;
private static final boolean DEBUG = MenuView.DEBUG;
- private final float mCardHeight;
- private final float mExtendedCardHeight;
- private final float mTextViewHeight;
- private final float mExtendedTextViewCardHeight;
private final int mCardImageWidth;
private final int mCardImageHeight;
private final int mIconWidth;
@@ -63,12 +57,9 @@ public class AppLinkCardView extends BaseCardView<Channel> {
private ImageView mImageView;
private View mGradientView;
private TextView mAppInfoView;
- private TextView mMetaViewFocused;
- private TextView mMetaViewUnfocused;
private View mMetaViewHolder;
private Channel mChannel;
private Intent mIntent;
- private boolean mExtendViewOnFocus;
private final PackageManager mPackageManager;
private final TvInputManagerHelper mTvInputManagerHelper;
@@ -85,19 +76,12 @@ public class AppLinkCardView extends BaseCardView<Channel> {
mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width);
mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.card_image_layout_height);
- mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height);
- mExtendedCardHeight = getResources().getDimensionPixelOffset(
- R.dimen.card_layout_height_extended);
mIconWidth = getResources().getDimensionPixelSize(R.dimen.app_link_card_icon_width);
mIconHeight = getResources().getDimensionPixelSize(R.dimen.app_link_card_icon_height);
mIconPadding = getResources().getDimensionPixelOffset(R.dimen.app_link_card_icon_padding);
mPackageManager = context.getPackageManager();
mTvInputManagerHelper = ((MainActivity) context).getTvInputManagerHelper();
- mTextViewHeight = getResources().getDimensionPixelSize(
- R.dimen.card_meta_layout_height);
- mExtendedTextViewCardHeight = getResources().getDimensionPixelOffset(
- R.dimen.card_meta_layout_height_extended);
- mIconColorFilter = Utils.getColor(getResources(), R.color.app_link_card_icon_color_filter);
+ mIconColorFilter = getResources().getColor(R.color.app_link_card_icon_color_filter, null);
}
/**
@@ -120,7 +104,7 @@ public class AppLinkCardView extends BaseCardView<Channel> {
switch (linkType) {
case Channel.APP_LINK_TYPE_CHANNEL:
- setMetaViewText(mChannel.getAppLinkText());
+ setText(mChannel.getAppLinkText());
mAppInfoView.setVisibility(VISIBLE);
mGradientView.setVisibility(VISIBLE);
mAppInfoView.setCompoundDrawablePadding(mIconPadding);
@@ -138,7 +122,7 @@ public class AppLinkCardView extends BaseCardView<Channel> {
}
break;
case Channel.APP_LINK_TYPE_APP:
- setMetaViewText(getContext().getString(
+ setText(getContext().getString(
R.string.channels_item_app_link_app_launcher,
mPackageManager.getApplicationLabel(appInfo)));
mAppInfoView.setVisibility(GONE);
@@ -164,17 +148,8 @@ public class AppLinkCardView extends BaseCardView<Channel> {
} else {
setCardImageWithBanner(appInfo);
}
-
- mMetaViewFocused.measure(MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY),
- MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
- mExtendViewOnFocus = mMetaViewFocused.getLineCount() > 1;
- if (mExtendViewOnFocus) {
- setMetaViewFocusedAlpha(selected ? 1f : 0f);
- } else {
- setMetaViewFocusedAlpha(1f);
- }
-
- // Call super.onBind() at the end in order to make getCardHeight() return a proper value.
+ // Call super.onBind() at the end intentionally. In order to correctly handle extension of
+ // text view, text should be set before calling super.onBind.
super.onBind(channel, selected);
}
@@ -228,32 +203,6 @@ public class AppLinkCardView extends BaseCardView<Channel> {
mGradientView = findViewById(R.id.image_gradient);
mAppInfoView = (TextView) findViewById(R.id.app_info);
mMetaViewHolder = findViewById(R.id.app_link_text_holder);
- mMetaViewFocused = (TextView) findViewById(R.id.app_link_text_focused);
- mMetaViewUnfocused = (TextView) findViewById(R.id.app_link_text_unfocused);
- }
-
- @Override
- protected void onFocusAnimationStart(boolean selected) {
- if (mExtendViewOnFocus) {
- setMetaViewFocusedAlpha(selected ? 1f : 0f);
- }
- }
-
- @Override
- protected void onSetFocusAnimatedValue(float animatedValue) {
- super.onSetFocusAnimatedValue(animatedValue);
- if (mExtendViewOnFocus) {
- ViewGroup.LayoutParams params = mMetaViewUnfocused.getLayoutParams();
- params.height = Math.round(mTextViewHeight
- + (mExtendedTextViewCardHeight - mTextViewHeight) * animatedValue);
- setMetaViewLayoutParams(params);
- setMetaViewFocusedAlpha(animatedValue);
- }
- }
-
- @Override
- protected float getCardHeight() {
- return (mExtendViewOnFocus && isFocused()) ? mExtendedCardHeight : mCardHeight;
}
// Try to set the card image with following order:
@@ -302,23 +251,8 @@ public class AppLinkCardView extends BaseCardView<Channel> {
@Override
public void onGenerated(Palette palette) {
mMetaViewHolder.setBackgroundColor(palette.getDarkVibrantColor(
- Utils.getColor(getResources(), R.color.channel_card_meta_background)));
+ getResources().getColor(R.color.channel_card_meta_background, null)));
}
});
}
-
- private void setMetaViewLayoutParams(ViewGroup.LayoutParams params) {
- mMetaViewFocused.setLayoutParams(params);
- mMetaViewUnfocused.setLayoutParams(params);
- }
-
- private void setMetaViewText(String text) {
- mMetaViewFocused.setText(text);
- mMetaViewUnfocused.setText(text);
- }
-
- private void setMetaViewFocusedAlpha(float focusedAlpha) {
- mMetaViewFocused.setAlpha(focusedAlpha);
- mMetaViewUnfocused.setAlpha(1f - focusedAlpha);
- }
}
diff --git a/src/com/android/tv/menu/BaseCardView.java b/src/com/android/tv/menu/BaseCardView.java
index b4500dd1..c6a34a5d 100644
--- a/src/com/android/tv/menu/BaseCardView.java
+++ b/src/com/android/tv/menu/BaseCardView.java
@@ -21,10 +21,13 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Outline;
+import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
+import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.LinearLayout;
+import android.widget.TextView;
import com.android.tv.R;
@@ -44,6 +47,16 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo
private final float mVerticalCardMargin;
private final float mCardCornerRadius;
private float mFocusAnimatedValue;
+ private boolean mExtendViewOnFocus;
+ private final float mExtendedCardHeight;
+ private final float mTextViewHeight;
+ private final float mExtendedTextViewHeight;
+ @Nullable
+ private TextView mTextView;
+ @Nullable
+ private TextView mTextViewFocused;
+ private final int mCardImageWidth;
+ private final float mCardHeight;
public BaseCardView(Context context) {
this(context, null);
@@ -72,16 +85,42 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo
outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCardCornerRadius);
}
});
+ mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width);
+ mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height);
+ mExtendedCardHeight = getResources().getDimensionPixelSize(
+ R.dimen.card_layout_height_extended);
+ mTextViewHeight = getResources().getDimensionPixelSize(R.dimen.card_meta_layout_height);
+ mExtendedTextViewHeight = getResources().getDimensionPixelOffset(
+ R.dimen.card_meta_layout_height_extended);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mTextView = (TextView) findViewById(R.id.card_text);
+ mTextViewFocused = (TextView) findViewById(R.id.card_text_focused);
}
/**
* Called when the view is displayed.
+ *
+ * Before onBind is called, this view's text should be set to determine if it'll be extended
+ * or not in focus state.
*/
@Override
public void onBind(T item, boolean selected) {
- // Note that getCardHeight() will be called by setFocusAnimatedValue().
- // Therefore, be sure that getCardHeight() has a proper value before this method is called.
- setFocusAnimatedValue(selected ? SCALE_FACTOR_1F : SCALE_FACTOR_0F);
+ if (mTextView != null && mTextViewFocused != null) {
+ mTextViewFocused.measure(
+ MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
+ mExtendViewOnFocus = mTextViewFocused.getLineCount() > 1;
+ if (mExtendViewOnFocus) {
+ setTextViewFocusedAlpha(selected ? 1f : 0f);
+ } else {
+ setTextViewFocusedAlpha(1f);
+ }
+ }
+ setFocusAnimatedValue(selected ? SCALE_FACTOR_1F : SCALE_FACTOR_0F);
}
@Override
@@ -108,10 +147,48 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo
}
/**
+ * Sets text of this card view.
+ */
+ public void setText(int resId) {
+ if (mTextViewFocused != null) {
+ mTextViewFocused.setText(resId);
+ }
+ if (mTextView != null) {
+ mTextView.setText(resId);
+ }
+ }
+
+ /**
+ * Sets text of this card view.
+ */
+ public void setText(String text) {
+ if (mTextViewFocused != null) {
+ mTextViewFocused.setText(text);
+ }
+ if (mTextView != null) {
+ mTextView.setText(text);
+ }
+ }
+
+ /**
+ * Enables or disables text view of this card view.
+ */
+ public void setTextViewEnabled(boolean enabled) {
+ if (mTextViewFocused != null) {
+ mTextViewFocused.setEnabled(enabled);
+ }
+ if (mTextView != null) {
+ mTextView.setEnabled(enabled);
+ }
+ }
+
+ /**
* Called when the focus animation started.
*/
protected void onFocusAnimationStart(boolean selected) {
- // do nothing.
+ if (mExtendViewOnFocus) {
+ setTextViewFocusedAlpha(selected ? 1f : 0f);
+ }
}
/**
@@ -126,10 +203,19 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo
* between {@code SCALE_FACTOR_0F} and {@code SCALE_FACTOR_1F}.
*/
protected void onSetFocusAnimatedValue(float animatedValue) {
- float scale = 1f + (mVerticalCardMargin / getCardHeight()) * animatedValue;
+ float cardViewHeight = (mExtendViewOnFocus && isFocused())
+ ? mExtendedCardHeight : mCardHeight;
+ float scale = 1f + (mVerticalCardMargin / cardViewHeight) * animatedValue;
setScaleX(scale);
setScaleY(scale);
setTranslationZ(mFocusTranslationZ * animatedValue);
+ if (mExtendViewOnFocus) {
+ ViewGroup.LayoutParams params = mTextView.getLayoutParams();
+ params.height = Math.round(mTextViewHeight
+ + (mExtendedTextViewHeight - mTextViewHeight) * animatedValue);
+ setTextViewLayoutParams(params);
+ setTextViewFocusedAlpha(animatedValue);
+ }
}
private void setFocusAnimatedValue(float animatedValue) {
@@ -171,8 +257,13 @@ public abstract class BaseCardView<T> extends LinearLayout implements ItemListRo
}
}
- /**
- * The implementation should return the height of the card.
- */
- protected abstract float getCardHeight();
+ private void setTextViewLayoutParams(ViewGroup.LayoutParams params) {
+ mTextViewFocused.setLayoutParams(params);
+ mTextView.setLayoutParams(params);
+ }
+
+ private void setTextViewFocusedAlpha(float focusedAlpha) {
+ mTextViewFocused.setAlpha(focusedAlpha);
+ mTextView.setAlpha(1f - focusedAlpha);
+ }
}
diff --git a/src/com/android/tv/menu/ChannelCardView.java b/src/com/android/tv/menu/ChannelCardView.java
index 860da224..1c8015a6 100644
--- a/src/com/android/tv/menu/ChannelCardView.java
+++ b/src/com/android/tv/menu/ChannelCardView.java
@@ -23,7 +23,6 @@ import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
-import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
@@ -42,10 +41,6 @@ public class ChannelCardView extends BaseCardView<Channel> {
private static final String TAG = MenuView.TAG;
private static final boolean DEBUG = MenuView.DEBUG;
- private final float mCardHeight;
- private final float mExtendedCardHeight;
- private final float mProgramNameViewHeight;
- private final float mExtendedTextViewCardHeight;
private final int mCardImageWidth;
private final int mCardImageHeight;
@@ -53,11 +48,8 @@ public class ChannelCardView extends BaseCardView<Channel> {
private View mGradientView;
private TextView mChannelNumberNameView;
private ProgressBar mProgressBar;
- private TextView mMetaViewFocused;
- private TextView mMetaViewUnfocused;
private Channel mChannel;
private Program mProgram;
- private boolean mExtendViewOnFocus;
private final MainActivity mMainActivity;
public ChannelCardView(Context context) {
@@ -70,17 +62,8 @@ public class ChannelCardView extends BaseCardView<Channel> {
public ChannelCardView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
-
mCardImageWidth = getResources().getDimensionPixelSize(R.dimen.card_image_layout_width);
mCardImageHeight = getResources().getDimensionPixelSize(R.dimen.card_image_layout_height);
- mCardHeight = getResources().getDimensionPixelSize(R.dimen.card_layout_height);
- mExtendedCardHeight = getResources().getDimensionPixelSize(
- R.dimen.card_layout_height_extended);
- mProgramNameViewHeight = getResources().getDimensionPixelSize(
- R.dimen.card_meta_layout_height);
- mExtendedTextViewCardHeight = getResources().getDimensionPixelOffset(
- R.dimen.card_meta_layout_height_extended);
-
mMainActivity = (MainActivity) context;
}
@@ -90,8 +73,6 @@ public class ChannelCardView extends BaseCardView<Channel> {
mImageView = (ImageView) findViewById(R.id.image);
mGradientView = findViewById(R.id.image_gradient);
mChannelNumberNameView = (TextView) findViewById(R.id.channel_number_and_name);
- mMetaViewFocused = (TextView) findViewById(R.id.channel_title_focused);
- mMetaViewUnfocused = (TextView) findViewById(R.id.channel_title_unfocused);
mProgressBar = (ProgressBar) findViewById(R.id.progress);
}
@@ -103,38 +84,25 @@ public class ChannelCardView extends BaseCardView<Channel> {
}
mChannel = channel;
mProgram = null;
- if (TextUtils.isEmpty(mChannel.getDisplayName())) {
- mChannelNumberNameView.setText(mChannel.getDisplayNumber());
- } else {
- mChannelNumberNameView.setText(mChannel.getDisplayNumber() + " "
- + mChannel.getDisplayName());
- }
+ mChannelNumberNameView.setText(mChannel.getDisplayText());
mChannelNumberNameView.setVisibility(VISIBLE);
mImageView.setImageResource(R.drawable.ic_recent_thumbnail_default);
mImageView.setBackgroundResource(R.color.channel_card);
mGradientView.setVisibility(View.GONE);
mProgressBar.setVisibility(GONE);
- setMetaViewEnabled(true);
+ setTextViewEnabled(true);
if (mMainActivity.getParentalControlSettings().isParentalControlsEnabled()
&& mChannel.isLocked()) {
- setMetaViewText(R.string.program_title_for_blocked_channel);
+ setText(R.string.program_title_for_blocked_channel);
return;
} else {
- setMetaViewText("");
+ setText("");
}
updateProgramInformation();
- mMetaViewFocused.measure(
- MeasureSpec.makeMeasureSpec(mCardImageWidth, MeasureSpec.EXACTLY),
- MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
- if (mExtendViewOnFocus = mMetaViewFocused.getLineCount() > 1) {
- setMetaViewFocusedAlpha(selected ? 1f : 0f);
- } else {
- setMetaViewFocusedAlpha(1f);
- }
-
- // Call super.onBind() at the end in order to make getCardHeight() return a proper value.
+ // Call super.onBind() at the end intentionally. In order to correctly handle extension of
+ // text view, text should be set before calling super.onBind.
super.onBind(channel, selected);
}
@@ -158,40 +126,16 @@ public class ChannelCardView extends BaseCardView<Channel> {
mGradientView.setVisibility(View.VISIBLE);
}
- @Override
- protected void onFocusAnimationStart(boolean selected) {
- if (mExtendViewOnFocus) {
- setMetaViewFocusedAlpha(selected ? 1f : 0f);
- }
- }
-
- @Override
- protected void onSetFocusAnimatedValue(float animatedValue) {
- super.onSetFocusAnimatedValue(animatedValue);
- if (mExtendViewOnFocus) {
- ViewGroup.LayoutParams params = mMetaViewUnfocused.getLayoutParams();
- params.height = Math.round(mProgramNameViewHeight
- + (mExtendedTextViewCardHeight - mProgramNameViewHeight) * animatedValue);
- setMetaViewLayoutParams(params);
- setMetaViewFocusedAlpha(animatedValue);
- }
- }
-
- @Override
- protected float getCardHeight() {
- return (mExtendViewOnFocus && isFocused()) ? mExtendedCardHeight : mCardHeight;
- }
-
private void updateProgramInformation() {
if (mChannel == null) {
return;
}
mProgram = mMainActivity.getProgramDataManager().getCurrentProgram(mChannel.getId());
if (mProgram == null || TextUtils.isEmpty(mProgram.getTitle())) {
- setMetaViewEnabled(false);
- setMetaViewText(R.string.program_title_for_no_information);
+ setTextViewEnabled(false);
+ setText(R.string.program_title_for_no_information);
} else {
- setMetaViewText(mProgram.getTitle());
+ setText(mProgram.getTitle());
}
if (mProgram == null) {
@@ -222,29 +166,4 @@ public class ChannelCardView extends BaseCardView<Channel> {
createProgramPosterArtCallback(this, mProgram));
}
}
-
- private void setMetaViewLayoutParams(ViewGroup.LayoutParams params) {
- mMetaViewFocused.setLayoutParams(params);
- mMetaViewUnfocused.setLayoutParams(params);
- }
-
- private void setMetaViewText(String text) {
- mMetaViewFocused.setText(text);
- mMetaViewUnfocused.setText(text);
- }
-
- private void setMetaViewText(int resId) {
- mMetaViewFocused.setText(resId);
- mMetaViewUnfocused.setText(resId);
- }
-
- private void setMetaViewEnabled(boolean enabled) {
- mMetaViewFocused.setEnabled(enabled);
- mMetaViewUnfocused.setEnabled(enabled);
- }
-
- private void setMetaViewFocusedAlpha(float focusedAlpha) {
- mMetaViewFocused.setAlpha(focusedAlpha);
- mMetaViewUnfocused.setAlpha(1f - focusedAlpha);
- }
}
diff --git a/src/com/android/tv/menu/ChannelsRowAdapter.java b/src/com/android/tv/menu/ChannelsRowAdapter.java
index 200f4ac0..c8e1bd05 100644
--- a/src/com/android/tv/menu/ChannelsRowAdapter.java
+++ b/src/com/android/tv/menu/ChannelsRowAdapter.java
@@ -19,16 +19,15 @@ package com.android.tv.menu;
import android.content.Context;
import android.content.Intent;
import android.media.tv.TvInputInfo;
-import android.os.Build;
-import android.support.v4.os.BuildCompat;
import android.view.View;
-import com.android.tv.MainActivity;
+import com.android.tv.ApplicationSingletons;
import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.analytics.Tracker;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.data.Channel;
+import com.android.tv.dvr.DvrDataManager;
import com.android.tv.recommendation.Recommender;
import com.android.tv.util.SetupUtils;
import com.android.tv.util.TvInputManagerHelper;
@@ -41,15 +40,17 @@ import java.util.List;
* An adapter of the Channels row.
*/
public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel> {
- // There are four special cards: guide, setup, dvr, record, applink.
+ private static final String TAG = "ChannelsRowAdapter";
+
+ // There are four special cards: guide, setup, dvr, applink.
private static final int SIZE_OF_VIEW_TYPE = 5;
private final Context mContext;
private final Tracker mTracker;
private final Recommender mRecommender;
+ private final DvrDataManager mDvrDataManager;
private final int mMaxCount;
private final int mMinCount;
- private final boolean mDvrFeatureEnabled;
private final int[] mViewType = new int[SIZE_OF_VIEW_TYPE];
private final View.OnClickListener mGuideOnClickListener = new View.OnClickListener() {
@@ -71,28 +72,11 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
private final View.OnClickListener mDvrOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
- Utils.showToastMessageForDeveloperFeature(view.getContext());
mTracker.sendMenuClicked(R.string.channels_item_dvr);
getMainActivity().getOverlayManager().showDvrManager();
}
};
- private final View.OnClickListener mRecordOnClickListener = new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- Utils.showToastMessageForDeveloperFeature(view.getContext());
- RecordCardView v = ((RecordCardView) view);
- boolean isRecording = v.isRecording();
- mTracker.sendMenuClicked(isRecording ? R.string.channels_item_record_start
- : R.string.channels_item_record_stop);
- if (!isRecording) {
- v.startRecording();
- } else {
- v.stopRecording();
- }
- }
- };
-
private final View.OnClickListener mAppLinkOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
@@ -118,12 +102,17 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
public ChannelsRowAdapter(Context context, Recommender recommender,
int minCount, int maxCount) {
super(context);
- mTracker = TvApplication.getSingletons(context).getTracker();
mContext = context;
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ mTracker = appSingletons.getTracker();
+ if (CommonFeatures.DVR.isEnabled(context)) {
+ mDvrDataManager = appSingletons.getDvrDataManager();
+ } else {
+ mDvrDataManager = null;
+ }
mRecommender = recommender;
mMinCount = minCount;
mMaxCount = maxCount;
- mDvrFeatureEnabled = CommonFeatures.DVR.isEnabled(mContext) && BuildCompat.isAtLeastN();
}
@Override
@@ -152,8 +141,8 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
viewHolder.itemView.setOnClickListener(mAppLinkOnClickListener);
} else if (viewType == R.layout.menu_card_dvr) {
viewHolder.itemView.setOnClickListener(mDvrOnClickListener);
- } else if (viewType == R.layout.menu_card_record) {
- viewHolder.itemView.setOnClickListener(mRecordOnClickListener);
+ SimpleCardView view = (SimpleCardView) viewHolder.itemView;
+ view.setText(R.string.channels_item_dvr);
} else {
viewHolder.itemView.setTag(getItemList().get(position));
viewHolder.itemView.setOnClickListener(mChannelOnClickListener);
@@ -170,25 +159,19 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
TvInputManagerHelper inputManager = TvApplication.getSingletons(mContext)
.getTvInputManagerHelper();
boolean showSetupCard = SetupUtils.getInstance(mContext).hasNewInput(inputManager);
- Channel currentChannel = ((MainActivity) mContext).getCurrentChannel();
- boolean showAppLinkCard = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
- && currentChannel != null
- && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE;
+ Channel currentChannel = getMainActivity().getCurrentChannel();
+ boolean showAppLinkCard = currentChannel != null
+ && currentChannel.getAppLinkType(mContext) != Channel.APP_LINK_TYPE_NONE
+ // Sometimes applicationInfo can be null. b/28932537
+ && inputManager.getTvInputAppInfo(currentChannel.getInputId()) != null;
boolean showDvrCard = false;
- boolean showRecordCard = false;
- if (mDvrFeatureEnabled) {
+ if (mDvrDataManager != null) {
for (TvInputInfo info : inputManager.getTvInputInfos(true, true)) {
if (info.canRecord()) {
showDvrCard = true;
break;
}
}
- if (currentChannel != null && currentChannel.getInputId() != null) {
- TvInputInfo inputInfo = inputManager.getTvInputInfo(currentChannel.getInputId());
- if ((inputInfo.canRecord() && inputInfo.getTunerCount() > 1)) {
- showRecordCard = true;
- }
- }
}
mViewType[0] = R.layout.menu_card_guide;
@@ -201,10 +184,6 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
channelList.add(dummyChannel);
mViewType[index++] = R.layout.menu_card_dvr;
}
- if (showRecordCard) {
- channelList.add(currentChannel);
- mViewType[index++] = R.layout.menu_card_record;
- }
if (showAppLinkCard) {
channelList.add(currentChannel);
mViewType[index++] = R.layout.menu_card_app_link;
@@ -226,8 +205,8 @@ public class ChannelsRowAdapter extends ItemListRowView.ItemListAdapter<Channel>
int count = channelList.size();
// If the number of recommended channels is not enough, add more from the recent channel
// list.
- if (count < mMinCount && mContext instanceof MainActivity) {
- for (long channelId : ((MainActivity) mContext).getRecentChannels()) {
+ if (count < mMinCount) {
+ for (long channelId : getMainActivity().getRecentChannels()) {
Channel channel = mRecommender.getChannel(channelId);
if (channel == null || channelList.contains(channel)
|| !channel.isBrowsable()) {
diff --git a/src/com/android/tv/menu/IMenuView.java b/src/com/android/tv/menu/IMenuView.java
index 99fb4126..87c5d9f6 100644
--- a/src/com/android/tv/menu/IMenuView.java
+++ b/src/com/android/tv/menu/IMenuView.java
@@ -54,6 +54,13 @@ public interface IMenuView {
boolean update(boolean menuActive);
/**
+ * Updates the menu row.
+ *
+ * <p>Returns <@code true> if the contents have been changed, otherwise {@code false}.
+ */
+ boolean update(String rowId, boolean menuActive);
+
+ /**
* Checks if the menu view is visible or not.
*/
boolean isVisible();
diff --git a/src/com/android/tv/menu/Menu.java b/src/com/android/tv/menu/Menu.java
index 7bb0787e..1160a5b5 100644
--- a/src/com/android/tv/menu/Menu.java
+++ b/src/com/android/tv/menu/Menu.java
@@ -35,10 +35,10 @@ import com.android.tv.analytics.DurationTimer;
import com.android.tv.analytics.Tracker;
import com.android.tv.common.TvCommonUtils;
import com.android.tv.common.WeakHandler;
-import com.android.tv.data.Channel;
import com.android.tv.menu.MenuRowFactory.PartnerRow;
import com.android.tv.menu.MenuRowFactory.PipOptionsRow;
import com.android.tv.menu.MenuRowFactory.TvOptionsRow;
+import com.android.tv.ui.TunableTvView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -56,7 +56,7 @@ public class Menu {
@IntDef({REASON_NONE, REASON_GUIDE, REASON_PLAY_CONTROLS_PLAY, REASON_PLAY_CONTROLS_PAUSE,
REASON_PLAY_CONTROLS_PLAY_PAUSE, REASON_PLAY_CONTROLS_REWIND,
REASON_PLAY_CONTROLS_FAST_FORWARD, REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS,
- REASON_PLAY_CONTROLS_JUMP_TO_NEXT, REASON_RECORDING_PLAYBACK})
+ REASON_PLAY_CONTROLS_JUMP_TO_NEXT})
public @interface MenuShowReason {}
public static final int REASON_NONE = 0;
public static final int REASON_GUIDE = 1;
@@ -67,20 +67,18 @@ public class Menu {
public static final int REASON_PLAY_CONTROLS_FAST_FORWARD = 6;
public static final int REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS = 7;
public static final int REASON_PLAY_CONTROLS_JUMP_TO_NEXT = 8;
- public static final int REASON_RECORDING_PLAYBACK = 9;
private static final List<String> sRowIdListForReason = new ArrayList<>();
static {
- sRowIdListForReason.add(null); // REASON_NONE
- sRowIdListForReason.add(ChannelsRow.ID); // REASON_GUIDE
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PAUSE
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY_PAUSE
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_REWIND
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_FAST_FORWARD
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_NEXT
- sRowIdListForReason.add(PlayControlsRow.ID); // REASON_RECORDING_PLAYBACK
+ sRowIdListForReason.add(null); // REASON_NONE
+ sRowIdListForReason.add(ChannelsRow.ID); // REASON_GUIDE
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PAUSE
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_PLAY_PAUSE
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_REWIND
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_FAST_FORWARD
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS
+ sRowIdListForReason.add(PlayControlsRow.ID); // REASON_PLAY_CONTROLS_JUMP_TO_NEXT
}
private static final String SCREEN_NAME = "Menu";
@@ -94,37 +92,26 @@ public class Menu {
private final OnMenuVisibilityChangeListener mOnMenuVisibilityChangeListener;
private final WeakHandler<Menu> mHandler = new MenuWeakHandler(this, Looper.getMainLooper());
- private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() {
- @Override
- public void onLoadFinished() {}
-
- @Override
- public void onBrowsableChannelListChanged() {
- mMenuView.update(isActive());
- }
-
- @Override
- public void onCurrentChannelUnavailable(Channel channel) {}
-
- @Override
- public void onChannelChanged(Channel previousChannel, Channel currentChannel) {}
- };
-
+ private final MenuUpdater mMenuUpdater;
private final List<MenuRow> mMenuRows = new ArrayList<>();
private final Animator mShowAnimator;
private final Animator mHideAnimator;
- private ChannelTuner mChannelTuner;
private boolean mKeepVisible;
private boolean mAnimationDisabledForTest;
- /**
- * A constructor.
- */
- public Menu(Context context, IMenuView menuView, MenuRowFactory menuRowFactory,
- OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) {
+ @VisibleForTesting
+ Menu(Context context, IMenuView menuView, MenuRowFactory menuRowFactory,
+ OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) {
+ this(context, null, menuView, menuRowFactory, onMenuVisibilityChangeListener);
+ }
+
+ public Menu(Context context, TunableTvView tvView, IMenuView menuView,
+ MenuRowFactory menuRowFactory,
+ OnMenuVisibilityChangeListener onMenuVisibilityChangeListener) {
mMenuView = menuView;
mTracker = TvApplication.getSingletons(context).getTracker();
+ mMenuUpdater = new MenuUpdater(context, tvView, this);
Resources res = context.getResources();
mShowDurationMillis = res.getInteger(R.integer.menu_show_duration);
mOnMenuVisibilityChangeListener = onMenuVisibilityChangeListener;
@@ -152,14 +139,7 @@ public class Menu {
* or not available any more.
*/
public void setChannelTuner(ChannelTuner channelTuner) {
- if (mChannelTuner != null) {
- mChannelTuner.removeListener(mChannelTunerListener);
- }
- mChannelTuner = channelTuner;
- if (mChannelTuner != null) {
- mChannelTuner.addListener(mChannelTunerListener);
- }
- mMenuView.update(isActive());
+ mMenuUpdater.setChannelTuner(channelTuner);
}
private void addMenuRow(MenuRow row) {
@@ -172,7 +152,7 @@ public class Menu {
* Call this method to end the lifetime of the menu.
*/
public void release() {
- setChannelTuner(null);
+ mMenuUpdater.release();
for (MenuRow row : mMenuRows) {
row.release();
}
@@ -199,7 +179,9 @@ public class Menu {
mMenuView.onShow(reason, rowIdToSelect, mAnimationDisabledForTest ? null : new Runnable() {
@Override
public void run() {
- mShowAnimator.start();
+ if (isActive()) {
+ mShowAnimator.start();
+ }
}
});
scheduleHide();
@@ -209,6 +191,9 @@ public class Menu {
* Closes the menu.
*/
public void hide(boolean withAnimation) {
+ if (mShowAnimator.isStarted()) {
+ mShowAnimator.cancel();
+ }
if (!isActive()) {
return;
}
@@ -284,6 +269,16 @@ public class Menu {
}
/**
+ * Updates the menu row.
+ *
+ * <p>Returns <@code true> if the contents have been changed, otherwise {@code false}.
+ */
+ public boolean update(String rowId) {
+ if (DEBUG) Log.d(TAG, "update main menu");
+ return mMenuView.update(rowId, isActive());
+ }
+
+ /**
* This method is called when channels are changed.
*/
public void onRecentChannelsChanged() {
diff --git a/src/com/android/tv/menu/MenuAction.java b/src/com/android/tv/menu/MenuAction.java
index 86153084..0d59552a 100644
--- a/src/com/android/tv/menu/MenuAction.java
+++ b/src/com/android/tv/menu/MenuAction.java
@@ -41,13 +41,16 @@ public class MenuAction {
R.drawable.ic_tvoption_pip);
public static final MenuAction SYSTEMWIDE_PIP_ACTION =
new MenuAction(R.string.options_item_pip, TvOptionsManager.OPTION_SYSTEMWIDE_PIP,
- R.drawable.ic_tvoption_pip);
+ R.drawable.ic_pip_option_layout2);
public static final MenuAction SELECT_AUDIO_LANGUAGE_ACTION =
new MenuAction(R.string.options_item_multi_audio, TvOptionsManager.OPTION_MULTI_AUDIO,
R.drawable.ic_tvoption_multi_track);
public static final MenuAction MORE_CHANNELS_ACTION =
new MenuAction(R.string.options_item_more_channels,
TvOptionsManager.OPTION_MORE_CHANNELS, R.drawable.ic_store);
+ public static final MenuAction DEV_ACTION =
+ new MenuAction(R.string.options_item_developer,
+ TvOptionsManager.OPTION_DEVELOPER, R.drawable.ic_developer_mode_tv_white_48dp);
// TODO: Change the icon.
public static final MenuAction SETTINGS_ACTION =
new MenuAction(R.string.options_item_settings, TvOptionsManager.OPTION_SETTINGS,
diff --git a/src/com/android/tv/menu/MenuLayoutManager.java b/src/com/android/tv/menu/MenuLayoutManager.java
index 1f377f54..6c767247 100644
--- a/src/com/android/tv/menu/MenuLayoutManager.java
+++ b/src/com/android/tv/menu/MenuLayoutManager.java
@@ -219,8 +219,8 @@ public class MenuLayoutManager {
* @param bottom The bottom coordinate of the menu view.
*/
private List<Rect> getViewLayouts(int left, int top, int right, int bottom) {
- return getViewLayouts(left, top, right, bottom, Collections.<Integer>emptyList(),
- Collections.<Integer>emptyList());
+ return getViewLayouts(left, top, right, bottom, Collections.emptyList(),
+ Collections.emptyList());
}
/**
diff --git a/src/com/android/tv/menu/MenuRow.java b/src/com/android/tv/menu/MenuRow.java
index fe73edd2..6f98e615 100644
--- a/src/com/android/tv/menu/MenuRow.java
+++ b/src/com/android/tv/menu/MenuRow.java
@@ -17,6 +17,7 @@
package com.android.tv.menu;
import android.content.Context;
+import android.view.View;
/**
* A base class of the item which will be displayed in the main menu.
@@ -30,6 +31,8 @@ public abstract class MenuRow {
private final int mHeight;
private final Menu mMenu;
+ private MenuRowView mMenuRowView;
+
// TODO: Check if the heightResId is really necessary.
public MenuRow(Context context, Menu menu, int titleResId, int heightResId) {
this(context, menu, context.getString(titleResId), heightResId);
@@ -71,6 +74,20 @@ public abstract class MenuRow {
}
/**
+ * Sets the menu row view.
+ */
+ public void setMenuRowView(MenuRowView menuRowView) {
+ mMenuRowView = menuRowView;
+ }
+
+ /**
+ * Returns the menu row view.
+ */
+ protected MenuRowView getMenuRowView() {
+ return mMenuRowView;
+ }
+
+ /**
* Updates the contents in this row.
* This method is called only by the menu when necessary.
*/
diff --git a/src/com/android/tv/menu/MenuRowFactory.java b/src/com/android/tv/menu/MenuRowFactory.java
index b0b000f1..c67a0e04 100644
--- a/src/com/android/tv/menu/MenuRowFactory.java
+++ b/src/com/android/tv/menu/MenuRowFactory.java
@@ -24,6 +24,7 @@ import com.android.tv.MainActivity;
import com.android.tv.R;
import com.android.tv.customization.CustomAction;
import com.android.tv.customization.TvCustomizationManager;
+import com.android.tv.ui.TunableTvView;
import java.util.List;
@@ -32,13 +33,15 @@ import java.util.List;
*/
public class MenuRowFactory {
private final MainActivity mMainActivity;
+ private final TunableTvView mTvView;
private final TvCustomizationManager mTvCustomizationManager;
/**
* A constructor.
*/
- public MenuRowFactory(MainActivity mainActivity) {
+ public MenuRowFactory(MainActivity mainActivity, TunableTvView tvView) {
mMainActivity = mainActivity;
+ mTvView = tvView;
mTvCustomizationManager = new TvCustomizationManager(mainActivity);
mTvCustomizationManager.initialize();
}
@@ -49,7 +52,8 @@ public class MenuRowFactory {
@Nullable
public MenuRow createMenuRow(Menu menu, Class<?> key) {
if (PlayControlsRow.class.equals(key)) {
- return new PlayControlsRow(mMainActivity, menu, mMainActivity.getTimeShiftManager());
+ return new PlayControlsRow(mMainActivity, mTvView, menu,
+ mMainActivity.getTimeShiftManager());
} else if (ChannelsRow.class.equals(key)) {
return new ChannelsRow(mMainActivity, menu, mMainActivity.getProgramDataManager());
} else if (PartnerRow.class.equals(key)) {
diff --git a/src/com/android/tv/menu/MenuRowView.java b/src/com/android/tv/menu/MenuRowView.java
index 7cdbfe9e..97dea29a 100644
--- a/src/com/android/tv/menu/MenuRowView.java
+++ b/src/com/android/tv/menu/MenuRowView.java
@@ -35,25 +35,6 @@ public abstract class MenuRowView extends LinearLayout {
private static final String TAG = "MenuRowView";
private static final boolean DEBUG = false;
- /**
- * For setting ListView visible, and TitleView visible with the selected text size and color
- * without animation.
- */
- public static final int ANIM_NONE_SELECTED = 1;
- /**
- * For setting ListView gone, and TitleView visible with the deselected text size and color
- * without animation.
- */
- public static final int ANIM_NONE_DESELECTED = 2;
- /**
- * An animation for the selected item list view.
- */
- public static final int ANIM_SELECTED = 3;
- /**
- * An animation for the deselected item list view.
- */
- public static final int ANIM_DESELECTED = 4;
-
private TextView mTitleView;
private View mContentsView;
diff --git a/src/com/android/tv/menu/MenuUpdater.java b/src/com/android/tv/menu/MenuUpdater.java
new file mode 100644
index 00000000..075b299e
--- /dev/null
+++ b/src/com/android/tv/menu/MenuUpdater.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.menu;
+
+import android.content.Context;
+import android.support.annotation.Nullable;
+
+import com.android.tv.ChannelTuner;
+import com.android.tv.data.Channel;
+import com.android.tv.ui.TunableTvView;
+import com.android.tv.ui.TunableTvView.OnScreenBlockingChangedListener;
+
+/**
+ * Update menu items when needed.
+ *
+ * <p>As the menu is updated when it shows up, this class handles only the dynamic updates.
+ */
+public class MenuUpdater {
+ // Can be null for testing.
+ @Nullable
+ private final TunableTvView mTvView;
+ private final Menu mMenu;
+ private ChannelTuner mChannelTuner;
+
+ private final ChannelTuner.Listener mChannelTunerListener = new ChannelTuner.Listener() {
+ @Override
+ public void onLoadFinished() {}
+
+ @Override
+ public void onBrowsableChannelListChanged() {
+ mMenu.update();
+ }
+
+ @Override
+ public void onCurrentChannelUnavailable(Channel channel) {}
+
+ @Override
+ public void onChannelChanged(Channel previousChannel, Channel currentChannel) {
+ mMenu.update(ChannelsRow.ID);
+ }
+ };
+
+ public MenuUpdater(Context context, TunableTvView tvView, Menu menu) {
+ mTvView = tvView;
+ mMenu = menu;
+ if (mTvView != null) {
+ mTvView.setOnScreenBlockedListener(new OnScreenBlockingChangedListener() {
+ @Override
+ public void onScreenBlockingChanged(boolean blocked) {
+ mMenu.update(PlayControlsRow.ID);
+ }
+ });
+ }
+ }
+
+ /**
+ * Sets the instance of {@link ChannelTuner}. Call this method when the channel tuner is ready
+ * or not available any more.
+ */
+ public void setChannelTuner(ChannelTuner channelTuner) {
+ if (mChannelTuner != null) {
+ mChannelTuner.removeListener(mChannelTunerListener);
+ }
+ mChannelTuner = channelTuner;
+ if (mChannelTuner != null) {
+ mChannelTuner.addListener(mChannelTunerListener);
+ }
+ mMenu.update();
+ }
+
+ /**
+ * Called at the end of the menu's lifetime.
+ */
+ public void release() {
+ if (mChannelTuner != null) {
+ mChannelTuner.removeListener(mChannelTunerListener);
+ }
+ if (mTvView != null) {
+ mTvView.setOnScreenBlockedListener(null);
+ }
+ }
+}
diff --git a/src/com/android/tv/menu/MenuView.java b/src/com/android/tv/menu/MenuView.java
index e012dfca..ee0b036e 100644
--- a/src/com/android/tv/menu/MenuView.java
+++ b/src/com/android/tv/menu/MenuView.java
@@ -24,6 +24,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewParent;
import android.view.ViewTreeObserver.OnGlobalFocusChangeListener;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.FrameLayout;
import com.android.tv.menu.Menu.MenuShowReason;
@@ -57,6 +58,8 @@ public class MenuView extends FrameLayout implements IMenuView {
public MenuView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mLayoutInflater = LayoutInflater.from(context);
+ // Set hardware layer type for smooth animation of lots of views.
+ setLayerType(LAYER_TYPE_HARDWARE, null);
getViewTreeObserver().addOnGlobalFocusChangeListener(new OnGlobalFocusChangeListener() {
@Override
public void onGlobalFocusChanged(View oldFocus, View newFocus) {
@@ -89,6 +92,7 @@ public class MenuView extends FrameLayout implements IMenuView {
private MenuRowView createMenuRowView(MenuRow row) {
MenuRowView view = (MenuRowView) mLayoutInflater.inflate(row.getLayoutResId(), this, false);
view.onBind(row);
+ row.setMenuRowView(view);
return view;
}
@@ -128,7 +132,15 @@ public class MenuView extends FrameLayout implements IMenuView {
// Make the selected row have the focus.
requestFocus();
if (runnableAfterShow != null) {
- runnableAfterShow.run();
+ getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ // Start show animation after layout finishes for smooth animation because the
+ // layout can take long time.
+ runnableAfterShow.run();
+ }
+ });
}
mLayoutManager.onMenuShow();
}
@@ -160,6 +172,19 @@ public class MenuView extends FrameLayout implements IMenuView {
}
@Override
+ public boolean update(String rowId, boolean menuActive) {
+ if (menuActive) {
+ MenuRow row = getMenuRow(rowId);
+ if (row != null) {
+ row.update();
+ mLayoutManager.onMenuRowUpdated();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
int selectedPosition = mLayoutManager.getSelectedPosition();
// When the menu shows up, the selected row should have focus.
@@ -169,6 +194,16 @@ public class MenuView extends FrameLayout implements IMenuView {
return super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
}
+ @Override
+ public void focusableViewAvailable(View v) {
+ // Workaround of b/30788222 and b/32074688.
+ // The re-layout of RecyclerView gives the focus to the card view even when the menu is not
+ // visible. Don't report focusable view when the menu is not visible.
+ if (getVisibility() == VISIBLE) {
+ super.focusableViewAvailable(v);
+ }
+ }
+
private void setSelectedPosition(int position) {
mLayoutManager.setSelectedPosition(position);
}
@@ -183,6 +218,15 @@ public class MenuView extends FrameLayout implements IMenuView {
}
}
+ private MenuRow getMenuRow(String rowId) {
+ for (MenuRow item : mMenuRows) {
+ if (rowId.equals(item.getId())) {
+ return item;
+ }
+ }
+ return null;
+ }
+
private int getItemPosition(String rowIdToSelect) {
if (rowIdToSelect == null) {
return -1;
diff --git a/src/com/android/tv/menu/PlayControlsButton.java b/src/com/android/tv/menu/PlayControlsButton.java
index 957f2e94..aff39db3 100644
--- a/src/com/android/tv/menu/PlayControlsButton.java
+++ b/src/com/android/tv/menu/PlayControlsButton.java
@@ -16,6 +16,8 @@
package com.android.tv.menu;
+import android.R.integer;
+import android.animation.ValueAnimator;
import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
@@ -33,6 +35,9 @@ public class PlayControlsButton extends FrameLayout {
private final ImageView mButton;
private final ImageView mIcon;
private final TextView mLabel;
+ private final long mFocusAnimationTimeMs;
+ private final int mIconColor;
+ private int mIconFocusedColor;
public PlayControlsButton(Context context) {
this(context, null);
@@ -53,6 +58,9 @@ public class PlayControlsButton extends FrameLayout {
mButton = (ImageView) findViewById(R.id.button);
mIcon = (ImageView) findViewById(R.id.icon);
mLabel = (TextView) findViewById(R.id.label);
+ mFocusAnimationTimeMs = context.getResources().getInteger(integer.config_shortAnimTime);
+ mIconColor = context.getResources().getColor(R.color.play_controls_icon_color);
+ mIconFocusedColor = mIconColor;
}
/**
@@ -60,6 +68,9 @@ public class PlayControlsButton extends FrameLayout {
*/
public void setImageResId(int imageResId) {
mIcon.setImageResource(imageResId);
+ // Since on foucus changing, icons' color should be switched with animation,
+ // as a result, selectors cannot be used to switch colors in this case.
+ mIcon.getDrawable().setTint(hasFocus() ? mIconFocusedColor : mIconColor);
}
/**
@@ -74,6 +85,31 @@ public class PlayControlsButton extends FrameLayout {
});
}
+ /**
+ * Sets the icon's color should change to when the button is on focus.
+ */
+ public void setFocusedIconColor(int color) {
+ final ValueAnimator valueAnimator = ValueAnimator.ofArgb(mIconColor, color);
+ valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(final ValueAnimator animator) {
+ mIcon.getDrawable().setTint((int) animator.getAnimatedValue());
+ }
+ });
+ valueAnimator.setDuration(mFocusAnimationTimeMs);
+ mButton.setOnFocusChangeListener(new View.OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(View v, boolean hasFocus) {
+ if (hasFocus) {
+ valueAnimator.start();
+ } else {
+ valueAnimator.reverse();
+ }
+ }
+ });
+ mIconFocusedColor = color;
+ }
+
public void setLabel(String label) {
if (TextUtils.isEmpty(label)) {
mIcon.setVisibility(View.VISIBLE);
@@ -93,6 +129,7 @@ public class PlayControlsButton extends FrameLayout {
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
mButton.setEnabled(enabled);
+ mButton.setFocusable(enabled);
mIcon.setEnabled(enabled);
mIcon.setAlpha(enabled ? ALPHA_ENABLED : ALPHA_DISABLED);
mLabel.setEnabled(enabled);
diff --git a/src/com/android/tv/menu/PlayControlsRow.java b/src/com/android/tv/menu/PlayControlsRow.java
index 588ecf6a..a60ff153 100644
--- a/src/com/android/tv/menu/PlayControlsRow.java
+++ b/src/com/android/tv/menu/PlayControlsRow.java
@@ -20,19 +20,24 @@ import android.content.Context;
import com.android.tv.R;
import com.android.tv.TimeShiftManager;
+import com.android.tv.ui.TunableTvView;
public class PlayControlsRow extends MenuRow {
public static final String ID = PlayControlsRow.class.getName();
+ private final TunableTvView mTvView;
private final TimeShiftManager mTimeShiftManager;
- public PlayControlsRow(Context context, Menu menu, TimeShiftManager timeShiftManager) {
+ public PlayControlsRow(Context context, TunableTvView tvView, Menu menu,
+ TimeShiftManager timeShiftManager) {
super(context, menu, R.string.menu_title_play_controls, R.dimen.play_controls_height);
+ mTvView = tvView;
mTimeShiftManager = timeShiftManager;
}
@Override
public void update() {
+ ((PlayControlsRowView) getMenuRowView()).update();
}
@Override
@@ -41,6 +46,13 @@ public class PlayControlsRow extends MenuRow {
}
/**
+ * Returns TV view.
+ */
+ public TunableTvView getTvView() {
+ return mTvView;
+ }
+
+ /**
* Returns an instance of {@link TimeShiftManager}.
*/
public TimeShiftManager getTimeShiftManager() {
diff --git a/src/com/android/tv/menu/PlayControlsRowView.java b/src/com/android/tv/menu/PlayControlsRowView.java
index 058d5108..a620d4dd 100644
--- a/src/com/android/tv/menu/PlayControlsRowView.java
+++ b/src/com/android/tv/menu/PlayControlsRowView.java
@@ -19,20 +19,35 @@ package com.android.tv.menu;
import android.content.Context;
import android.content.res.Resources;
import android.text.format.DateFormat;
-import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
+import android.widget.Toast;
+import com.android.tv.MainActivity;
import com.android.tv.R;
import com.android.tv.TimeShiftManager;
import com.android.tv.TimeShiftManager.TimeShiftActionId;
+import com.android.tv.TvApplication;
import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.data.Channel;
import com.android.tv.data.Program;
+import com.android.tv.dvr.DvrDataManager;
+import com.android.tv.dvr.DvrDataManager.OnDvrScheduleLoadFinishedListener;
+import com.android.tv.dvr.DvrDataManager.ScheduledRecordingListener;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.DvrUiHelper;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.dvr.ui.DvrStopRecordingFragment;
+import com.android.tv.dvr.ui.HalfSizedDialogFragment;
import com.android.tv.menu.Menu.MenuShowReason;
+import com.android.tv.ui.TunableTvView;
+import com.android.tv.util.Utils;
public class PlayControlsRowView extends MenuRowView {
+ private static final int NORMAL_WIDTH_MAX_BUTTON_COUNT = 5;
// Dimensions
private final int mTimeIndicatorLeftMargin;
private final int mTimeTextLeftMargin;
@@ -51,14 +66,44 @@ public class PlayControlsRowView extends MenuRowView {
private PlayControlsButton mPlayPauseButton;
private PlayControlsButton mFastForwardButton;
private PlayControlsButton mJumpNextButton;
+ private PlayControlsButton mRecordButton;
private TextView mProgramStartTimeText;
private TextView mProgramEndTimeText;
private View mUnavailableMessageText;
+ private TunableTvView mTvView;
private TimeShiftManager mTimeShiftManager;
+ private final DvrDataManager mDvrDataManager;
+ private final DvrManager mDvrManager;
+ private final MainActivity mMainActivity;
private final java.text.DateFormat mTimeFormat;
private long mProgramStartTimeMs;
private long mProgramEndTimeMs;
+ private boolean mUseCompactLayout;
+ private final int mNormalButtonMargin;
+ private final int mCompactButtonMargin;
+
+ private final ScheduledRecordingListener mScheduledRecordingListener
+ = new ScheduledRecordingListener() {
+ @Override
+ public void onScheduledRecordingAdded(ScheduledRecording... scheduledRecordings) { }
+
+ @Override
+ public void onScheduledRecordingRemoved(ScheduledRecording... scheduledRecordings) { }
+
+ @Override
+ public void onScheduledRecordingStatusChanged(ScheduledRecording... scheduledRecordings) {
+ Channel currentChannel = mMainActivity.getCurrentChannel();
+ if (currentChannel != null && isShown()) {
+ for (ScheduledRecording schedule : scheduledRecordings) {
+ if (schedule.getChannelId() == currentChannel.getId()) {
+ updateRecordButton();
+ break;
+ }
+ }
+ }
+ }
+ };
public PlayControlsRowView(Context context) {
this(context, null);
@@ -82,6 +127,38 @@ public class PlayControlsRowView extends MenuRowView {
- res.getDimensionPixelOffset(R.dimen.play_controls_time_width) / 2;
mTimelineWidth = res.getDimensionPixelSize(R.dimen.play_controls_width);
mTimeFormat = DateFormat.getTimeFormat(context);
+ mNormalButtonMargin = res.getDimensionPixelSize(R.dimen.play_controls_button_normal_margin);
+ mCompactButtonMargin =
+ res.getDimensionPixelSize(R.dimen.play_controls_button_compact_margin);
+ if (CommonFeatures.DVR.isEnabled(context)) {
+ mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
+ mDvrManager = TvApplication.getSingletons(context).getDvrManager();
+ } else {
+ mDvrDataManager = null;
+ mDvrManager = null;
+ }
+ mMainActivity = (MainActivity) context;
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ if (mDvrDataManager != null) {
+ mDvrDataManager.addScheduledRecordingListener(mScheduledRecordingListener);
+ if (!mDvrDataManager.isDvrScheduleLoadFinished()) {
+ mDvrDataManager.addDvrScheduleLoadFinishedListener(
+ new OnDvrScheduleLoadFinishedListener() {
+ @Override
+ public void onDvrScheduleLoadFinished() {
+ mDvrDataManager.removeDvrScheduleLoadFinishedListener(this);
+ if (isShown()) {
+ updateRecordButton();
+ }
+ }
+ });
+ }
+
+ }
}
@Override
@@ -107,22 +184,23 @@ public class PlayControlsRowView extends MenuRowView {
mPlayPauseButton = (PlayControlsButton) findViewById(R.id.play_pause);
mFastForwardButton = (PlayControlsButton) findViewById(R.id.fast_forward);
mJumpNextButton = (PlayControlsButton) findViewById(R.id.jump_next);
+ mRecordButton = (PlayControlsButton) findViewById(R.id.record);
mProgramStartTimeText = (TextView) findViewById(R.id.program_start_time);
mProgramEndTimeText = (TextView) findViewById(R.id.program_end_time);
mUnavailableMessageText = findViewById(R.id.unavailable_text);
initializeButton(mJumpPreviousButton, R.drawable.lb_ic_skip_previous,
- R.string.play_controls_description_skip_previous, new Runnable() {
+ R.string.play_controls_description_skip_previous, null, new Runnable() {
@Override
public void run() {
if (mTimeShiftManager.isAvailable()) {
mTimeShiftManager.jumpToPrevious();
- updateAll();
+ updateControls();
}
}
});
initializeButton(mRewindButton, R.drawable.lb_ic_fast_rewind,
- R.string.play_controls_description_fast_rewind, new Runnable() {
+ R.string.play_controls_description_fast_rewind, null, new Runnable() {
@Override
public void run() {
if (mTimeShiftManager.isAvailable()) {
@@ -132,7 +210,7 @@ public class PlayControlsRowView extends MenuRowView {
}
});
initializeButton(mPlayPauseButton, R.drawable.lb_ic_play,
- R.string.play_controls_description_play_pause, new Runnable() {
+ R.string.play_controls_description_play_pause, null, new Runnable() {
@Override
public void run() {
if (mTimeShiftManager.isAvailable()) {
@@ -142,7 +220,7 @@ public class PlayControlsRowView extends MenuRowView {
}
});
initializeButton(mFastForwardButton, R.drawable.lb_ic_fast_forward,
- R.string.play_controls_description_fast_forward, new Runnable() {
+ R.string.play_controls_description_fast_forward, null, new Runnable() {
@Override
public void run() {
if (mTimeShiftManager.isAvailable()) {
@@ -152,21 +230,80 @@ public class PlayControlsRowView extends MenuRowView {
}
});
initializeButton(mJumpNextButton, R.drawable.lb_ic_skip_next,
- R.string.play_controls_description_skip_next, new Runnable() {
+ R.string.play_controls_description_skip_next, null, new Runnable() {
@Override
public void run() {
if (mTimeShiftManager.isAvailable()) {
mTimeShiftManager.jumpToNext();
- updateAll();
+ updateControls();
}
}
});
+ int color = getResources().getColor(R.color.play_controls_recording_icon_color_on_focus,
+ null);
+ initializeButton(mRecordButton, R.drawable.ic_record_start, R.string
+ .channels_item_record_start, color, new Runnable() {
+ @Override
+ public void run() {
+ onRecordButtonClicked();
+ }
+ });
+ }
+
+ private boolean isCurrentChannelRecording() {
+ Channel currentChannel = mMainActivity.getCurrentChannel();
+ return currentChannel != null && mDvrManager != null
+ && mDvrManager.getCurrentRecording(currentChannel.getId()) != null;
+ }
+
+ private void onRecordButtonClicked() {
+ boolean isRecording = isCurrentChannelRecording();
+ Channel currentChannel = mMainActivity.getCurrentChannel();
+ TvApplication.getSingletons(getContext()).getTracker().sendMenuClicked(isRecording ?
+ R.string.channels_item_record_start : R.string.channels_item_record_stop);
+ if (!isRecording) {
+ if (!(mDvrManager != null && mDvrManager.isChannelRecordable(currentChannel))) {
+ Toast.makeText(mMainActivity, R.string.dvr_msg_cannot_record_channel,
+ Toast.LENGTH_SHORT).show();
+ } else if (DvrUiHelper.checkStorageStatusAndShowErrorMessage(mMainActivity,
+ currentChannel.getInputId())) {
+ Program program = TvApplication.getSingletons(mMainActivity).getProgramDataManager()
+ .getCurrentProgram(currentChannel.getId());
+ if (program == null) {
+ DvrUiHelper.showChannelRecordDurationOptions(mMainActivity, currentChannel);
+ } else if (DvrUiHelper.handleCreateSchedule(mMainActivity, program)) {
+ String msg = mMainActivity.getString(R.string.dvr_msg_current_program_scheduled,
+ program.getTitle(),
+ Utils.toTimeString(program.getEndTimeUtcMillis(), false));
+ Toast.makeText(mMainActivity, msg, Toast.LENGTH_SHORT).show();
+ }
+ }
+ } else if (currentChannel != null) {
+ DvrUiHelper.showStopRecordingDialog(mMainActivity, currentChannel.getId(),
+ DvrStopRecordingFragment.REASON_USER_STOP,
+ new HalfSizedDialogFragment.OnActionClickListener() {
+ @Override
+ public void onActionClick(long actionId) {
+ if (actionId == DvrStopRecordingFragment.ACTION_STOP) {
+ ScheduledRecording currentRecording =
+ mDvrManager.getCurrentRecording(
+ currentChannel.getId());
+ if (currentRecording != null) {
+ mDvrManager.stopRecording(currentRecording);
+ }
+ }
+ }
+ });
+ }
}
private void initializeButton(PlayControlsButton button, int imageResId,
- int descriptionId, Runnable clickAction) {
+ int descriptionId, Integer focusedIconColor, Runnable clickAction) {
button.setImageResId(imageResId);
button.setAction(clickAction);
+ if (focusedIconColor != null) {
+ button.setFocusedIconColor(focusedIconColor);
+ }
button.findViewById(R.id.button)
.setContentDescription(getResources().getString(descriptionId));
}
@@ -175,46 +312,46 @@ public class PlayControlsRowView extends MenuRowView {
public void onBind(MenuRow row) {
super.onBind(row);
PlayControlsRow playControlsRow = (PlayControlsRow) row;
+ mTvView = playControlsRow.getTvView();
mTimeShiftManager = playControlsRow.getTimeShiftManager();
mTimeShiftManager.setListener(new TimeShiftManager.Listener() {
@Override
public void onAvailabilityChanged() {
updateMenuVisibility();
- PlayControlsRowView.this.onAvailabilityChanged();
+ if (isShown()) {
+ PlayControlsRowView.this.updateAll();
+ }
}
@Override
public void onPlayStatusChanged(int status) {
updateMenuVisibility();
- if (mTimeShiftManager.isAvailable()) {
- updateAll();
+ if (mTimeShiftManager.isAvailable() && isShown()) {
+ updateControls();
}
}
@Override
public void onRecordTimeRangeChanged() {
- if (!mTimeShiftManager.isAvailable()) {
- return;
+ if (mTimeShiftManager.isAvailable() && isShown()) {
+ updateControls();
}
- updateAll();
}
@Override
public void onCurrentPositionChanged() {
- if (!mTimeShiftManager.isAvailable()) {
- return;
+ if (mTimeShiftManager.isAvailable() && isShown()) {
+ initializeTimeline();
+ updateControls();
}
- initializeTimeline();
- updateAll();
}
@Override
public void onProgramInfoChanged() {
- if (!mTimeShiftManager.isAvailable()) {
- return;
+ if (mTimeShiftManager.isAvailable() && isShown()) {
+ initializeTimeline();
+ updateControls();
}
- initializeTimeline();
- updateAll();
}
@Override
@@ -235,31 +372,14 @@ public class PlayControlsRowView extends MenuRowView {
}
}
});
- onAvailabilityChanged();
- }
-
- private void onAvailabilityChanged() {
- if (mTimeShiftManager.isAvailable()) {
- setEnabled(true);
- initializeTimeline();
- mBackgroundView.setEnabled(true);
- } else {
- setEnabled(false);
- mBackgroundView.setEnabled(false);
- }
updateAll();
}
private void initializeTimeline() {
- if (mTimeShiftManager.isRecordingPlayback()) {
- mProgramStartTimeMs = mTimeShiftManager.getRecordStartTimeMs();
- mProgramEndTimeMs = mTimeShiftManager.getRecordEndTimeMs();
- } else {
- Program program = mTimeShiftManager.getProgramAt(
- mTimeShiftManager.getCurrentPositionMs());
- mProgramStartTimeMs = program.getStartTimeUtcMillis();
- mProgramEndTimeMs = program.getEndTimeUtcMillis();
- }
+ Program program = mTimeShiftManager.getProgramAt(
+ mTimeShiftManager.getCurrentPositionMs());
+ mProgramStartTimeMs = program.getStartTimeUtcMillis();
+ mProgramEndTimeMs = program.getEndTimeUtcMillis();
SoftPreconditions.checkArgument(mProgramStartTimeMs <= mProgramEndTimeMs);
}
@@ -272,7 +392,7 @@ public class PlayControlsRowView extends MenuRowView {
@Override
public void onSelected(boolean showTitle) {
super.onSelected(showTitle);
- updateAll();
+ updateControls();
postHideRippleAnimation();
}
@@ -350,11 +470,32 @@ public class PlayControlsRowView extends MenuRowView {
}
}
+ /**
+ * Updates the view contents. It is called from the PlayControlsRow.
+ */
+ public void update() {
+ updateAll();
+ }
+
private void updateAll() {
+ if (mTimeShiftManager.isAvailable() && !mTvView.isScreenBlocked()) {
+ setEnabled(true);
+ initializeTimeline();
+ mBackgroundView.setEnabled(true);
+ } else {
+ setEnabled(false);
+ mBackgroundView.setEnabled(false);
+ }
+ updateControls();
+ }
+
+ private void updateControls() {
updateTime();
updateProgress();
updateRecTimeText();
updateButtons();
+ updateRecordButton();
+ updateButtonMargin();
}
private void updateTime() {
@@ -423,12 +564,8 @@ public class PlayControlsRowView extends MenuRowView {
private void updateRecTimeText() {
if (isEnabled()) {
- if (mTimeShiftManager.isRecordingPlayback()) {
- mProgramStartTimeText.setVisibility(View.GONE);
- } else {
- mProgramStartTimeText.setVisibility(View.VISIBLE);
- mProgramStartTimeText.setText(getTimeString(mProgramStartTimeMs));
- }
+ mProgramStartTimeText.setVisibility(View.VISIBLE);
+ mProgramStartTimeText.setText(getTimeString(mProgramStartTimeMs));
mProgramEndTimeText.setVisibility(View.VISIBLE);
mProgramEndTimeText.setText(getTimeString(mProgramEndTimeMs));
} else {
@@ -464,6 +601,9 @@ public class PlayControlsRowView extends MenuRowView {
TimeShiftManager.TIME_SHIFT_ACTION_ID_FAST_FORWARD));
mJumpNextButton.setEnabled(mTimeShiftManager.isActionEnabled(
TimeShiftManager.TIME_SHIFT_ACTION_ID_JUMP_TO_NEXT));
+ mJumpPreviousButton.setVisibility(VISIBLE);
+ mJumpNextButton.setVisibility(VISIBLE);
+ updateButtonMargin();
PlayControlsButton button;
if (mTimeShiftManager.getPlayDirection() == TimeShiftManager.PLAY_DIRECTION_FORWARD) {
@@ -481,10 +621,51 @@ public class PlayControlsRowView extends MenuRowView {
}
}
+ private void updateRecordButton() {
+ if (!(mDvrManager != null
+ && mDvrManager.isChannelRecordable(mMainActivity.getCurrentChannel()))) {
+ mRecordButton.setVisibility(View.GONE);
+ updateButtonMargin();
+ return;
+ }
+ mRecordButton.setVisibility(View.VISIBLE);
+ updateButtonMargin();
+ if (isCurrentChannelRecording()) {
+ mRecordButton.setImageResId(R.drawable.ic_record_stop);
+ } else {
+ mRecordButton.setImageResId(R.drawable.ic_record_start);
+ }
+ }
+
+ private void updateButtonMargin() {
+ int numOfVisibleButtons = (mJumpPreviousButton.getVisibility() == View.VISIBLE ? 1 : 0)
+ + (mRewindButton.getVisibility() == View.VISIBLE ? 1 : 0)
+ + (mPlayPauseButton.getVisibility() == View.VISIBLE ? 1 : 0)
+ + (mFastForwardButton.getVisibility() == View.VISIBLE ? 1 : 0)
+ + (mJumpNextButton.getVisibility() == View.VISIBLE ? 1 : 0)
+ + (mRecordButton.getVisibility() == View.VISIBLE ? 1 : 0);
+ boolean useCompactLayout = numOfVisibleButtons > NORMAL_WIDTH_MAX_BUTTON_COUNT;
+ if (mUseCompactLayout == useCompactLayout) {
+ return;
+ }
+ mUseCompactLayout = useCompactLayout;
+ int margin = mUseCompactLayout ? mCompactButtonMargin : mNormalButtonMargin;
+ updateButtonMargin(mJumpPreviousButton, margin);
+ updateButtonMargin(mRewindButton, margin);
+ updateButtonMargin(mPlayPauseButton, margin);
+ updateButtonMargin(mFastForwardButton, margin);
+ updateButtonMargin(mJumpNextButton, margin);
+ updateButtonMargin(mRecordButton, margin);
+ }
+
+ private void updateButtonMargin(PlayControlsButton button, int margin) {
+ MarginLayoutParams params = (MarginLayoutParams) button.getLayoutParams();
+ params.setMargins(margin, 0, margin, 0);
+ button.setLayoutParams(params);
+ }
+
private String getTimeString(long timeMs) {
- return mTimeShiftManager.isRecordingPlayback()
- ? DateUtils.formatElapsedTime(timeMs / 1000)
- : mTimeFormat.format(timeMs);
+ return mTimeFormat.format(timeMs);
}
private int convertDurationToPixel(long duration) {
@@ -493,4 +674,12 @@ public class PlayControlsRowView extends MenuRowView {
}
return (int) (duration * mTimelineWidth / (mProgramEndTimeMs - mProgramStartTimeMs));
}
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ if (mDvrDataManager != null) {
+ mDvrDataManager.removeScheduledRecordingListener(mScheduledRecordingListener);
+ }
+ }
}
diff --git a/src/com/android/tv/menu/RecordCardView.java b/src/com/android/tv/menu/RecordCardView.java
deleted file mode 100644
index de30894e..00000000
--- a/src/com/android/tv/menu/RecordCardView.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.menu;
-
-import android.app.AlertDialog;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.res.Resources;
-import android.util.AttributeSet;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import com.android.tv.MainActivity;
-import com.android.tv.R;
-import com.android.tv.TvApplication;
-import com.android.tv.data.Channel;
-import com.android.tv.data.Program;
-import com.android.tv.dvr.DvrDataManager;
-import com.android.tv.dvr.DvrManager;
-import com.android.tv.dvr.ScheduledRecording;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-/**
- * A view to render an item of TV options.
- */
-public class RecordCardView extends SimpleCardView implements
- DvrDataManager.ScheduledRecordingListener {
- private static final String TAG = MenuView.TAG;
- private static final boolean DEBUG = MenuView.DEBUG;
- private static final long MIN_PROGRAM_RECORD_DURATION = TimeUnit.MINUTES.toMillis(5);
-
- private ImageView mIconView;
- private TextView mLabelView;
- private Channel mCurrentChannel;
- private final DvrManager mDvrManager;
- private final DvrDataManager mDvrDataManager;
- private boolean mIsRecording;
- private ScheduledRecording mCurrentRecording;
-
- public RecordCardView(Context context) {
- this(context, null);
- }
-
- public RecordCardView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
-
- public RecordCardView(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- mDvrManager = TvApplication.getSingletons(context).getDvrManager();
- mDvrDataManager = TvApplication.getSingletons(context).getDvrDataManager();
- }
-
- @Override
- public void onBind(Channel channel, boolean selected) {
- super.onBind(channel, selected);
- mIconView = (ImageView) findViewById(R.id.record_icon);
- mLabelView = (TextView) findViewById(R.id.record_label);
- mCurrentChannel = channel;
- mCurrentRecording = null;
- for (ScheduledRecording recording : mDvrDataManager.getStartedRecordings()) {
- if (recording.getChannelId() == channel.getId()) {
- mIsRecording = true;
- mCurrentRecording = recording;
- }
- }
- mDvrDataManager.addScheduledRecordingListener(this);
- updateCardView();
- }
-
- @Override
- public void onRecycled() {
- super.onRecycled();
- mDvrDataManager.removeScheduledRecordingListener(this);
- }
-
- public boolean isRecording() {
- return mIsRecording;
- }
-
- public void startRecording() {
- showStartRecordingDialog();
- }
-
- public void stopRecording() {
- mDvrManager.stopRecording(mCurrentRecording);
- }
-
- private void updateCardView() {
- if (mIsRecording) {
- mIconView.setImageResource(R.drawable.ic_record_stop);
- mLabelView.setText(R.string.channels_item_record_stop);
- } else {
- mIconView.setImageResource(R.drawable.ic_record_start);
- mLabelView.setText(R.string.channels_item_record_start);
- }
- }
-
- private void showStartRecordingDialog() {
- final long endOfProgram = -1;
-
- final List<CharSequence> items = new ArrayList<>();
- final List<Long> durations = new ArrayList<>();
- Resources res = getResources();
- items.add(res.getString(R.string.recording_start_dialog_10_min_duration));
- durations.add(TimeUnit.MINUTES.toMillis(10));
- items.add(res.getString(R.string.recording_start_dialog_30_min_duration));
- durations.add(TimeUnit.MINUTES.toMillis(30));
- items.add(res.getString(R.string.recording_start_dialog_1_hour_duration));
- durations.add(TimeUnit.HOURS.toMillis(1));
- items.add(res.getString(R.string.recording_start_dialog_3_hours_duration));
- durations.add(TimeUnit.HOURS.toMillis(3));
-
- Program currenProgram = ((MainActivity) getContext()).getCurrentProgram(false);
- if (currenProgram != null) {
- long duration = currenProgram.getEndTimeUtcMillis() - System.currentTimeMillis();
- if (duration > MIN_PROGRAM_RECORD_DURATION) {
- items.add(res.getString(R.string.recording_start_dialog_till_end_of_program));
- durations.add(duration);
- }
- }
-
- DialogInterface.OnClickListener onClickListener
- = new DialogInterface.OnClickListener() {
- @Override
- public void onClick(final DialogInterface dialog, int which) {
- long startTime = System.currentTimeMillis();
- long endTime = System.currentTimeMillis() + durations.get(which);
- mDvrManager.addSchedule(mCurrentChannel, startTime, endTime);
- dialog.dismiss();
- }
- };
- new AlertDialog.Builder(getContext())
- .setItems(items.toArray(new CharSequence[items.size()]), onClickListener)
- .create()
- .show();
- }
-
- @Override
- public void onScheduledRecordingAdded(ScheduledRecording recording) {
- }
-
- @Override
- public void onScheduledRecordingRemoved(ScheduledRecording recording) {
- if (recording.getChannelId() != mCurrentChannel.getId()) {
- return;
- }
- if (mIsRecording) {
- mIsRecording = false;
- mCurrentRecording = null;
- updateCardView();
- }
- }
-
- @Override
- public void onScheduledRecordingStatusChanged(ScheduledRecording recording) {
- if (recording.getChannelId() != mCurrentChannel.getId()) {
- return;
- }
- int state = recording.getState();
- if (state == ScheduledRecording.STATE_RECORDING_FAILED
- || state == ScheduledRecording.STATE_RECORDING_FINISHED) {
- mIsRecording = false;
- mCurrentRecording = null;
- updateCardView();
- } else if (state == ScheduledRecording.STATE_RECORDING_IN_PROGRESS) {
- mIsRecording = true;
- mCurrentRecording = recording;
- updateCardView();
- }
- }
-}
diff --git a/src/com/android/tv/menu/SetupCardView.java b/src/com/android/tv/menu/SetupCardView.java
deleted file mode 100644
index 7ad5e9d0..00000000
--- a/src/com/android/tv/menu/SetupCardView.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.menu;
-
-import android.content.Context;
-import android.util.AttributeSet;
-
-import com.android.tv.R;
-import com.android.tv.data.Channel;
-
-/**
- * A view to render a guide card.
- */
-public class SetupCardView extends BaseCardView<Channel> {
- private static final String TAG = "GuideCardView";
- private static final boolean DEBUG = false;
-
- private static final int INVALID_COUNT = -1;
-
- private final float mCardHeight;
-
- public SetupCardView(Context context) {
- this(context, null, 0);
- }
-
- public SetupCardView(Context context, AttributeSet attrs) {
- this(context, attrs, 0);
- }
-
- public SetupCardView(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- mCardHeight = getResources().getDimension(R.dimen.card_layout_height);
- }
-
- @Override
- protected float getCardHeight() {
- return mCardHeight;
- }
-}
diff --git a/src/com/android/tv/menu/SimpleCardView.java b/src/com/android/tv/menu/SimpleCardView.java
index 24a44244..c99834be 100644
--- a/src/com/android/tv/menu/SimpleCardView.java
+++ b/src/com/android/tv/menu/SimpleCardView.java
@@ -19,16 +19,12 @@ package com.android.tv.menu;
import android.content.Context;
import android.util.AttributeSet;
-import com.android.tv.R;
import com.android.tv.data.Channel;
/**
* A view to render a guide card.
*/
public class SimpleCardView extends BaseCardView<Channel> {
- private static final String TAG = "GuideCardView";
- private static final boolean DEBUG = false;
- private final float mCardHeight;
public SimpleCardView(Context context) {
this(context, null, 0);
@@ -40,11 +36,5 @@ public class SimpleCardView extends BaseCardView<Channel> {
public SimpleCardView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
- mCardHeight = getResources().getDimension(R.dimen.card_layout_height);
- }
-
- @Override
- protected float getCardHeight() {
- return mCardHeight;
}
}
diff --git a/src/com/android/tv/menu/TvOptionsRowAdapter.java b/src/com/android/tv/menu/TvOptionsRowAdapter.java
index ba84247b..fb062246 100644
--- a/src/com/android/tv/menu/TvOptionsRowAdapter.java
+++ b/src/com/android/tv/menu/TvOptionsRowAdapter.java
@@ -19,7 +19,6 @@ package com.android.tv.menu;
import android.content.Context;
import android.media.tv.TvTrackInfo;
import android.support.annotation.VisibleForTesting;
-import android.support.v4.os.BuildCompat;
import com.android.tv.Features;
import com.android.tv.R;
@@ -28,6 +27,7 @@ import com.android.tv.customization.CustomAction;
import com.android.tv.data.DisplayMode;
import com.android.tv.ui.TvViewUiManager;
import com.android.tv.ui.sidepanel.ClosedCaptionFragment;
+import com.android.tv.ui.sidepanel.DeveloperOptionFragment;
import com.android.tv.ui.sidepanel.DisplayModeFragment;
import com.android.tv.ui.sidepanel.MultiAudioFragment;
import com.android.tv.util.PipInputManager;
@@ -39,14 +39,14 @@ import java.util.List;
* An adapter of options.
*/
public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter {
+ private static final boolean ENABLE_IN_APP_PIP = false;
+
private int mPositionPipAction;
// If mInAppPipAction is false, system-wide PIP is used.
private boolean mInAppPipAction = true;
- private final Context mContext;
public TvOptionsRowAdapter(Context context, List<CustomAction> customActions) {
super(context, customActions);
- mContext = context;
}
@Override
@@ -61,8 +61,9 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter {
mPositionPipAction = actionList.size() - 1;
actionList.add(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION);
setOptionChangedListener(MenuAction.SELECT_AUDIO_LANGUAGE_ACTION);
- if (Features.ONBOARDING_PLAY_STORE.isEnabled(getMainActivity())) {
- actionList.add(MenuAction.MORE_CHANNELS_ACTION);
+ actionList.add(MenuAction.MORE_CHANNELS_ACTION);
+ if (DeveloperOptionFragment.shouldShow()) {
+ actionList.add(MenuAction.DEV_ACTION);
}
actionList.add(MenuAction.SETTINGS_ACTION);
@@ -109,23 +110,23 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter {
// Case 1
PipInputManager pipInputManager = getMainActivity().getPipInputManager();
- if (pipInputManager.getPipInputSize(false) < 2) {
+ if (ENABLE_IN_APP_PIP && pipInputManager.getPipInputSize(false) > 1) {
+ if (!mInAppPipAction) {
+ removeAction(mPositionPipAction);
+ addAction(mPositionPipAction, MenuAction.PIP_IN_APP_ACTION);
+ mInAppPipAction = true;
+ changed = true;
+ }
+ } else {
if (mInAppPipAction) {
removeAction(mPositionPipAction);
mInAppPipAction = false;
- if (BuildCompat.isAtLeastN()) {
+ if (Features.PICTURE_IN_PICTURE.isEnabled(getMainActivity())) {
addAction(mPositionPipAction, MenuAction.SYSTEMWIDE_PIP_ACTION);
}
return true;
}
return false;
- } else {
- if (!mInAppPipAction) {
- removeAction(mPositionPipAction);
- addAction(mPositionPipAction, MenuAction.PIP_IN_APP_ACTION);
- mInAppPipAction = true;
- changed = true;
- }
}
// Case 2
@@ -175,12 +176,12 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter {
protected void executeBaseAction(int type) {
switch (type) {
case TvOptionsManager.OPTION_CLOSED_CAPTIONS:
- getMainActivity().getOverlayManager().getSideFragmentManager().show(
- new ClosedCaptionFragment());
+ getMainActivity().getOverlayManager().getSideFragmentManager()
+ .show(new ClosedCaptionFragment());
break;
case TvOptionsManager.OPTION_DISPLAY_MODE:
- getMainActivity().getOverlayManager().getSideFragmentManager().show(
- new DisplayModeFragment());
+ getMainActivity().getOverlayManager().getSideFragmentManager()
+ .show(new DisplayModeFragment());
break;
case TvOptionsManager.OPTION_IN_APP_PIP:
getMainActivity().togglePipView();
@@ -189,12 +190,16 @@ public class TvOptionsRowAdapter extends CustomizableOptionsRowAdapter {
getMainActivity().enterPictureInPictureMode();
break;
case TvOptionsManager.OPTION_MULTI_AUDIO:
- getMainActivity().getOverlayManager().getSideFragmentManager().show(
- new MultiAudioFragment());
+ getMainActivity().getOverlayManager().getSideFragmentManager()
+ .show(new MultiAudioFragment());
break;
case TvOptionsManager.OPTION_MORE_CHANNELS:
getMainActivity().showMerchantCollection();
break;
+ case TvOptionsManager.OPTION_DEVELOPER:
+ getMainActivity().getOverlayManager().getSideFragmentManager()
+ .show(new DeveloperOptionFragment());
+ break;
case TvOptionsManager.OPTION_SETTINGS:
getMainActivity().showSettingsFragment();
break;
diff --git a/src/com/android/tv/onboarding/NewSourcesFragment.java b/src/com/android/tv/onboarding/NewSourcesFragment.java
index ebaf0b6c..8509b50c 100644
--- a/src/com/android/tv/onboarding/NewSourcesFragment.java
+++ b/src/com/android/tv/onboarding/NewSourcesFragment.java
@@ -17,7 +17,6 @@
package com.android.tv.onboarding;
import android.app.Fragment;
-import android.os.Build;
import android.os.Bundle;
import android.transition.Slide;
import android.view.Gravity;
@@ -27,7 +26,6 @@ import android.view.ViewGroup;
import com.android.tv.R;
import com.android.tv.TvApplication;
-import com.android.tv.common.ui.setup.OnActionClickListener;
import com.android.tv.common.ui.setup.SetupActionHelper;
import com.android.tv.util.SetupUtils;
@@ -35,12 +33,20 @@ import com.android.tv.util.SetupUtils;
* A fragment for new channel source info/setup.
*/
public class NewSourcesFragment extends Fragment {
- public static final String ACTION_CATEOGRY = NewSourcesFragment.class.getCanonicalName();
+ /**
+ * The action category.
+ */
+ public static final String ACTION_CATEOGRY =
+ "com.android.tv.onboarding.NewSourcesFragment";
+ /**
+ * An action to show the setup screen.
+ */
public static final int ACTION_SETUP = 1;
+ /**
+ * An action to close this fragment.
+ */
public static final int ACTION_SKIP = 2;
- private OnActionClickListener mOnActionClickListener;
-
public NewSourcesFragment() {
setAllowEnterTransitionOverlap(false);
setAllowReturnTransitionOverlap(false);
@@ -62,21 +68,8 @@ public class NewSourcesFragment extends Fragment {
return view;
}
- /**
- * Sets the {@link OnActionClickListener}. This method should be called before the views are
- * created.
- */
- public void setOnActionClickListener(OnActionClickListener onActionClickListener) {
- mOnActionClickListener = onActionClickListener;
- }
-
private void initializeButton(View view, int actionId) {
- view.setOnClickListener(SetupActionHelper.createOnClickListenerForAction(
- mOnActionClickListener, ACTION_CATEOGRY, actionId));
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- // Prior to M, foreground ripple animation is not supported.
- // Use background ripple drawable instead of drawing in the foreground manually.
- view.setBackground(getActivity().getDrawable(R.drawable.setup_selector_background));
- }
+ view.setOnClickListener(SetupActionHelper.createOnClickListenerForAction(this,
+ ACTION_CATEOGRY, actionId, null));
}
}
diff --git a/src/com/android/tv/onboarding/OnboardingActivity.java b/src/com/android/tv/onboarding/OnboardingActivity.java
index 0685d14b..45205c4c 100644
--- a/src/com/android/tv/onboarding/OnboardingActivity.java
+++ b/src/com/android/tv/onboarding/OnboardingActivity.java
@@ -17,32 +17,40 @@
package com.android.tv.onboarding;
import android.app.Fragment;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.os.Build;
+import android.media.tv.TvInputInfo;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.widget.Toast;
+import com.android.tv.ApplicationSingletons;
import com.android.tv.R;
+import com.android.tv.SetupPassthroughActivity;
import com.android.tv.TvApplication;
+import com.android.tv.common.TvCommonUtils;
import com.android.tv.common.ui.setup.SetupActivity;
import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.util.OnboardingUtils;
import com.android.tv.util.PermissionUtils;
import com.android.tv.util.SetupUtils;
+import com.android.tv.util.TvInputManagerHelper;
public class OnboardingActivity extends SetupActivity {
private static final String KEY_INTENT_AFTER_COMPLETION = "key_intent_after_completion";
private static final int PERMISSIONS_REQUEST_READ_TV_LISTINGS = 1;
- private static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS";
private static final int SHOW_RIPPLE_DURATION_MS = 266;
+ private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1;
+
private ChannelDataManager mChannelDataManager;
+ private TvInputManagerHelper mInputManager;
private final ChannelDataManager.Listener mChannelListener = new ChannelDataManager.Listener() {
@Override
public void onLoadFinished() {
@@ -73,16 +81,20 @@ public class OnboardingActivity extends SetupActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- if (!PermissionUtils.hasAccessAllEpg(this)) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Toast.makeText(this, R.string.msg_not_supported_device, Toast.LENGTH_LONG).show();
- finish();
- return;
- } else if (checkSelfPermission(PERMISSION_READ_TV_LISTINGS)
- != PackageManager.PERMISSION_GRANTED) {
- requestPermissions(new String[]{PERMISSION_READ_TV_LISTINGS},
- PERMISSIONS_REQUEST_READ_TV_LISTINGS);
+ ApplicationSingletons singletons = TvApplication.getSingletons(this);
+ mInputManager = singletons.getTvInputManagerHelper();
+ if (PermissionUtils.hasAccessAllEpg(this) || PermissionUtils.hasReadTvListings(this)) {
+ mChannelDataManager = singletons.getChannelDataManager();
+ // Make the channels of the new inputs which have been setup outside Live TV
+ // browsable.
+ if (mChannelDataManager.isDbLoadFinished()) {
+ SetupUtils.getInstance(this).markNewChannelsBrowsable();
+ } else {
+ mChannelDataManager.addListener(mChannelListener);
}
+ } else {
+ requestPermissions(new String[] {PermissionUtils.PERMISSION_READ_TV_LISTINGS},
+ PERMISSIONS_REQUEST_READ_TV_LISTINGS);
}
}
@@ -97,14 +109,6 @@ public class OnboardingActivity extends SetupActivity {
@Override
protected Fragment onCreateInitialFragment() {
if (PermissionUtils.hasAccessAllEpg(this) || PermissionUtils.hasReadTvListings(this)) {
- // Make the channels of the new inputs which have been setup outside Live TV
- // browsable.
- mChannelDataManager = TvApplication.getSingletons(this).getChannelDataManager();
- if (mChannelDataManager.isDbLoadFinished()) {
- SetupUtils.getInstance(this).markNewChannelsBrowsable();
- } else {
- mChannelDataManager.addListener(mChannelListener);
- }
return OnboardingUtils.isFirstRunWithCurrentVersion(this) ? new WelcomeFragment()
: new SetupSourcesFragment();
}
@@ -115,8 +119,7 @@ public class OnboardingActivity extends SetupActivity {
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
if (requestCode == PERMISSIONS_REQUEST_READ_TV_LISTINGS) {
- if (grantResults != null && grantResults.length > 0
- && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
finish();
Intent intentForNextActivity = getIntent().getParcelableExtra(
KEY_INTENT_AFTER_COMPLETION);
@@ -129,7 +132,7 @@ public class OnboardingActivity extends SetupActivity {
}
}
- void finishActivity() {
+ private void finishActivity() {
Intent intentForNextActivity = getIntent().getParcelableExtra(
KEY_INTENT_AFTER_COMPLETION);
if (intentForNextActivity != null) {
@@ -138,17 +141,17 @@ public class OnboardingActivity extends SetupActivity {
finish();
}
- void showMerchantCollection() {
+ private void showMerchantCollection() {
executeActionWithDelay(new Runnable() {
@Override
public void run() {
- startActivity(OnboardingUtils.PLAY_STORE_INTENT);
+ startActivity(OnboardingUtils.ONLINE_STORE_INTENT);
}
}, SHOW_RIPPLE_DURATION_MS);
}
@Override
- protected void executeAction(String category, int actionId) {
+ protected boolean executeAction(String category, int actionId, Bundle params) {
switch (category) {
case WelcomeFragment.ACTION_CATEGORY:
switch (actionId) {
@@ -156,14 +159,40 @@ public class OnboardingActivity extends SetupActivity {
OnboardingUtils.setFirstRunWithCurrentVersionCompleted(
OnboardingActivity.this);
showFragment(new SetupSourcesFragment(), false);
- break;
+ return true;
}
break;
case SetupSourcesFragment.ACTION_CATEGORY:
switch (actionId) {
- case SetupSourcesFragment.ACTION_PLAY_STORE:
+ case SetupSourcesFragment.ACTION_ONLINE_STORE:
showMerchantCollection();
- break;
+ return true;
+ case SetupSourcesFragment.ACTION_SETUP_INPUT: {
+ String inputId = params.getString(
+ SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID);
+ TvInputInfo input = mInputManager.getTvInputInfo(inputId);
+ Intent intent = TvCommonUtils.createSetupIntent(input);
+ if (intent == null) {
+ Toast.makeText(this, R.string.msg_no_setup_activity, Toast.LENGTH_SHORT)
+ .show();
+ return true;
+ }
+ // Even though other app can handle the intent, the setup launched by Live
+ // channels should go through Live channels SetupPassthroughActivity.
+ intent.setComponent(new ComponentName(this,
+ SetupPassthroughActivity.class));
+ try {
+ // Now we know that the user intends to set up this input. Grant
+ // permission for writing EPG data.
+ SetupUtils.grantEpgPermission(this, input.getServiceInfo().packageName);
+ startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(this,
+ getString(R.string.msg_unable_to_start_setup_activity,
+ input.loadLabel(this)), Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }
case SetupMultiPaneFragment.ACTION_DONE: {
ChannelDataManager manager = TvApplication.getSingletons(
OnboardingActivity.this).getChannelDataManager();
@@ -172,10 +201,11 @@ public class OnboardingActivity extends SetupActivity {
} else {
finishActivity();
}
- break;
+ return true;
}
}
break;
}
+ return false;
}
}
diff --git a/src/com/android/tv/onboarding/SetupSourcesFragment.java b/src/com/android/tv/onboarding/SetupSourcesFragment.java
index 23145503..7607822c 100644
--- a/src/com/android/tv/onboarding/SetupSourcesFragment.java
+++ b/src/com/android/tv/onboarding/SetupSourcesFragment.java
@@ -16,12 +16,8 @@
package com.android.tv.onboarding;
-import android.content.ActivityNotFoundException;
-import android.content.ComponentName;
import android.content.Context;
-import android.content.Intent;
import android.graphics.Typeface;
-import android.graphics.drawable.Drawable;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager.TvInputCallback;
import android.os.Bundle;
@@ -33,23 +29,18 @@ import android.support.v17.leanback.widget.VerticalGridView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.ImageView;
import android.widget.TextView;
-import android.widget.Toast;
import com.android.tv.ApplicationSingletons;
-import com.android.tv.Features;
import com.android.tv.R;
-import com.android.tv.SetupPassthroughActivity;
import com.android.tv.TvApplication;
-import com.android.tv.common.TvCommonUtils;
import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.TvInputNewComparator;
+import com.android.tv.ui.GuidedActionsStylistWithDivider;
import com.android.tv.util.SetupUtils;
import com.android.tv.util.TvInputManagerHelper;
-import com.android.tv.util.Utils;
import java.util.ArrayList;
import java.util.Collections;
@@ -59,17 +50,30 @@ import java.util.List;
* A fragment for channel source info/setup.
*/
public class SetupSourcesFragment extends SetupMultiPaneFragment {
- private static final String TAG = "SetupSourcesFragment";
-
+ /**
+ * The action category for the actions which is fired from this fragment.
+ */
public static final String ACTION_CATEGORY =
"com.android.tv.onboarding.SetupSourcesFragment";
- public static final int ACTION_PLAY_STORE = 1;
-
- private static final String SETUP_TRACKER_LABEL = "Setup fragment";
+ /**
+ * An action to open the merchant collection.
+ */
+ public static final int ACTION_ONLINE_STORE = 1;
+ /**
+ * An action to show the setup activity of TV input.
+ * <p>
+ * This action is not added to the action list. This is sent outside of the fragment.
+ * Use {@link #ACTION_PARAM_KEY_INPUT_ID} to get the input ID from the parameter.
+ */
+ public static final int ACTION_SETUP_INPUT = 2;
- private InputSetupRunnable mInputSetupRunnable;
+ /**
+ * The key for the action parameter which contains the TV input ID. It's used for the action
+ * {@link #ACTION_SETUP_INPUT}.
+ */
+ public static final String ACTION_PARAM_KEY_INPUT_ID = "input_id";
- private ContentFragment mContentFragment;
+ private static final String SETUP_TRACKER_LABEL = "Setup fragment";
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -81,19 +85,21 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
@Override
protected void onEnterTransitionEnd() {
- if (mContentFragment != null) {
- mContentFragment.executePendingAction();
+ SetupGuidedStepFragment f = getContentFragment();
+ if (f instanceof ContentFragment) {
+ // If the enter transition is canceled quickly, the child fragment can be null because
+ // the fragment is added asynchronously.
+ ((ContentFragment) f).executePendingAction();
}
}
@Override
protected SetupGuidedStepFragment onCreateContentFragment() {
- mContentFragment = new ContentFragment();
- mContentFragment.setParentFragment(this);
+ SetupGuidedStepFragment f = new ContentFragment();
Bundle arguments = new Bundle();
arguments.putBoolean(SetupGuidedStepFragment.KEY_THREE_PANE, true);
- mContentFragment.setArguments(arguments);
- return mContentFragment;
+ f.setArguments(arguments);
+ return f;
}
@Override
@@ -101,32 +107,8 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
return ACTION_CATEGORY;
}
- /**
- * Call this method to run customized input setup.
- *
- * @param runnable runnable to be called when the input setup is necessary.
- */
- public void setInputSetupRunnable(InputSetupRunnable runnable) {
- mInputSetupRunnable = runnable;
- }
-
- /**
- * Interface for the customized input setup.
- */
- public interface InputSetupRunnable {
- /**
- * Called for the input setup.
- *
- * @param input TV input for setup.
- */
- void runInputSetup(TvInputInfo input);
- }
-
public static class ContentFragment extends SetupGuidedStepFragment {
- private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1;
-
- // ACTION_PLAY_STORE is defined in the outer class.
- private static final int ACTION_DIVIDER = 2;
+ // ACTION_ONLINE_STORE is defined in the outer class.
private static final int ACTION_HEADER = 3;
private static final int ACTION_INPUT_START = 4;
@@ -163,6 +145,11 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
handleInputChanged();
}
+ @Override
+ public void onTvInputInfoUpdated(TvInputInfo inputInfo) {
+ handleInputChanged();
+ }
+
private void handleInputChanged() {
// The actions created while enter transition is running will not be included in the
// fragment transition.
@@ -175,10 +162,6 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
}
};
- void setParentFragment(SetupSourcesFragment parentFragment) {
- mParentFragment = parentFragment;
- }
-
private final ChannelDataManager.Listener mChannelDataManagerListener
= new ChannelDataManager.Listener() {
@Override
@@ -211,7 +194,6 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
@Override
public void onCreate(Bundle savedInstanceState) {
- // TODO: Handle USB TV tuner differently.
Context context = getActivity();
ApplicationSingletons app = TvApplication.getSingletons(context);
mInputManager = app.getTvInputManagerHelper();
@@ -221,6 +203,7 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
mInputManager.addCallback(mInputCallback);
mChannelDataManager.addListener(mChannelDataManagerListener);
super.onCreate(savedInstanceState);
+ mParentFragment = (SetupSourcesFragment) getParentFragment();
}
@Override
@@ -289,16 +272,24 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
int position = 0;
if (mDoneInputStartIndex > 0) {
// Need a "New" category
- actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_HEADER)
- .title(null).description(getString(R.string.setup_category_new))
- .focusable(false).build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_HEADER)
+ .title(null)
+ .description(getString(R.string.setup_category_new))
+ .focusable(false)
+ .infoOnly(true)
+ .build());
}
for (int i = 0; i < mInputs.size(); ++i) {
if (i == mDoneInputStartIndex) {
++position;
- actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_HEADER)
- .title(null).description(getString(R.string.setup_category_done))
- .focusable(false).build());
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_HEADER)
+ .title(null)
+ .description(getString(R.string.setup_category_done))
+ .focusable(false)
+ .infoOnly(true)
+ .build());
}
TvInputInfo input = mInputs.get(i);
String inputId = input.getId();
@@ -320,24 +311,26 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
if (input.getId().equals(mNewlyAddedInputId)) {
newPosition = position;
}
- actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_INPUT_START + i)
- .title(input.loadLabel(getActivity()).toString()).description(description)
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_INPUT_START + i)
+ .title(input.loadLabel(getActivity()).toString())
+ .description(description)
.build());
}
- if (Features.ONBOARDING_PLAY_STORE.isEnabled(getActivity())) {
- if (mInputs.size() > 0) {
- // Divider
- ++position;
- actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DIVIDER)
- .title(null).description(null).focusable(false).build());
- }
- // Play store action
+ if (mInputs.size() > 0) {
+ // Divider
++position;
- actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_PLAY_STORE)
- .title(getString(R.string.setup_play_store_action_title))
- .description(getString(R.string.setup_play_store_action_description))
- .icon(R.drawable.ic_playstore).build());
+ actions.add(GuidedActionsStylistWithDivider.createDividerAction(getContext()));
}
+ // online store action
+ ++position;
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(ACTION_ONLINE_STORE)
+ .title(getString(R.string.setup_store_action_title))
+ .description(getString(R.string.setup_store_action_description))
+ .icon(R.drawable.ic_store)
+ .build());
+
if (newPosition != -1) {
VerticalGridView gridView = getGuidedActionsStylist().getActionsGridView();
gridView.setSelectedPosition(newPosition);
@@ -351,38 +344,17 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
@Override
public void onGuidedActionClicked(GuidedAction action) {
- if (action.getId() == ACTION_PLAY_STORE) {
+ if (action.getId() == ACTION_ONLINE_STORE) {
mParentFragment.onActionClick(ACTION_CATEGORY, (int) action.getId());
return;
}
- TvInputInfo input = mInputs.get((int) action.getId() - ACTION_INPUT_START);
- if (mParentFragment.mInputSetupRunnable != null) {
- mParentFragment.mInputSetupRunnable.runInputSetup(input);
- return;
+ int index = (int) action.getId() - ACTION_INPUT_START;
+ if (index >= 0) {
+ TvInputInfo input = mInputs.get(index);
+ Bundle params = new Bundle();
+ params.putString(ACTION_PARAM_KEY_INPUT_ID, input.getId());
+ mParentFragment.onActionClick(ACTION_CATEGORY, ACTION_SETUP_INPUT, params);
}
- Intent intent = TvCommonUtils.createSetupIntent(input);
- if (intent == null) {
- Toast.makeText(getActivity(), R.string.msg_no_setup_activity, Toast.LENGTH_SHORT)
- .show();
- return;
- }
- // Even though other app can handle the intent, the setup launched by Live channels
- // should go through Live channels SetupPassthroughActivity.
- intent.setComponent(new ComponentName(getActivity(), SetupPassthroughActivity.class));
- try {
- // Now we know that the user intends to set up this input. Grant permission for
- // writing EPG data.
- SetupUtils.grantEpgPermission(getActivity(), input.getServiceInfo().packageName);
- startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY);
- } catch (ActivityNotFoundException e) {
- Toast.makeText(getActivity(), getString(R.string.msg_unable_to_start_setup_activity,
- input.loadLabel(getActivity())), Toast.LENGTH_SHORT).show();
- }
- }
-
- @Override
- public void onActivityResult(int requestCode, int resultCode, Intent data) {
- updateActions();
}
void executePendingAction() {
@@ -397,60 +369,28 @@ public class SetupSourcesFragment extends SetupMultiPaneFragment {
mPendingAction = PENDING_ACTION_NONE;
}
- private class SetupSourceGuidedActionsStylist extends GuidedActionsStylist {
- private static final int VIEW_TYPE_DIVIDER = 1;
-
+ private class SetupSourceGuidedActionsStylist extends GuidedActionsStylistWithDivider {
private static final float ALPHA_CATEGORY = 1.0f;
private static final float ALPHA_INPUT_DESCRIPTION = 0.5f;
@Override
- public int getItemViewType(GuidedAction action) {
- if (action.getId() == ACTION_DIVIDER) {
- return VIEW_TYPE_DIVIDER;
- }
- return super.getItemViewType(action);
- }
-
- @Override
- public int onProvideItemLayoutId(int viewType) {
- if (viewType == VIEW_TYPE_DIVIDER) {
- return R.layout.onboarding_item_divider;
- }
- return super.onProvideItemLayoutId(viewType);
- }
-
- @Override
public void onBindViewHolder(ViewHolder vh, GuidedAction action) {
super.onBindViewHolder(vh, action);
TextView descriptionView = vh.getDescriptionView();
if (descriptionView != null) {
if (action.getId() == ACTION_HEADER) {
descriptionView.setAlpha(ALPHA_CATEGORY);
- descriptionView.setTextColor(Utils.getColor(getResources(),
- R.color.setup_category));
+ descriptionView.setTextColor(getResources().getColor(R.color.setup_category,
+ null));
descriptionView.setTypeface(Typeface.create(
getString(R.string.condensed_font), 0));
} else {
descriptionView.setAlpha(ALPHA_INPUT_DESCRIPTION);
- descriptionView.setTextColor(Utils.getColor(getResources(),
- R.color.common_setup_input_description));
+ descriptionView.setTextColor(getResources().getColor(
+ R.color.common_setup_input_description, null));
descriptionView.setTypeface(Typeface.create(getString(R.string.font), 0));
}
}
- // Workaround for b/26473407.
- ImageView iconView = vh.getIconView();
- if (iconView != null) {
- Drawable icon = action.getIcon();
- if (icon != null) {
- // setImageDrawable resets the drawable's level unless we set the view level
- // first.
- iconView.setImageLevel(icon.getLevel());
- iconView.setImageDrawable(icon);
- iconView.setVisibility(View.VISIBLE);
- } else {
- iconView.setVisibility(View.GONE);
- }
- }
}
}
}
diff --git a/src/com/android/tv/onboarding/WelcomeFragment.java b/src/com/android/tv/onboarding/WelcomeFragment.java
index 00f7fe8d..f12233e9 100644
--- a/src/com/android/tv/onboarding/WelcomeFragment.java
+++ b/src/com/android/tv/onboarding/WelcomeFragment.java
@@ -591,12 +591,6 @@ public class WelcomeFragment extends OnboardingFragment {
}
@Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
- initialize();
- }
-
- @Override
public void onAttach(Context context) {
super.onAttach(context);
initialize();
diff --git a/src/com/android/tv/receiver/BootCompletedReceiver.java b/src/com/android/tv/receiver/BootCompletedReceiver.java
index da88f70d..8d6c5a14 100644
--- a/src/com/android/tv/receiver/BootCompletedReceiver.java
+++ b/src/com/android/tv/receiver/BootCompletedReceiver.java
@@ -21,11 +21,11 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.support.v4.os.BuildCompat;
import android.util.Log;
import com.android.tv.Features;
import com.android.tv.TvActivity;
+import com.android.tv.TvApplication;
import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.dvr.DvrRecordingService;
import com.android.tv.recommendation.NotificationService;
@@ -50,6 +50,7 @@ public class BootCompletedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (DEBUG) Log.d(TAG, "boot completed " + intent);
+ TvApplication.setCurrentRunningProcess(context, true);
// Start {@link NotificationService}.
Intent notificationIntent = new Intent(context, NotificationService.class);
notificationIntent.setAction(NotificationService.ACTION_SHOW_RECOMMENDATION);
@@ -73,7 +74,7 @@ public class BootCompletedReceiver extends BroadcastReceiver {
}
}
- if (CommonFeatures.DVR.isEnabled(context) && BuildCompat.isAtLeastN()) {
+ if (CommonFeatures.DVR.isEnabled(context)) {
DvrRecordingService.startService(context);
}
}
diff --git a/src/com/android/tv/receiver/GlobalKeyReceiver.java b/src/com/android/tv/receiver/GlobalKeyReceiver.java
index 2e19c089..8cd4fdf1 100644
--- a/src/com/android/tv/receiver/GlobalKeyReceiver.java
+++ b/src/com/android/tv/receiver/GlobalKeyReceiver.java
@@ -35,6 +35,7 @@ public class GlobalKeyReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
+ TvApplication.setCurrentRunningProcess(context, true);
if (ACTION_GLOBAL_BUTTON.equals(intent.getAction())) {
KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (DEBUG) Log.d(TAG, "onReceive: " + event);
diff --git a/src/com/android/tv/receiver/PackageIntentsReceiver.java b/src/com/android/tv/receiver/PackageIntentsReceiver.java
index 4c850402..26d000e7 100644
--- a/src/com/android/tv/receiver/PackageIntentsReceiver.java
+++ b/src/com/android/tv/receiver/PackageIntentsReceiver.java
@@ -29,6 +29,7 @@ public class PackageIntentsReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
+ TvApplication.setCurrentRunningProcess(context, true);
((TvApplication) context.getApplicationContext()).handleInputCountChanged();
}
}
diff --git a/src/com/android/tv/recommendation/NotificationService.java b/src/com/android/tv/recommendation/NotificationService.java
index 0095482d..30ec73e3 100644
--- a/src/com/android/tv/recommendation/NotificationService.java
+++ b/src/com/android/tv/recommendation/NotificationService.java
@@ -28,7 +28,6 @@ import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.media.tv.TvInputInfo;
-import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
@@ -52,7 +51,6 @@ import com.android.tv.data.Program;
import com.android.tv.util.BitmapUtils;
import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
import com.android.tv.util.ImageLoader;
-import com.android.tv.util.PermissionUtils;
import com.android.tv.util.TvInputManagerHelper;
import com.android.tv.util.Utils;
@@ -128,14 +126,8 @@ public class NotificationService extends Service implements Recommender.Listener
@Override
public void onCreate() {
if (DEBUG) Log.d(TAG, "onCreate");
+ TvApplication.setCurrentRunningProcess(this, true);
super.onCreate();
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
- && !PermissionUtils.hasAccessAllEpg(this)) {
- Log.w(TAG, "Live TV requires the system permission on this platform.");
- stopSelf();
- return;
- }
-
mCurrentNotificationCount = 0;
mNotificationChannels = new long[NOTIFICATION_COUNT];
for (int i = 0; i < NOTIFICATION_COUNT; ++i) {
@@ -426,8 +418,7 @@ public class NotificationService extends Service implements Recommender.Listener
}
private void sendNotification(int notificationId, Bitmap channelLogo, Channel channel,
- Bitmap posterArtBitmap, Program program, String inputDisplayName1) {
-
+ Bitmap posterArtBitmap, Program program, String inputDisplayName) {
final long programDurationMs = program.getEndTimeUtcMillis() - program
.getStartTimeUtcMillis();
long programLeftTimsMs = program.getEndTimeUtcMillis() - System.currentTimeMillis();
@@ -442,16 +433,18 @@ public class NotificationService extends Service implements Recommender.Listener
: overlayChannelLogo(channelLogo, posterArtBitmap);
String channelDisplayName = channel.getDisplayName();
Notification notification = new Notification.Builder(this)
- .setContentIntent(notificationIntent).setContentTitle(program.getTitle())
- .setContentText(inputDisplayName1 + " " +
- (TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber()
- : channelDisplayName)).setContentInfo(channelDisplayName)
+ .setContentIntent(notificationIntent)
+ .setContentTitle(program.getTitle())
+ .setContentText(TextUtils.isEmpty(channelDisplayName) ? channel.getDisplayNumber()
+ : channelDisplayName)
+ .setContentInfo(channelDisplayName)
.setAutoCancel(true).setLargeIcon(largeIconBitmap)
.setSmallIcon(R.drawable.ic_launcher_s)
.setCategory(Notification.CATEGORY_RECOMMENDATION)
.setProgress((programProgress > 0) ? 100 : 0, programProgress, false)
- .setSortKey(mRecommender.getChannelSortKey(channel.getId())).build();
- notification.color = Utils.getColor(getResources(), R.color.recommendation_card_background);
+ .setSortKey(mRecommender.getChannelSortKey(channel.getId()))
+ .build();
+ notification.color = getResources().getColor(R.color.recommendation_card_background, null);
if (!TextUtils.isEmpty(program.getThumbnailUri())) {
notification.extras
.putString(Notification.EXTRA_BACKGROUND_IMAGE_URI, program.getThumbnailUri());
diff --git a/src/com/android/tv/recommendation/RecommendationDataManager.java b/src/com/android/tv/recommendation/RecommendationDataManager.java
index a7d4c46d..62ccd578 100644
--- a/src/com/android/tv/recommendation/RecommendationDataManager.java
+++ b/src/com/android/tv/recommendation/RecommendationDataManager.java
@@ -16,8 +16,8 @@
package com.android.tv.recommendation;
+import android.annotation.SuppressLint;
import android.content.Context;
-import android.content.UriMatcher;
import android.database.ContentObserver;
import android.database.Cursor;
import android.media.tv.TvContract;
@@ -41,6 +41,7 @@ import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.Program;
import com.android.tv.data.WatchedHistoryManager;
import com.android.tv.util.PermissionUtils;
+import com.android.tv.util.TvProviderUriMatcher;
import java.util.ArrayList;
import java.util.Collection;
@@ -52,17 +53,6 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class RecommendationDataManager implements WatchedHistoryManager.Listener {
- private static final UriMatcher sUriMatcher;
- private static final int MATCH_CHANNEL = 1;
- private static final int MATCH_CHANNEL_ID = 2;
- private static final int MATCH_WATCHED_PROGRAM_ID = 3;
- static {
- sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
- sUriMatcher.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
- sUriMatcher.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID);
- sUriMatcher.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID);
- }
-
private static final int MSG_START = 1000;
private static final int MSG_STOP = 1001;
private static final int MSG_UPDATE_CHANNELS = 1002;
@@ -130,8 +120,9 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
public synchronized static RecommendationDataManager acquireManager(
Context context, @NonNull Listener listener) {
if (sManager == null) {
- sManager = new RecommendationDataManager(context, listener);
+ sManager = new RecommendationDataManager(context);
}
+ sManager.addListener(listener);
return sManager;
}
@@ -191,7 +182,7 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
public void onInputUpdated(String inputId) { }
};
- private RecommendationDataManager(Context context, final Listener listener) {
+ private RecommendationDataManager(Context context) {
mContext = context.getApplicationContext();
mHandlerThread = new HandlerThread("RecommendationDataManager");
mHandlerThread.start();
@@ -202,7 +193,6 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
runOnMainThread(new Runnable() {
@Override
public void run() {
- addListener(listener);
start();
}
});
@@ -273,9 +263,13 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
.sendToTarget();
}
- @MainThread
private void addListener(Listener listener) {
- mListeners.add(listener);
+ runOnMainThread(new Runnable() {
+ @Override
+ public void run() {
+ mListeners.add(listener);
+ }
+ });
}
@MainThread
@@ -493,7 +487,7 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
private ChannelRecord updateChannelRecordFromWatchedProgram(WatchedProgram program) {
ChannelRecord channelRecord = null;
- if (program != null && program.getWatchEndTimeMs() != 0l) {
+ if (program != null && program.getWatchEndTimeMs() != 0L) {
channelRecord = mChannelRecordMap.get(program.getProgram().getChannelId());
if (channelRecord != null
&& channelRecord.getLastWatchEndTimeMs() < program.getWatchEndTimeMs()) {
@@ -508,10 +502,11 @@ public class RecommendationDataManager implements WatchedHistoryManager.Listener
super(handler);
}
+ @SuppressLint("SwitchIntDef")
@Override
public void onChange(final boolean selfChange, final Uri uri) {
- switch (sUriMatcher.match(uri)) {
- case MATCH_WATCHED_PROGRAM_ID:
+ switch (TvProviderUriMatcher.match(uri)) {
+ case TvProviderUriMatcher.MATCH_WATCHED_PROGRAM_ID:
if (!mHandler.hasMessages(MSG_UPDATE_WATCH_HISTORY,
TvContract.WatchedPrograms.CONTENT_URI)) {
mHandler.obtainMessage(MSG_UPDATE_WATCH_HISTORY, uri).sendToTarget();
diff --git a/src/com/android/tv/search/DataManagerSearch.java b/src/com/android/tv/search/DataManagerSearch.java
index d26ae334..5f89a21a 100644
--- a/src/com/android/tv/search/DataManagerSearch.java
+++ b/src/com/android/tv/search/DataManagerSearch.java
@@ -22,6 +22,7 @@ import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Programs;
import android.media.tv.TvInputManager;
+import android.os.SystemClock;
import android.support.annotation.MainThread;
import android.text.TextUtils;
import android.util.Log;
@@ -50,8 +51,8 @@ import java.util.concurrent.Future;
* and {@link ProgramDataManager}.
*/
public class DataManagerSearch implements SearchInterface {
- private static final boolean DEBUG = false;
private static final String TAG = "TvProviderSearch";
+ private static final boolean DEBUG = false;
private final Context mContext;
private final TvInputManager mTvInputManager;
@@ -98,6 +99,8 @@ public class DataManagerSearch implements SearchInterface {
// Voice search query should be handled by the a system TV app.
return results;
}
+ if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'");
+ long time = SystemClock.elapsedRealtime();
Set<Long> channelsFound = new HashSet<>();
List<Channel> channelList = mChannelDataManager.getBrowsableChannelList();
query = query.toLowerCase();
@@ -110,6 +113,11 @@ public class DataManagerSearch implements SearchInterface {
addResult(results, channelsFound, channel, null);
}
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" +
+ " searching channels: " + (SystemClock.elapsedRealtime() - time) +
+ "(msec)");
+ }
return results;
}
}
@@ -124,9 +132,21 @@ public class DataManagerSearch implements SearchInterface {
addResult(results, channelsFound, channel, null);
}
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" +
+ " searching channels: " + (SystemClock.elapsedRealtime() - time) +
+ "(msec)");
+ }
return results;
}
}
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for" +
+ " searching channels: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
+ int channelResult = results.size();
+ if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'");
+ time = SystemClock.elapsedRealtime();
for (Channel channel : channelList) {
if (channelsFound.contains(channel.getId())) {
continue;
@@ -140,6 +160,11 @@ public class DataManagerSearch implements SearchInterface {
addResult(results, channelsFound, channel, program);
}
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed" +
+ " time for searching programs: " +
+ (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return results;
}
}
@@ -156,9 +181,18 @@ public class DataManagerSearch implements SearchInterface {
addResult(results, channelsFound, channel, program);
}
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed" +
+ " time for searching programs: " +
+ (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return results;
}
}
+ if (DEBUG) {
+ Log.d(TAG, "Found " + (results.size() - channelResult) + " programs. Elapsed time for" +
+ " searching programs: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return results;
}
diff --git a/src/com/android/tv/search/LocalSearchProvider.java b/src/com/android/tv/search/LocalSearchProvider.java
index 7edb07dc..9255a43d 100644
--- a/src/com/android/tv/search/LocalSearchProvider.java
+++ b/src/com/android/tv/search/LocalSearchProvider.java
@@ -22,6 +22,7 @@ import android.content.ContentValues;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
+import android.os.SystemClock;
import android.text.TextUtils;
import android.util.Log;
@@ -32,8 +33,8 @@ import java.util.Arrays;
import java.util.List;
public class LocalSearchProvider extends ContentProvider {
- private static final boolean DEBUG = false;
private static final String TAG = "LocalSearchProvider";
+ private static final boolean DEBUG = false;
public static final int PROGRESS_PERCENTAGE_HIDE = -1;
@@ -76,10 +77,13 @@ public class LocalSearchProvider extends ContentProvider {
Log.d(TAG, "query(" + uri + ", " + Arrays.toString(projection) + ", " + selection + ", "
+ Arrays.toString(selectionArgs) + ", " + sortOrder + ")");
}
+ long time = SystemClock.elapsedRealtime();
SearchInterface search;
if (PermissionUtils.hasAccessAllEpg(getContext())) {
+ if (DEBUG) Log.d(TAG, "Performing TV Provider search.");
search = new TvProviderSearch(getContext());
} else {
+ if (DEBUG) Log.d(TAG, "Performing Data Manager search.");
search = new DataManagerSearch(getContext());
}
String query = uri.getLastPathSegment();
@@ -95,7 +99,9 @@ public class LocalSearchProvider extends ContentProvider {
if (!TextUtils.isEmpty(query)) {
results.addAll(search.search(query, limit, action));
}
- return createSuggestionsCursor(results);
+ Cursor c = createSuggestionsCursor(results);
+ if (DEBUG) Log.d(TAG, "Elapsed time: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ return c;
}
private Cursor createSuggestionsCursor(List<SearchResult> results) {
diff --git a/src/com/android/tv/search/TvProviderSearch.java b/src/com/android/tv/search/TvProviderSearch.java
index bd4ae5e5..3804f2de 100644
--- a/src/com/android/tv/search/TvProviderSearch.java
+++ b/src/com/android/tv/search/TvProviderSearch.java
@@ -28,6 +28,7 @@ import android.media.tv.TvContract.WatchedPrograms;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
import android.net.Uri;
+import android.os.SystemClock;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;
@@ -54,8 +55,8 @@ import java.util.Set;
* An implementation of {@link SearchInterface} to search query from TvProvider directly.
*/
public class TvProviderSearch implements SearchInterface {
- private static final boolean DEBUG = false;
private static final String TAG = "TvProviderSearch";
+ private static final boolean DEBUG = false;
private static final int NO_LIMIT = 0;
@@ -159,6 +160,8 @@ public class TvProviderSearch implements SearchInterface {
@WorkerThread
private List<SearchResult> searchChannels(String query, Set<Long> channels, int limit) {
+ if (DEBUG) Log.d(TAG, "Searching channels: '" + query + "'");
+ long time = SystemClock.elapsedRealtime();
List<SearchResult> results = new ArrayList<>();
if (TextUtils.isDigitsOnly(query)) {
results.addAll(searchChannels(query, new String[] { Channels.COLUMN_DISPLAY_NUMBER },
@@ -178,6 +181,10 @@ public class TvProviderSearch implements SearchInterface {
for (SearchResult result : results) {
fillProgramInfo(result);
}
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " channels. Elapsed time for searching" +
+ " channels: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return results;
}
@@ -305,6 +312,8 @@ public class TvProviderSearch implements SearchInterface {
@WorkerThread
private List<SearchResult> searchPrograms(String query, String[] columnForExactMatching,
String[] columnForPartialMatching, Set<Long> channelsFound, int limit) {
+ if (DEBUG) Log.d(TAG, "Searching programs: '" + query + "'");
+ long time = SystemClock.elapsedRealtime();
Assert.assertTrue(
(columnForExactMatching != null && columnForExactMatching.length > 0) ||
(columnForPartialMatching != null && columnForPartialMatching.length > 0));
@@ -395,6 +404,10 @@ public class TvProviderSearch implements SearchInterface {
}
}
}
+ if (DEBUG) {
+ Log.d(TAG, "Found " + searchResults.size() + " programs. Elapsed time for searching" +
+ " programs: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return searchResults;
}
@@ -420,9 +433,8 @@ public class TvProviderSearch implements SearchInterface {
}
private List<SearchResult> searchInputs(String query, int limit) {
- if (DEBUG) {
- Log.d(TAG, "searchInputs(" + query + ", limit=" + limit + ")");
- }
+ if (DEBUG) Log.d(TAG, "Searching inputs: '" + query + "'");
+ long time = SystemClock.elapsedRealtime();
query = canonicalizeLabel(query);
List<TvInputInfo> inputList = mTvInputManager.getTvInputList();
@@ -435,6 +447,11 @@ public class TvProviderSearch implements SearchInterface {
if (TextUtils.equals(query, label) || TextUtils.equals(query, customLabel)) {
results.add(buildSearchResultForInput(input.getId()));
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" +
+ " searching inputs: " + (SystemClock.elapsedRealtime() - time) +
+ "(msec)");
+ }
return results;
}
}
@@ -448,10 +465,19 @@ public class TvProviderSearch implements SearchInterface {
(customLabel != null && customLabel.contains(query))) {
results.add(buildSearchResultForInput(input.getId()));
if (results.size() >= limit) {
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for" +
+ " searching inputs: " + (SystemClock.elapsedRealtime() - time) +
+ "(msec)");
+ }
return results;
}
}
}
+ if (DEBUG) {
+ Log.d(TAG, "Found " + results.size() + " inputs. Elapsed time for searching" +
+ " inputs: " + (SystemClock.elapsedRealtime() - time) + "(msec)");
+ }
return results;
}
diff --git a/src/com/android/tv/setup/SystemSetupActivity.java b/src/com/android/tv/setup/SystemSetupActivity.java
new file mode 100644
index 00000000..7e627410
--- /dev/null
+++ b/src/com/android/tv/setup/SystemSetupActivity.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.setup;
+
+import android.app.Activity;
+import android.app.Fragment;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.media.tv.TvInputInfo;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
+import com.android.tv.SetupPassthroughActivity;
+import com.android.tv.common.TvCommonUtils;
+import com.android.tv.common.ui.setup.SetupActivity;
+import com.android.tv.common.ui.setup.SetupFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.TvApplication;
+import com.android.tv.data.ChannelDataManager;
+import com.android.tv.onboarding.SetupSourcesFragment;
+import com.android.tv.util.OnboardingUtils;
+import com.android.tv.util.SetupUtils;
+import com.android.tv.util.TvInputManagerHelper;
+
+/**
+ * A activity to start input sources setup fragment for initial setup flow.
+ */
+public class SystemSetupActivity extends SetupActivity {
+ private static final String SYSTEM_SETUP =
+ "com.android.tv.action.LAUNCH_SYSTEM_SETUP";
+ private static final int SHOW_RIPPLE_DURATION_MS = 266;
+ private static final int REQUEST_CODE_START_SETUP_ACTIVITY = 1;
+
+ private TvInputManagerHelper mInputManager;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Intent intent = getIntent();
+ if (!SYSTEM_SETUP.equals(intent.getAction())) {
+ finish();
+ return;
+ }
+ ApplicationSingletons singletons = TvApplication.getSingletons(this);
+ mInputManager = singletons.getTvInputManagerHelper();
+ }
+
+ @Override
+ protected Fragment onCreateInitialFragment() {
+ return new SetupSourcesFragment();
+ }
+
+ private void showMerchantCollection() {
+ executeActionWithDelay(new Runnable() {
+ @Override
+ public void run() {
+ startActivity(OnboardingUtils.ONLINE_STORE_INTENT);
+ }
+ }, SHOW_RIPPLE_DURATION_MS);
+ }
+
+ @Override
+ public boolean executeAction(String category, int actionId, Bundle params) {
+ switch (category) {
+ case SetupSourcesFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupSourcesFragment.ACTION_ONLINE_STORE:
+ showMerchantCollection();
+ return true;
+ case SetupSourcesFragment.ACTION_SETUP_INPUT: {
+ String inputId = params.getString(
+ SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID);
+ TvInputInfo input = mInputManager.getTvInputInfo(inputId);
+ Intent intent = TvCommonUtils.createSetupIntent(input);
+ if (intent == null) {
+ Toast.makeText(this, R.string.msg_no_setup_activity, Toast.LENGTH_SHORT)
+ .show();
+ return true;
+ }
+ // Even though other app can handle the intent, the setup launched by Live
+ // channels should go through Live channels SetupPassthroughActivity.
+ intent.setComponent(new ComponentName(this,
+ SetupPassthroughActivity.class));
+ try {
+ // Now we know that the user intends to set up this input. Grant
+ // permission for writing EPG data.
+ SetupUtils.grantEpgPermission(this, input.getServiceInfo().packageName);
+ startActivityForResult(intent, REQUEST_CODE_START_SETUP_ACTIVITY);
+ } catch (ActivityNotFoundException e) {
+ Toast.makeText(this,
+ getString(R.string.msg_unable_to_start_setup_activity,
+ input.loadLabel(this)), Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }
+ case SetupMultiPaneFragment.ACTION_DONE: {
+ // To make sure user can finish setup flow, set result as RESULT_OK.
+ setResult(Activity.RESULT_OK);
+ finish();
+ return true;
+ }
+ }
+ break;
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/tv/tuner/ChannelScanFileParser.java b/src/com/android/tv/tuner/ChannelScanFileParser.java
new file mode 100644
index 00000000..8b06aaa9
--- /dev/null
+++ b/src/com/android/tv/tuner/ChannelScanFileParser.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.util.Log;
+
+import com.android.tv.tuner.data.Channel;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parses plain text formatted scan files, which contain the list of channels.
+ */
+public class ChannelScanFileParser {
+ private static final String TAG = "ChannelScanFileParser";
+
+ public static final class ScanChannel {
+ public final int type;
+ public final int frequency;
+ public final String modulation;
+ public final String filename;
+ /**
+ * Radio frequency (channel) number specified at
+ * https://en.wikipedia.org/wiki/North_American_television_frequencies
+ * This can be {@code null} for cases like cable signal.
+ */
+ public final Integer radioFrequencyNumber;
+
+ public static ScanChannel forTuner(int frequency, String modulation,
+ Integer radioFrequencyNumber) {
+ return new ScanChannel(Channel.TYPE_TUNER, frequency, modulation, null,
+ radioFrequencyNumber);
+ }
+
+ public static ScanChannel forFile(int frequency, String filename) {
+ return new ScanChannel(Channel.TYPE_FILE, frequency, "file:", filename, null);
+ }
+
+ private ScanChannel(int type, int frequency, String modulation, String filename,
+ Integer radioFrequencyNumber) {
+ this.type = type;
+ this.frequency = frequency;
+ this.modulation = modulation;
+ this.filename = filename;
+ this.radioFrequencyNumber = radioFrequencyNumber;
+ }
+ }
+
+ /**
+ * Parses a given scan file and returns the list of {@link ScanChannel} objects.
+ *
+ * @param is {@link InputStream} of a scan file. Each line matches one channel.
+ * The line format of the scan file is as follows:<br>
+ * "A &lt;frequency&gt; &lt;modulation&gt;".
+ * @return a list of {@link ScanChannel} objects parsed
+ */
+ public static List<ScanChannel> parseScanFile(InputStream is) {
+ BufferedReader in = new BufferedReader(new InputStreamReader(is));
+ String line;
+ List<ScanChannel> scanChannelList = new ArrayList<>();
+ try {
+ while ((line = in.readLine()) != null) {
+ if (line.isEmpty()) {
+ continue;
+ }
+ if (line.charAt(0) == '#') {
+ // Skip comment line
+ continue;
+ }
+ String[] tokens = line.split("\\s+");
+ if (tokens.length != 3 && tokens.length != 4) {
+ continue;
+ }
+ if (!tokens[0].equals("A")) {
+ // Only support ATSC
+ continue;
+ }
+ scanChannelList.add(ScanChannel.forTuner(Integer.parseInt(tokens[1]), tokens[2],
+ tokens.length == 4 ? Integer.parseInt(tokens[3]) : null));
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "error on parseScanFile()", e);
+ }
+ return scanChannelList;
+ }
+}
diff --git a/src/com/android/tv/tuner/DvbDeviceAccessor.java b/src/com/android/tv/tuner/DvbDeviceAccessor.java
new file mode 100644
index 00000000..4f5d8ee4
--- /dev/null
+++ b/src/com/android/tv/tuner/DvbDeviceAccessor.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.Context;
+import android.media.tv.TvInputManager;
+import android.os.ParcelFileDescriptor;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import com.android.tv.common.recording.RecordingCapability;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Provides with the file descriptors to access DVB device.
+ */
+public class DvbDeviceAccessor {
+ private static final String TAG = "DvbDeviceAccessor";
+
+ @IntDef({DVB_DEVICE_DEMUX, DVB_DEVICE_DVR, DVB_DEVICE_FRONTEND})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DvbDevice {}
+ public static final int DVB_DEVICE_DEMUX = 0; // TvInputManager.DVB_DEVICE_DEMUX;
+ public static final int DVB_DEVICE_DVR = 1; // TvInputManager.DVB_DEVICE_DVR;
+ public static final int DVB_DEVICE_FRONTEND = 2; // TvInputManager.DVB_DEVICE_FRONTEND;
+
+ private static Method sGetDvbDeviceListMethod;
+ private static Method sOpenDvbDeviceMethod;
+
+ private final TvInputManager mTvInputManager;
+
+ static {
+ try {
+ Class tvInputManagerClass = Class.forName("android.media.tv.TvInputManager");
+ Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo");
+ sGetDvbDeviceListMethod = tvInputManagerClass.getDeclaredMethod("getDvbDeviceList");
+ sGetDvbDeviceListMethod.setAccessible(true);
+ sOpenDvbDeviceMethod = tvInputManagerClass.getDeclaredMethod(
+ "openDvbDevice", dvbDeviceInfoClass, Integer.TYPE);
+ sOpenDvbDeviceMethod.setAccessible(true);
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "Couldn't find class", e);
+ } catch (NoSuchMethodException e) {
+ Log.e(TAG, "Couldn't find method", e);
+ }
+ }
+
+ public DvbDeviceAccessor(Context context) {
+ mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
+ }
+
+ public List<DvbDeviceInfoWrapper> getDvbDeviceList() {
+ try {
+ List<DvbDeviceInfoWrapper> wrapperList = new ArrayList<>();
+ List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager);
+ for (Object dvbDeviceInfo : dvbDeviceInfoList) {
+ wrapperList.add(new DvbDeviceInfoWrapper(dvbDeviceInfo));
+ }
+ Collections.sort(wrapperList);
+ return wrapperList;
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the number of currently connected DVB devices.
+ */
+ public int getNumOfDvbDevices() {
+ List<DvbDeviceInfoWrapper> dvbDeviceList = getDvbDeviceList();
+ return dvbDeviceList == null ? 0 : dvbDeviceList.size();
+ }
+
+ public boolean isDvbDeviceAvailable() {
+ try {
+ List dvbDeviceInfoList = (List) sGetDvbDeviceListMethod.invoke(mTvInputManager);
+ return (!dvbDeviceInfoList.isEmpty());
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return false;
+ }
+
+ public ParcelFileDescriptor openDvbDevice(DvbDeviceInfoWrapper deviceInfo,
+ @DvbDevice int device) {
+ try {
+ return (ParcelFileDescriptor) sOpenDvbDeviceMethod.invoke(
+ mTvInputManager, deviceInfo.getDvbDeviceInfo(), device);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ }
+ return null;
+ }
+
+ /**
+ * Returns the current recording capability for USB tuner.
+ * @param inputId the input id to use.
+ */
+ public RecordingCapability getRecordingCapability(String inputId) {
+ List<DvbDeviceInfoWrapper> deviceList = getDvbDeviceList();
+ // TODO(DVR) implement accurate capabilities and updating values when needed.
+ return RecordingCapability.builder()
+ .setInputId(inputId)
+ .setMaxConcurrentPlayingSessions(1)
+ .setMaxConcurrentTunedSessions(deviceList.size())
+ .setMaxConcurrentSessionsOfAllTypes(deviceList.size() + 1)
+ .build();
+ }
+
+ public static class DvbDeviceInfoWrapper implements Comparable<DvbDeviceInfoWrapper> {
+ private static Method sGetAdapterIdMethod;
+ private static Method sGetDeviceIdMethod;
+ private final Object mDvbDeviceInfo;
+ private final int mAdapterId;
+ private final int mDeviceId;
+ private final long mId;
+
+ static {
+ try {
+ Class dvbDeviceInfoClass = Class.forName("android.media.tv.DvbDeviceInfo");
+ sGetAdapterIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getAdapterId");
+ sGetAdapterIdMethod.setAccessible(true);
+ sGetDeviceIdMethod = dvbDeviceInfoClass.getDeclaredMethod("getDeviceId");
+ sGetDeviceIdMethod.setAccessible(true);
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "Couldn't find class", e);
+ } catch (NoSuchMethodException e) {
+ Log.e(TAG, "Couldn't find method", e);
+ }
+ }
+
+ public DvbDeviceInfoWrapper(Object dvbDeviceInfo) {
+ mDvbDeviceInfo = dvbDeviceInfo;
+ mAdapterId = initAdapterId();
+ mDeviceId = initDeviceId();
+ mId = (((long) getAdapterId()) << 32) | (getDeviceId() & 0xffffffffL);
+ }
+
+ public long getId() {
+ return mId;
+ }
+
+ public int getAdapterId() {
+ return mAdapterId;
+ }
+
+ private int initAdapterId() {
+ try {
+ return (int) sGetAdapterIdMethod.invoke(mDvbDeviceInfo);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ }
+ return -1;
+ }
+
+ public int getDeviceId() {
+ return mDeviceId;
+ }
+
+ private int initDeviceId() {
+ try {
+ return (int) sGetDeviceIdMethod.invoke(mDvbDeviceInfo);
+ } catch (InvocationTargetException e) {
+ Log.e(TAG, "Couldn't invoke", e);
+ } catch (IllegalAccessException e) {
+ Log.e(TAG, "Couldn't access", e);
+ }
+ return -1;
+ }
+
+ public Object getDvbDeviceInfo() {
+ return mDvbDeviceInfo;
+ }
+
+ @Override
+ public int compareTo(@NonNull DvbDeviceInfoWrapper another) {
+ if (getAdapterId() != another.getAdapterId()) {
+ return getAdapterId() - another.getAdapterId();
+ }
+ return getDeviceId() - another.getDeviceId();
+ }
+
+ @Override
+ public String toString() {
+ return String.format(Locale.US, "DvbDeviceInfo {adapterId: %d, deviceId: %d}",
+ getAdapterId(),
+ getDeviceId());
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/TunerHal.java b/src/com/android/tv/tuner/TunerHal.java
new file mode 100644
index 00000000..de19766e
--- /dev/null
+++ b/src/com/android/tv/tuner/TunerHal.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.Context;
+import android.support.annotation.IntDef;
+import android.support.annotation.StringDef;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/**
+ * A base class to handle a hardware tuner device.
+ */
+public abstract class TunerHal implements AutoCloseable {
+ protected static final String TAG = "TunerHal";
+ protected static final boolean DEBUG = false;
+
+ @IntDef({ FILTER_TYPE_OTHER, FILTER_TYPE_AUDIO, FILTER_TYPE_VIDEO, FILTER_TYPE_PCR })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface FilterType {}
+ public static final int FILTER_TYPE_OTHER = 0;
+ public static final int FILTER_TYPE_AUDIO = 1;
+ public static final int FILTER_TYPE_VIDEO = 2;
+ public static final int FILTER_TYPE_PCR = 3;
+
+ @StringDef({ MODULATION_8VSB, MODULATION_QAM256 })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ModulationType {}
+ public static final String MODULATION_8VSB = "8VSB";
+ public static final String MODULATION_QAM256 = "QAM256";
+
+ public static final int TUNER_TYPE_BUILT_IN = 1;
+ public static final int TUNER_TYPE_USB = 2;
+
+ protected static final int PID_PAT = 0;
+ protected static final int PID_ATSC_SI_BASE = 0x1ffb;
+ protected static final int DEFAULT_VSB_TUNE_TIMEOUT_MS = 2000;
+ protected static final int DEFAULT_QAM_TUNE_TIMEOUT_MS = 4000; // Some device takes time for
+ // QAM256 tuning.
+ private boolean mIsStreaming;
+ private int mFrequency;
+ private String mModulation;
+
+ static {
+ System.loadLibrary("tunertvinput_jni");
+ }
+
+ /**
+ * Creates a TunerHal instance.
+ * @param context context for creating the TunerHal instance
+ * @return the TunerHal instance
+ */
+ public synchronized static TunerHal createInstance(Context context) {
+ TunerHal tunerHal = null;
+ if (getTunerType(context) == TUNER_TYPE_BUILT_IN) {
+ }
+ if (tunerHal == null) {
+ tunerHal = new UsbTunerHal(context);
+ }
+ if (tunerHal.openFirstAvailable()) {
+ return tunerHal;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the number of tuner devices currently present.
+ */
+ public static int getTunerCount(Context context) {
+ if (getTunerType(context) == TUNER_TYPE_BUILT_IN) {
+ }
+ return UsbTunerHal.getNumberOfDevices(context);
+ }
+
+ /**
+ * Gets the type of tuner devices currently used.
+ */
+ public static int getTunerType(Context context) {
+ return TUNER_TYPE_USB;
+ }
+
+ protected TunerHal(Context context) {
+ mIsStreaming = false;
+ mFrequency = -1;
+ mModulation = null;
+ }
+
+ protected boolean isStreaming() {
+ return mIsStreaming;
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ super.finalize();
+ close();
+ }
+
+ protected native void nativeFinalize(long deviceId);
+
+ /**
+ * Acquires the first available tuner device. If there is a tuner device that is available, the
+ * tuner device will be locked to the current instance.
+ *
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ protected abstract boolean openFirstAvailable();
+
+ protected abstract boolean isDeviceOpen();
+
+ protected abstract long getDeviceId();
+
+ /**
+ * Sets the tuner channel. This should be called after acquiring a tuner device.
+ *
+ * @param frequency a frequency of the channel to tune to
+ * @param modulation a modulation method of the channel to tune to
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ public synchronized boolean tune(int frequency, @ModulationType String modulation) {
+ if (!isDeviceOpen()) {
+ Log.e(TAG, "There's no available device");
+ return false;
+ }
+ if (mIsStreaming) {
+ nativeCloseAllPidFilters(getDeviceId());
+ mIsStreaming = false;
+ }
+
+ // When tuning to a new channel in the same frequency, there's no need to stop current tuner
+ // device completely and the only thing necessary for tuning is reopening pid filters.
+ if (mFrequency == frequency && Objects.equals(mModulation, modulation)) {
+ addPidFilter(PID_PAT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER);
+ mIsStreaming = true;
+ return true;
+ }
+ int timeout_ms = modulation.equals(MODULATION_8VSB) ? DEFAULT_VSB_TUNE_TIMEOUT_MS
+ : DEFAULT_QAM_TUNE_TIMEOUT_MS;
+ if (nativeTune(getDeviceId(), frequency, modulation, timeout_ms)) {
+ addPidFilter(PID_PAT, FILTER_TYPE_OTHER);
+ addPidFilter(PID_ATSC_SI_BASE, FILTER_TYPE_OTHER);
+ mFrequency = frequency;
+ mModulation = modulation;
+ mIsStreaming = true;
+ return true;
+ }
+ return false;
+ }
+
+ protected native boolean nativeTune(long deviceId, int frequency,
+ @ModulationType String modulation, int timeout_ms);
+
+ /**
+ * Sets a pid filter. This should be set after setting a channel.
+ *
+ * @param pid a pid number to be added to filter list
+ * @param filterType a type of pid. Must be one of (FILTER_TYPE_XXX)
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ public synchronized boolean addPidFilter(int pid, @FilterType int filterType) {
+ if (!isDeviceOpen()) {
+ Log.e(TAG, "There's no available device");
+ return false;
+ }
+ if (pid >= 0 && pid <= 0x1fff) {
+ nativeAddPidFilter(getDeviceId(), pid, filterType);
+ return true;
+ }
+ return false;
+ }
+
+ protected native void nativeAddPidFilter(long deviceId, int pid, @FilterType int filterType);
+ protected native void nativeCloseAllPidFilters(long deviceId);
+ protected native void nativeSetHasPendingTune(long deviceId, boolean hasPendingTune);
+
+ /**
+ * Stops current tuning. The tuner device and pid filters will be reset by this call and make
+ * the tuner ready to accept another tune request.
+ */
+ public synchronized void stopTune() {
+ if (isDeviceOpen()) {
+ if (mIsStreaming) {
+ nativeCloseAllPidFilters(getDeviceId());
+ }
+ nativeStopTune(getDeviceId());
+ }
+ mIsStreaming = false;
+ mFrequency = -1;
+ mModulation = null;
+ }
+
+ public void setHasPendingTune(boolean hasPendingTune) {
+ nativeSetHasPendingTune(getDeviceId(), hasPendingTune);
+ }
+
+ protected native void nativeStopTune(long deviceId);
+
+ /**
+ * This method must be called after {@link TunerHal#tune} and before
+ * {@link TunerHal#stopTune}. Writes at most maxSize TS frames in a buffer
+ * provided by the user. The frames employ MPEG encoding.
+ *
+ * @param javaBuffer a buffer to write the video data in
+ * @param javaBufferSize the max amount of bytes to write in this buffer. Usually this number
+ * should be equal to the length of the buffer.
+ * @return the amount of bytes written in the buffer. Note that this value could be 0 if no new
+ * frames have been obtained since the last call.
+ */
+ public synchronized int readTsStream(byte[] javaBuffer, int javaBufferSize) {
+ if (isDeviceOpen()) {
+ return nativeWriteInBuffer(getDeviceId(), javaBuffer, javaBufferSize);
+ } else {
+ return 0;
+ }
+ }
+
+ protected native int nativeWriteInBuffer(long deviceId, byte[] javaBuffer, int javaBufferSize);
+
+ /**
+ * Opens Linux DVB frontend device. This method is called from native JNI and used only for
+ * UsbTunerHal.
+ */
+ protected int openDvbFrontEndFd() {
+ return -1;
+ }
+
+ /**
+ * Opens Linux DVB demux device. This method is called from native JNI and used only for
+ * UsbTunerHal.
+ */
+ protected int openDvbDemuxFd() {
+ return -1;
+ }
+
+ /**
+ * Opens Linux DVB dvr device. This method is called from native JNI and used only for
+ * UsbTunerHal.
+ */
+ protected int openDvbDvrFd() {
+ return -1;
+ }
+}
diff --git a/src/com/android/tv/tuner/TunerInputController.java b/src/com/android/tv/tuner/TunerInputController.java
new file mode 100644
index 00000000..d89b6a0c
--- /dev/null
+++ b/src/com/android/tv/tuner/TunerInputController.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbManager;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.support.v4.os.BuildCompat;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.tv.Features;
+import com.android.tv.TvApplication;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.setup.TunerSetupActivity;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+
+import java.util.Map;
+
+/**
+ * Controls the package visibility of {@link TunerTvInputService}.
+ * <p>
+ * Listens to broadcast intent for {@link Intent#ACTION_BOOT_COMPLETED},
+ * {@code UsbManager.ACTION_USB_DEVICE_ATTACHED}, and {@code UsbManager.ACTION_USB_DEVICE_ATTACHED}
+ * to update the connection status of the supported USB TV tuners.
+ */
+public class TunerInputController extends BroadcastReceiver {
+ private static final boolean DEBUG = true;
+ private static final String TAG = "TunerInputController";
+
+ private static final TunerDevice[] TUNER_DEVICES = {
+ new TunerDevice(0x2040, 0xb123), // WinTV-HVR-955Q
+ new TunerDevice(0x07ca, 0x0837) // AverTV Volar Hybrid Q
+ };
+
+ private static final int MSG_ENABLE_INPUT_SERVICE = 1000;
+ private static final long DVB_DRIVER_CHECK_DELAY_MS = 300;
+
+ private DvbDeviceAccessor mDvbDeviceAccessor;
+ private final Handler mHandler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_ENABLE_INPUT_SERVICE:
+ Context context = (Context) msg.obj;
+ if (mDvbDeviceAccessor == null) {
+ mDvbDeviceAccessor = new DvbDeviceAccessor(context);
+ }
+ enableTunerTvInputService(context, mDvbDeviceAccessor.isDvbDeviceAvailable());
+ break;
+ }
+ }
+ };
+
+ /**
+ * Simple data holder for a USB device. Used to represent a tuner model, and compare
+ * against {@link UsbDevice}.
+ */
+ private static class TunerDevice {
+ private final int vendorId;
+ private final int productId;
+
+ private TunerDevice(int vendorId, int productId) {
+ this.vendorId = vendorId;
+ this.productId = productId;
+ }
+
+ private boolean equals(UsbDevice device) {
+ return device.getVendorId() == vendorId && device.getProductId() == productId;
+ }
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (DEBUG) Log.d(TAG, "Broadcast intent received:" + intent);
+ TvApplication.setCurrentRunningProcess(context, true);
+ if (!Features.TUNER.isEnabled(context)) {
+ enableTunerTvInputService(context, false);
+ return;
+ }
+
+ switch (intent.getAction()) {
+ case Intent.ACTION_BOOT_COMPLETED:
+ case TvApplication.ACTION_APPLICATION_FIRST_LAUNCHED:
+ case UsbManager.ACTION_USB_DEVICE_ATTACHED:
+ case UsbManager.ACTION_USB_DEVICE_DETACHED:
+ if (TunerInputInfoUtils.isBuiltInTuner(context)) {
+ enableTunerTvInputService(context, true);
+ break;
+ }
+ // Falls back to the below to check USB tuner devices.
+ boolean enabled = isUsbTunerConnected(context);
+ mHandler.removeMessages(MSG_ENABLE_INPUT_SERVICE);
+ if (enabled) {
+ // Need to check if DVB driver is accessible. Since the driver creation
+ // could be happen after the USB event, delay the checking by
+ // DVB_DRIVER_CHECK_DELAY_MS.
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(MSG_ENABLE_INPUT_SERVICE, context),
+ DVB_DRIVER_CHECK_DELAY_MS);
+ } else {
+ enableTunerTvInputService(context, false);
+ }
+ break;
+ }
+ }
+
+ /**
+ * See if any USB tuner hardware is attached in the system.
+ *
+ * @param context {@link Context} instance
+ * @return {@code true} if any tuner device we support is plugged in
+ */
+ private boolean isUsbTunerConnected(Context context) {
+ UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
+ Map<String, UsbDevice> deviceList = manager.getDeviceList();
+ for (UsbDevice device : deviceList.values()) {
+ if (DEBUG) {
+ Log.d(TAG, "Device: " + device);
+ }
+ for (TunerDevice tuner : TUNER_DEVICES) {
+ if (tuner.equals(device)) {
+ Log.i(TAG, "Tuner found");
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Enable/disable the component {@link TunerTvInputService}.
+ *
+ * @param context {@link Context} instance
+ * @param enabled {@code true} to enable the service; otherwise {@code false}
+ */
+ private void enableTunerTvInputService(Context context, boolean enabled) {
+ if (DEBUG) Log.d(TAG, "enableTunerTvInputService: " + enabled);
+ PackageManager pm = context.getPackageManager();
+ ComponentName componentName = new ComponentName(context, TunerTvInputService.class);
+
+ // Don't kill app by enabling/disabling TvActivity. If LC is killed by enabling/disabling
+ // TvActivity, the following pm.setComponentEnabledSetting doesn't work.
+ ((TvApplication) context.getApplicationContext()).handleInputCountChanged(
+ true, enabled, true);
+ // Since PackageManager.DONT_KILL_APP delays the operation by 10 seconds
+ // (PackageManagerService.BROADCAST_DELAY), we'd better avoid using it. It is used only
+ // when the LiveChannels app is active since we don't want to kill the running app.
+ int flags = TvApplication.getSingletons(context).getMainActivityWrapper().isCreated()
+ ? PackageManager.DONT_KILL_APP : 0;
+ int newState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+ : PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+ if (newState != pm.getComponentEnabledSetting(componentName)) {
+ // Send/cancel the USB tuner TV input setup recommendation card.
+ TunerSetupActivity.onTvInputEnabled(context, enabled);
+ // Enable/disable the USB tuner TV input.
+ pm.setComponentEnabledSetting(componentName, newState, flags);
+ if (!enabled) {
+ Toast.makeText(
+ context, R.string.msg_usb_device_detached, Toast.LENGTH_SHORT).show();
+ }
+ if (DEBUG) Log.d(TAG, "Status updated:" + enabled);
+ } else if (enabled) {
+ // When # of USB tuners is changed or the device just boots.
+ TunerInputInfoUtils.updateTunerInputInfo(context);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/TunerPreferenceProvider.java b/src/com/android/tv/tuner/TunerPreferenceProvider.java
new file mode 100644
index 00000000..3a3561b6
--- /dev/null
+++ b/src/com/android/tv/tuner/TunerPreferenceProvider.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.net.Uri;
+
+/**
+ * A content provider for storing the preferences. It's used across TV app and USB tuner TV input.
+ */
+public class TunerPreferenceProvider extends ContentProvider {
+ /** The authority of the provider */
+ public static final String AUTHORITY = "com.android.tv.tuner.preferences";
+
+ private static final String PATH_PREFERENCES = "preferences";
+
+ private static final int DATABASE_VERSION = 1;
+ private static final String DATABASE_NAME = "usbtuner_preferences.db";
+ private static final String PREFERENCES_TABLE = "preferences";
+
+ private static final int MATCH_PREFERENCE = 1;
+ private static final int MATCH_PREFERENCE_KEY = 2;
+
+ private static final UriMatcher sUriMatcher;
+
+ private DatabaseOpenHelper mDatabaseOpenHelper;
+
+ static {
+ sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+ sUriMatcher.addURI(AUTHORITY, "preferences", MATCH_PREFERENCE);
+ sUriMatcher.addURI(AUTHORITY, "preferences/*", MATCH_PREFERENCE_KEY);
+ }
+
+ /**
+ * Builds a Uri that points to a specific preference.
+
+ * @param key a key of the preference to point to
+ */
+ public static Uri buildPreferenceUri(String key) {
+ return Preferences.CONTENT_URI.buildUpon().appendPath(key).build();
+ }
+
+ /**
+ * Columns definitions for the preferences table.
+ */
+ public interface Preferences {
+
+ /**
+ * The content:// style for the preferences table.
+ */
+ Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + PATH_PREFERENCES);
+
+ /**
+ * The MIME type of a directory of preferences.
+ */
+ String CONTENT_TYPE = "vnd.android.cursor.dir/preferences";
+
+ /**
+ * The MIME type of a single preference.
+ */
+ String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/preferences";
+
+ /**
+ * The ID of this preference.
+ *
+ * <p>This is auto-incremented.
+ *
+ * <p>Type: INTEGER
+ */
+ String _ID = "_id";
+
+ /**
+ * The key of this preference.
+ *
+ * <p>Should be unique.
+ *
+ * <p>Type: TEXT
+ */
+ String COLUMN_KEY = "key";
+
+ /**
+ * The value of this preference.
+ *
+ * <p>Type: TEXT
+ */
+ String COLUMN_VALUE = "value";
+ }
+
+ private static class DatabaseOpenHelper extends SQLiteOpenHelper {
+ public DatabaseOpenHelper(Context context) {
+ super(context, DATABASE_NAME, null, DATABASE_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL("CREATE TABLE " + PREFERENCES_TABLE + " ("
+ + Preferences._ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + Preferences.COLUMN_KEY + " TEXT NOT NULL,"
+ + Preferences.COLUMN_VALUE + " TEXT);");
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // No-op
+ }
+ }
+
+ @Override
+ public boolean onCreate() {
+ mDatabaseOpenHelper = new DatabaseOpenHelper(getContext());
+ return true;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ int match = sUriMatcher.match(uri);
+ if (match != MATCH_PREFERENCE && match != MATCH_PREFERENCE_KEY) {
+ throw new UnsupportedOperationException();
+ }
+ SQLiteDatabase db = mDatabaseOpenHelper.getReadableDatabase();
+ Cursor cursor = db.query(PREFERENCES_TABLE, projection, selection, selectionArgs,
+ null, null, sortOrder);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return cursor;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ switch (sUriMatcher.match(uri)) {
+ case MATCH_PREFERENCE:
+ return Preferences.CONTENT_TYPE;
+ case MATCH_PREFERENCE_KEY:
+ return Preferences.CONTENT_ITEM_TYPE;
+ }
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ /**
+ * Inserts a preference row into the preference table.
+ *
+ * If a key is already exists in the table, it removes the old row and inserts a new row.
+ *
+ * @param uri the URL of the table to insert into
+ * @param values the initial values for the newly inserted row
+ * @return the URL of the newly created row
+ */
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ if (sUriMatcher.match(uri) != MATCH_PREFERENCE) {
+ throw new UnsupportedOperationException();
+ }
+ return insertRow(uri, values);
+ }
+
+ private Uri insertRow(Uri uri, ContentValues values) {
+ SQLiteDatabase db = mDatabaseOpenHelper.getWritableDatabase();
+
+ // Remove the old row.
+ db.delete(PREFERENCES_TABLE, Preferences.COLUMN_KEY + " like ?",
+ new String[]{values.getAsString(Preferences.COLUMN_KEY)});
+
+ long rowId = db.insert(PREFERENCES_TABLE, null, values);
+ if (rowId > 0) {
+ Uri rowUri = buildPreferenceUri(values.getAsString(Preferences.COLUMN_KEY));
+ getContext().getContentResolver().notifyChange(rowUri, null);
+ return rowUri;
+ }
+
+ throw new SQLiteException("Failed to insert row into " + uri);
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/src/com/android/tv/tuner/TunerPreferences.java b/src/com/android/tv/tuner/TunerPreferences.java
new file mode 100644
index 00000000..1547e3ae
--- /dev/null
+++ b/src/com/android/tv/tuner/TunerPreferences.java
@@ -0,0 +1,310 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.annotation.MainThread;
+
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.TunerPreferenceProvider.Preferences;
+import com.android.tv.tuner.util.TisConfiguration;
+
+/**
+ * A helper class for the USB tuner preferences.
+ */
+public class TunerPreferences {
+ private static final String TAG = "TunerPreferences";
+
+ private static final String PREFS_KEY_CHANNEL_DATA_VERSION = "channel_data_version";
+ private static final String PREFS_KEY_SCANNED_CHANNEL_COUNT = "scanned_channel_count";
+ private static final String PREFS_KEY_SCAN_DONE = "scan_done";
+ private static final String PREFS_KEY_LAUNCH_SETUP = "launch_setup";
+ private static final String PREFS_KEY_STORE_TS_STREAM = "store_ts_stream";
+
+ private static final String SHARED_PREFS_NAME = "com.android.tv.tuner.preferences";
+
+ public static final int CHANNEL_DATA_VERSION_NOT_SET = -1;
+
+ private static final Bundle sPreferenceValues = new Bundle();
+ private static LoadPreferencesTask sLoadPreferencesTask;
+ private static ContentObserver sContentObserver;
+
+ private static boolean sInitialized;
+
+ /**
+ * Initializes the USB tuner preferences.
+ */
+ @MainThread
+ public static void initialize(final Context context) {
+ if (sInitialized) {
+ return;
+ }
+ sInitialized = true;
+ if (useContentProvider(context)) {
+ loadPreferences(context);
+ sContentObserver = new ContentObserver(new Handler()) {
+ @Override
+ public void onChange(boolean selfChange) {
+ loadPreferences(context);
+ }
+ };
+ context.getContentResolver().registerContentObserver(
+ TunerPreferenceProvider.Preferences.CONTENT_URI, true, sContentObserver);
+ } else {
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ getSharedPreferences(context);
+ return null;
+ }
+ }.execute();
+ }
+ }
+
+ /**
+ * Releases the resources.
+ */
+ @MainThread
+ public static void release(Context context) {
+ if (useContentProvider(context) && sContentObserver != null) {
+ context.getContentResolver().unregisterContentObserver(sContentObserver);
+ }
+ }
+
+ /**
+ * Loads the preferences from database.
+ * <p>
+ * This preferences is used across processes, so the preferences should be loaded again when the
+ * databases changes.
+ */
+ public static synchronized void loadPreferences(Context context) {
+ if (sLoadPreferencesTask != null
+ && sLoadPreferencesTask.getStatus() != AsyncTask.Status.FINISHED) {
+ sLoadPreferencesTask.cancel(true);
+ }
+ sLoadPreferencesTask = new LoadPreferencesTask(context);
+ sLoadPreferencesTask.execute();
+ }
+
+ private static boolean useContentProvider(Context context) {
+ // If TIS is a part of LC, it should use ContentProvider to resolve multiple process access.
+ return TisConfiguration.isPackagedWithLiveChannels(context);
+ }
+
+ @MainThread
+ public static int getChannelDataVersion(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ if (useContentProvider(context)) {
+ return sPreferenceValues.getInt(PREFS_KEY_CHANNEL_DATA_VERSION,
+ CHANNEL_DATA_VERSION_NOT_SET);
+ } else {
+ return getSharedPreferences(context)
+ .getInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION,
+ CHANNEL_DATA_VERSION_NOT_SET);
+ }
+ }
+
+ @MainThread
+ public static void setChannelDataVersion(Context context, int version) {
+ if (useContentProvider(context)) {
+ setPreference(context, PREFS_KEY_CHANNEL_DATA_VERSION, version);
+ } else {
+ getSharedPreferences(context).edit()
+ .putInt(TunerPreferences.PREFS_KEY_CHANNEL_DATA_VERSION, version)
+ .apply();
+ }
+ }
+
+ @MainThread
+ public static int getScannedChannelCount(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ if (useContentProvider(context)) {
+ return sPreferenceValues.getInt(PREFS_KEY_SCANNED_CHANNEL_COUNT);
+ } else {
+ return getSharedPreferences(context)
+ .getInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, 0);
+ }
+ }
+
+ @MainThread
+ public static void setScannedChannelCount(Context context, int channelCount) {
+ if (useContentProvider(context)) {
+ setPreference(context, PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount);
+ } else {
+ getSharedPreferences(context).edit()
+ .putInt(TunerPreferences.PREFS_KEY_SCANNED_CHANNEL_COUNT, channelCount)
+ .apply();
+ }
+ }
+
+ @MainThread
+ public static boolean isScanDone(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ if (useContentProvider(context)) {
+ return sPreferenceValues.getBoolean(PREFS_KEY_SCAN_DONE);
+ } else {
+ return getSharedPreferences(context)
+ .getBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, false);
+ }
+ }
+
+ @MainThread
+ public static void setScanDone(Context context) {
+ if (useContentProvider(context)) {
+ setPreference(context, PREFS_KEY_SCAN_DONE, true);
+ } else {
+ getSharedPreferences(context).edit()
+ .putBoolean(TunerPreferences.PREFS_KEY_SCAN_DONE, true)
+ .apply();
+ }
+ }
+
+ @MainThread
+ public static boolean shouldShowSetupActivity(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ if (useContentProvider(context)) {
+ return sPreferenceValues.getBoolean(PREFS_KEY_LAUNCH_SETUP);
+ } else {
+ return getSharedPreferences(context)
+ .getBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, false);
+ }
+ }
+
+ @MainThread
+ public static void setShouldShowSetupActivity(Context context, boolean need) {
+ if (useContentProvider(context)) {
+ setPreference(context, PREFS_KEY_LAUNCH_SETUP, need);
+ } else {
+ getSharedPreferences(context).edit()
+ .putBoolean(TunerPreferences.PREFS_KEY_LAUNCH_SETUP, need)
+ .apply();
+ }
+ }
+
+ @MainThread
+ public static boolean getStoreTsStream(Context context) {
+ SoftPreconditions.checkState(sInitialized);
+ if (useContentProvider(context)) {
+ return sPreferenceValues.getBoolean(PREFS_KEY_STORE_TS_STREAM, false);
+ } else {
+ return getSharedPreferences(context)
+ .getBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, false);
+ }
+ }
+
+ @MainThread
+ public static void setStoreTsStream(Context context, boolean shouldStore) {
+ if (useContentProvider(context)) {
+ setPreference(context, PREFS_KEY_STORE_TS_STREAM, shouldStore);
+ } else {
+ getSharedPreferences(context).edit()
+ .putBoolean(TunerPreferences.PREFS_KEY_STORE_TS_STREAM, shouldStore)
+ .apply();
+ }
+ }
+
+ private static SharedPreferences getSharedPreferences(Context context) {
+ return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
+ }
+
+ @MainThread
+ private static void setPreference(final Context context, final String key, final String value) {
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ ContentResolver resolver = context.getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(Preferences.COLUMN_KEY, key);
+ values.put(Preferences.COLUMN_VALUE, value);
+ try {
+ resolver.insert(Preferences.CONTENT_URI, values);
+ } catch (Exception e) {
+ SoftPreconditions.warn(TAG, "setPreference", "Error writing preference values",
+ e);
+ }
+ return null;
+ }
+ }.execute();
+ }
+
+ @MainThread
+ private static void setPreference(Context context, String key, int value) {
+ sPreferenceValues.putInt(key, value);
+ setPreference(context, key, Integer.toString(value));
+ }
+
+ @MainThread
+ private static void setPreference(Context context, String key, boolean value) {
+ sPreferenceValues.putBoolean(key, value);
+ setPreference(context, key, Boolean.toString(value));
+ }
+
+ private static class LoadPreferencesTask extends AsyncTask<Void, Void, Bundle> {
+ private final Context mContext;
+ private LoadPreferencesTask(Context context) {
+ mContext = context;
+ }
+
+ @Override
+ protected Bundle doInBackground(Void... params) {
+ Bundle bundle = new Bundle();
+ ContentResolver resolver = mContext.getContentResolver();
+ String[] projection = new String[] { Preferences.COLUMN_KEY, Preferences.COLUMN_VALUE };
+ try (Cursor cursor = resolver.query(Preferences.CONTENT_URI, projection, null, null,
+ null)) {
+ if (cursor != null) {
+ while (!isCancelled() && cursor.moveToNext()) {
+ String key = cursor.getString(0);
+ String value = cursor.getString(1);
+ switch (key) {
+ case PREFS_KEY_CHANNEL_DATA_VERSION:
+ case PREFS_KEY_SCANNED_CHANNEL_COUNT:
+ try {
+ bundle.putInt(key, Integer.parseInt(value));
+ } catch (NumberFormatException e) {
+ // Does nothing.
+ }
+ break;
+ case PREFS_KEY_SCAN_DONE:
+ case PREFS_KEY_LAUNCH_SETUP:
+ case PREFS_KEY_STORE_TS_STREAM:
+ bundle.putBoolean(key, Boolean.parseBoolean(value));
+ break;
+ }
+ }
+ }
+ } catch (Exception e) {
+ SoftPreconditions.warn(TAG, "getPreference", "Error querying preference values", e);
+ return null;
+ }
+ return bundle;
+ }
+
+ @Override
+ protected void onPostExecute(Bundle bundle) {
+ sPreferenceValues.putAll(bundle);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/UsbTunerHal.java b/src/com/android/tv/tuner/UsbTunerHal.java
new file mode 100644
index 00000000..22e35ea1
--- /dev/null
+++ b/src/com/android/tv/tuner/UsbTunerHal.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner;
+
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+import com.android.tv.tuner.DvbDeviceAccessor.DvbDeviceInfoWrapper;
+
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+/**
+ * A class to handle a hardware USB tuner device.
+ */
+public class UsbTunerHal extends TunerHal {
+
+ private static final Object sLock = new Object();
+ // @GuardedBy("sLock")
+ private static final SortedSet<DvbDeviceInfoWrapper> sUsedDvbDevices = new TreeSet<>();
+
+ private final DvbDeviceAccessor mDvbDeviceAccessor;
+ private DvbDeviceInfoWrapper mDvbDeviceInfo;
+
+ protected UsbTunerHal(Context context) {
+ super(context);
+ mDvbDeviceAccessor = new DvbDeviceAccessor(context);
+ }
+
+ @Override
+ protected boolean openFirstAvailable() {
+ List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList();
+ if (deviceInfoList == null || deviceInfoList.isEmpty()) {
+ Log.e(TAG, "There's no dvb device attached");
+ return false;
+ }
+ synchronized (sLock) {
+ for (DvbDeviceInfoWrapper deviceInfo : deviceInfoList) {
+ if (!sUsedDvbDevices.contains(deviceInfo)) {
+ if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo);
+ mDvbDeviceInfo = deviceInfo;
+ sUsedDvbDevices.add(deviceInfo);
+ return true;
+ }
+ }
+ }
+ Log.e(TAG, "There's no available dvb devices");
+ return false;
+ }
+
+ /**
+ * Acquires the tuner device. The requested device will be locked to the current instance if
+ * it's not acquired by others.
+ *
+ * @param deviceInfo a tuner device to open
+ * @return {@code true} if the operation was successful, {@code false} otherwise
+ */
+ protected boolean open(DvbDeviceInfoWrapper deviceInfo) {
+ if (deviceInfo == null) {
+ Log.e(TAG, "Device info should not be null");
+ return false;
+ }
+ if (mDvbDeviceInfo != null) {
+ Log.e(TAG, "Already acquired");
+ return false;
+ }
+ List<DvbDeviceInfoWrapper> deviceInfoList = mDvbDeviceAccessor.getDvbDeviceList();
+ if (deviceInfoList == null || deviceInfoList.isEmpty()) {
+ Log.e(TAG, "There's no dvb device attached");
+ return false;
+ }
+ for (DvbDeviceInfoWrapper deviceInfoWrapper : deviceInfoList) {
+ if (deviceInfoWrapper.compareTo(deviceInfo) == 0) {
+ synchronized (sLock) {
+ if (sUsedDvbDevices.contains(deviceInfo)) {
+ Log.e(TAG, deviceInfo + " is already taken");
+ return false;
+ }
+ sUsedDvbDevices.add(deviceInfo);
+ }
+ if (DEBUG) Log.d(TAG, "Available device info: " + deviceInfo);
+ mDvbDeviceInfo = deviceInfo;
+ return true;
+ }
+ }
+ Log.e(TAG, "There's no such dvb device attached");
+ return false;
+ }
+
+ @Override
+ public void close() {
+ if (mDvbDeviceInfo != null) {
+ if (isStreaming()) {
+ stopTune();
+ }
+ nativeFinalize(mDvbDeviceInfo.getId());
+ synchronized (sLock) {
+ sUsedDvbDevices.remove(mDvbDeviceInfo);
+ }
+ mDvbDeviceInfo = null;
+ }
+ }
+
+ @Override
+ protected boolean isDeviceOpen() {
+ return (mDvbDeviceInfo != null);
+ }
+
+ @Override
+ protected long getDeviceId() {
+ if (mDvbDeviceInfo != null) {
+ return mDvbDeviceInfo.getId();
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbFrontEndFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_FRONTEND);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbDemuxFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DEMUX);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ protected int openDvbDvrFd() {
+ if (mDvbDeviceInfo != null) {
+ ParcelFileDescriptor descriptor = mDvbDeviceAccessor.openDvbDevice(
+ mDvbDeviceInfo, DvbDeviceAccessor.DVB_DEVICE_DVR);
+ if (descriptor != null) {
+ return descriptor.detachFd();
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Gets the number of USB tuner devices currently present.
+ */
+ public static int getNumberOfDevices(Context context) {
+ return (new DvbDeviceAccessor(context)).getNumOfDvbDevices();
+ }
+}
diff --git a/src/com/android/tv/tuner/cc/CaptionLayout.java b/src/com/android/tv/tuner/cc/CaptionLayout.java
new file mode 100644
index 00000000..c41f1014
--- /dev/null
+++ b/src/com/android/tv/tuner/cc/CaptionLayout.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.cc;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.layout.ScaledLayout;
+
+/**
+ * Layout containing the safe title area that helps the closed captions look more prominent.
+ * This is required by CEA-708B.
+ */
+public class CaptionLayout extends ScaledLayout {
+ // The safe title area has 10% margins of the screen.
+ private static final float SAFE_TITLE_AREA_SCALE_START_X = 0.1f;
+ private static final float SAFE_TITLE_AREA_SCALE_END_X = 0.9f;
+ private static final float SAFE_TITLE_AREA_SCALE_START_Y = 0.1f;
+ private static final float SAFE_TITLE_AREA_SCALE_END_Y = 0.9f;
+
+ private final ScaledLayout mSafeTitleAreaLayout;
+ private AtscCaptionTrack mCaptionTrack;
+
+ public CaptionLayout(Context context) {
+ this(context, null);
+ }
+
+ public CaptionLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CaptionLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ mSafeTitleAreaLayout = new ScaledLayout(context);
+ addView(mSafeTitleAreaLayout, new ScaledLayoutParams(
+ SAFE_TITLE_AREA_SCALE_START_X, SAFE_TITLE_AREA_SCALE_END_X,
+ SAFE_TITLE_AREA_SCALE_START_Y, SAFE_TITLE_AREA_SCALE_END_Y));
+ }
+
+ public void addOrUpdateViewToSafeTitleArea(CaptionWindowLayout captionWindowLayout,
+ ScaledLayoutParams scaledLayoutParams) {
+ int index = mSafeTitleAreaLayout.indexOfChild(captionWindowLayout);
+ if (index < 0) {
+ mSafeTitleAreaLayout.addView(captionWindowLayout, scaledLayoutParams);
+ return;
+ }
+ mSafeTitleAreaLayout.updateViewLayout(captionWindowLayout, scaledLayoutParams);
+ }
+
+ public void removeViewFromSafeTitleArea(CaptionWindowLayout captionWindowLayout) {
+ mSafeTitleAreaLayout.removeView(captionWindowLayout);
+ }
+
+ public void setCaptionTrack(AtscCaptionTrack captionTrack) {
+ mCaptionTrack = captionTrack;
+ }
+
+ public AtscCaptionTrack getCaptionTrack() {
+ return mCaptionTrack;
+ }
+}
diff --git a/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java
new file mode 100644
index 00000000..3aa40982
--- /dev/null
+++ b/src/com/android/tv/tuner/cc/CaptionTrackRenderer.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.cc;
+
+import android.os.Handler;
+import android.os.Message;
+import android.util.Log;
+import android.view.View;
+
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Decodes and renders CEA-708.
+ */
+public class CaptionTrackRenderer implements Handler.Callback {
+ // TODO: Remaining works
+ // CaptionTrackRenderer does not support the full spec of CEA-708. The remaining works are
+ // described in the follows.
+ // C0 Table: Backspace, FF, and HCR are not supported. The rule for P16 is not standardized but
+ // it is handled as EUC-KR charset for korea broadcasting.
+ // C1 Table: All styles of windows and pens except underline, italic, pen size, and pen offset
+ // specified in CEA-708 are ignored and this follows system wide cc preferences for
+ // look and feel. SetPenLocation is not implemented.
+ // G2 Table: TSP, NBTSP and BLK are not supported.
+ // Text/commands: Word wrapping, fonts, row and column locking are not supported.
+
+ private static final String TAG = "CaptionTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ private static final long DELAY_IN_MILLIS = TimeUnit.MILLISECONDS.toMillis(100);
+
+ // According to CEA-708B, there can exist up to 8 caption windows.
+ private static final int CAPTION_WINDOWS_MAX = 8;
+ private static final int CAPTION_ALL_WINDOWS_BITMAP = 255;
+
+ private static final int MSG_DELAY_CANCEL = 1;
+ private static final int MSG_CAPTION_CLEAR = 2;
+
+ private static final long CAPTION_CLEAR_INTERVAL_MS = 60000;
+
+ private final CaptionLayout mCaptionLayout;
+ private boolean mIsDelayed = false;
+ private CaptionWindowLayout mCurrentWindowLayout;
+ private final CaptionWindowLayout[] mCaptionWindowLayouts =
+ new CaptionWindowLayout[CAPTION_WINDOWS_MAX];
+ private final ArrayList<CaptionEvent> mPendingCaptionEvents = new ArrayList<>();
+ private final Handler mHandler;
+
+ public CaptionTrackRenderer(CaptionLayout captionLayout) {
+ mCaptionLayout = captionLayout;
+ mHandler = new Handler(this);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_DELAY_CANCEL:
+ delayCancel();
+ return true;
+ case MSG_CAPTION_CLEAR:
+ clearWindows(CAPTION_ALL_WINDOWS_BITMAP);
+ return true;
+ }
+ return false;
+ }
+
+ public void start(AtscCaptionTrack captionTrack) {
+ if (captionTrack == null) {
+ stop();
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "Start captionTrack " + captionTrack.language);
+ }
+ reset();
+ mCaptionLayout.setCaptionTrack(captionTrack);
+ mCaptionLayout.setVisibility(View.VISIBLE);
+ }
+
+ public void stop() {
+ if (DEBUG) {
+ Log.d(TAG, "Stop captionTrack");
+ }
+ mCaptionLayout.setVisibility(View.INVISIBLE);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ }
+
+ public void processCaptionEvent(CaptionEvent event) {
+ if (mIsDelayed) {
+ mPendingCaptionEvents.add(event);
+ return;
+ }
+ switch (event.type) {
+ case Cea708Parser.CAPTION_EMIT_TYPE_BUFFER:
+ sendBufferToCurrentWindow((String) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_CONTROL:
+ sendControlToCurrentWindow((char) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CWX:
+ setCurrentWindowLayout((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_CLW:
+ clearWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DSW:
+ displayWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_HDW:
+ hideWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_TGW:
+ toggleWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLW:
+ deleteWindows((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLY:
+ delay((int) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DLC:
+ delayCancel();
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_RST:
+ reset();
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPA:
+ setPenAttr((CaptionPenAttr) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPC:
+ setPenColor((CaptionPenColor) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SPL:
+ setPenLocation((CaptionPenLocation) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_SWA:
+ setWindowAttr((CaptionWindowAttr) event.obj);
+ break;
+ case Cea708Parser.CAPTION_EMIT_TYPE_COMMAND_DFX:
+ defineWindow((CaptionWindow) event.obj);
+ break;
+ }
+ }
+
+ // The window related caption commands
+ private void setCurrentWindowLayout(int windowId) {
+ if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
+ return;
+ }
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
+ if (windowLayout == null) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "setCurrentWindowLayout to " + windowId);
+ }
+ mCurrentWindowLayout = windowLayout;
+ }
+
+ // Each bit of windowBitmap indicates a window.
+ // If a bit is set, the window id is the same as the number of the trailing zeros of the bit.
+ private ArrayList<CaptionWindowLayout> getWindowsFromBitmap(int windowBitmap) {
+ ArrayList<CaptionWindowLayout> windows = new ArrayList<>();
+ for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
+ if ((windowBitmap & (1 << i)) != 0) {
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[i];
+ if (windowLayout != null) {
+ windows.add(windowLayout);
+ }
+ }
+ }
+ return windows;
+ }
+
+ private void clearWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.clear();
+ }
+ }
+
+ private void displayWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.show();
+ }
+ }
+
+ private void hideWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.hide();
+ }
+ }
+
+ private void toggleWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ if (windowLayout.isShown()) {
+ windowLayout.hide();
+ } else {
+ windowLayout.show();
+ }
+ }
+ }
+
+ private void deleteWindows(int windowBitmap) {
+ if (windowBitmap == 0) {
+ return;
+ }
+ for (CaptionWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
+ windowLayout.removeFromCaptionView();
+ mCaptionWindowLayouts[windowLayout.getCaptionWindowId()] = null;
+ }
+ }
+
+ public void reset() {
+ mCurrentWindowLayout = null;
+ mIsDelayed = false;
+ mPendingCaptionEvents.clear();
+ for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
+ if (mCaptionWindowLayouts[i] != null) {
+ mCaptionWindowLayouts[i].removeFromCaptionView();
+ }
+ mCaptionWindowLayouts[i] = null;
+ }
+ mCaptionLayout.setVisibility(View.INVISIBLE);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ }
+
+ private void setWindowAttr(CaptionWindowAttr windowAttr) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setWindowAttr(windowAttr);
+ }
+ }
+
+ private void defineWindow(CaptionWindow window) {
+ if (window == null) {
+ return;
+ }
+ int windowId = window.id;
+ if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
+ return;
+ }
+ CaptionWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
+ if (windowLayout == null) {
+ windowLayout = new CaptionWindowLayout(mCaptionLayout.getContext());
+ }
+ windowLayout.initWindow(mCaptionLayout, window);
+ mCurrentWindowLayout = mCaptionWindowLayouts[windowId] = windowLayout;
+ }
+
+ // The job related caption commands
+ private void delay(int tenthsOfSeconds) {
+ if (tenthsOfSeconds < 0 || tenthsOfSeconds > 255) {
+ return;
+ }
+ mIsDelayed = true;
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_DELAY_CANCEL),
+ tenthsOfSeconds * DELAY_IN_MILLIS);
+ }
+
+ private void delayCancel() {
+ mIsDelayed = false;
+ processPendingBuffer();
+ }
+
+ private void processPendingBuffer() {
+ for (CaptionEvent event : mPendingCaptionEvents) {
+ processCaptionEvent(event);
+ }
+ mPendingCaptionEvents.clear();
+ }
+
+ // The implicit write caption commands
+ private void sendControlToCurrentWindow(char control) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.sendControl(control);
+ }
+ }
+
+ private void sendBufferToCurrentWindow(String buffer) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.sendBuffer(buffer);
+ mHandler.removeMessages(MSG_CAPTION_CLEAR);
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CAPTION_CLEAR),
+ CAPTION_CLEAR_INTERVAL_MS);
+ }
+ }
+
+ // The pen related caption commands
+ private void setPenAttr(CaptionPenAttr attr) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenAttr(attr);
+ }
+ }
+
+ private void setPenColor(CaptionPenColor color) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenColor(color);
+ }
+ }
+
+ private void setPenLocation(CaptionPenLocation location) {
+ if (mCurrentWindowLayout != null) {
+ mCurrentWindowLayout.setPenLocation(location.row, location.column);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/cc/CaptionWindowLayout.java b/src/com/android/tv/tuner/cc/CaptionWindowLayout.java
new file mode 100644
index 00000000..6f42b506
--- /dev/null
+++ b/src/com/android/tv/tuner/cc/CaptionWindowLayout.java
@@ -0,0 +1,650 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.cc;
+
+import android.content.Context;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.CharacterStyle;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StyleSpan;
+import android.text.style.SubscriptSpan;
+import android.text.style.SuperscriptSpan;
+import android.text.style.UnderlineSpan;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.CaptioningManager;
+import android.view.accessibility.CaptioningManager.CaptionStyle;
+import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
+import android.widget.RelativeLayout;
+
+import com.google.android.exoplayer.text.CaptionStyleCompat;
+import com.google.android.exoplayer.text.SubtitleView;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.layout.ScaledLayout;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Layout which renders a caption window of CEA-708B. It contains a {@link SubtitleView} that
+ * takes care of displaying the actual cc text.
+ */
+public class CaptionWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener {
+ private static final String TAG = "CaptionWindowLayout";
+ private static final boolean DEBUG = false;
+
+ private static final float PROPORTION_PEN_SIZE_SMALL = .75f;
+ private static final float PROPORTION_PEN_SIZE_LARGE = 1.25f;
+
+ // The following values indicates the maximum cell number of a window.
+ private static final int ANCHOR_RELATIVE_POSITIONING_MAX = 99;
+ private static final int ANCHOR_VERTICAL_MAX = 74;
+ private static final int ANCHOR_HORIZONTAL_4_3_MAX = 159;
+ private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209;
+
+ // The following values indicates a gravity of a window.
+ private static final int ANCHOR_MODE_DIVIDER = 3;
+ private static final int ANCHOR_HORIZONTAL_MODE_LEFT = 0;
+ private static final int ANCHOR_HORIZONTAL_MODE_CENTER = 1;
+ private static final int ANCHOR_HORIZONTAL_MODE_RIGHT = 2;
+ private static final int ANCHOR_VERTICAL_MODE_TOP = 0;
+ private static final int ANCHOR_VERTICAL_MODE_CENTER = 1;
+ private static final int ANCHOR_VERTICAL_MODE_BOTTOM = 2;
+
+ private static final int US_MAX_COLUMN_COUNT_16_9 = 42;
+ private static final int US_MAX_COLUMN_COUNT_4_3 = 32;
+ private static final int KR_MAX_COLUMN_COUNT_16_9 = 52;
+ private static final int KR_MAX_COLUMN_COUNT_4_3 = 40;
+ private static final int MAX_ROW_COUNT = 15;
+
+ private static final String KOR_ALPHABET =
+ new String("\uAC00".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+ private static final float WIDE_SCREEN_ASPECT_RATIO_THRESHOLD = 1.6f;
+
+ private CaptionLayout mCaptionLayout;
+ private CaptionStyleCompat mCaptionStyleCompat;
+
+ // TODO: Replace SubtitleView to {@link com.google.android.exoplayer.text.SubtitleLayout}.
+ private final SubtitleView mSubtitleView;
+ private int mRowLimit = 0;
+ private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
+ private final List<CharacterStyle> mCharacterStyles = new ArrayList<>();
+ private int mCaptionWindowId;
+ private int mCurrentTextRow = -1;
+ private float mFontScale;
+ private float mTextSize;
+ private String mWidestChar;
+ private int mLastCaptionLayoutWidth;
+ private int mLastCaptionLayoutHeight;
+ private int mWindowJustify;
+ private int mPrintDirection;
+
+ private class SystemWideCaptioningChangeListener extends CaptioningChangeListener {
+ @Override
+ public void onUserStyleChanged(CaptionStyle userStyle) {
+ mCaptionStyleCompat = CaptionStyleCompat.createFromCaptionStyle(userStyle);
+ mSubtitleView.setStyle(mCaptionStyleCompat);
+ updateWidestChar();
+ }
+
+ @Override
+ public void onFontScaleChanged(float fontScale) {
+ mFontScale = fontScale;
+ updateTextSize();
+ }
+ }
+
+ public CaptionWindowLayout(Context context) {
+ this(context, null);
+ }
+
+ public CaptionWindowLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ // Add a subtitle view to the layout.
+ mSubtitleView = new SubtitleView(context);
+ LayoutParams params = new RelativeLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ addView(mSubtitleView, params);
+
+ // Set the system wide cc preferences to the subtitle view.
+ CaptioningManager captioningManager =
+ (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
+ mFontScale = captioningManager.getFontScale();
+ mCaptionStyleCompat =
+ CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle());
+ mSubtitleView.setStyle(mCaptionStyleCompat);
+ mSubtitleView.setText("");
+ captioningManager.addCaptioningChangeListener(new SystemWideCaptioningChangeListener());
+ updateWidestChar();
+ }
+
+ public int getCaptionWindowId() {
+ return mCaptionWindowId;
+ }
+
+ public void setCaptionWindowId(int captionWindowId) {
+ mCaptionWindowId = captionWindowId;
+ }
+
+ public void clear() {
+ clearText();
+ hide();
+ }
+
+ public void show() {
+ setVisibility(View.VISIBLE);
+ requestLayout();
+ }
+
+ public void hide() {
+ setVisibility(View.INVISIBLE);
+ requestLayout();
+ }
+
+ public void setPenAttr(CaptionPenAttr penAttr) {
+ mCharacterStyles.clear();
+ if (penAttr.italic) {
+ mCharacterStyles.add(new StyleSpan(Typeface.ITALIC));
+ }
+ if (penAttr.underline) {
+ mCharacterStyles.add(new UnderlineSpan());
+ }
+ switch (penAttr.penSize) {
+ case CaptionPenAttr.PEN_SIZE_SMALL:
+ mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL));
+ break;
+ case CaptionPenAttr.PEN_SIZE_LARGE:
+ mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE));
+ break;
+ }
+ switch (penAttr.penOffset) {
+ case CaptionPenAttr.OFFSET_SUBSCRIPT:
+ mCharacterStyles.add(new SubscriptSpan());
+ break;
+ case CaptionPenAttr.OFFSET_SUPERSCRIPT:
+ mCharacterStyles.add(new SuperscriptSpan());
+ break;
+ }
+ }
+
+ public void setPenColor(CaptionPenColor penColor) {
+ // TODO: apply pen colors or skip this and use the style of system wide cc style as is.
+ }
+
+ public void setPenLocation(int row, int column) {
+ // TODO: change the location of pen when window's justify isn't left.
+ // According to the CEA708B spec 8.7, setPenLocation means set the pen cursor within
+ // window's text buffer. When row > mCurrentTextRow, we add "\n" to make the cursor locate
+ // at row. Adding white space to make cursor locate at column.
+ if (mWindowJustify == CaptionWindowAttr.JUSTIFY_LEFT) {
+ if (mCurrentTextRow >= 0) {
+ for (int r = mCurrentTextRow; r < row; ++r) {
+ appendText("\n");
+ }
+ if (mCurrentTextRow <= row) {
+ for (int i = 0; i < column; ++i) {
+ appendText(" ");
+ }
+ }
+ }
+ }
+ mCurrentTextRow = row;
+ }
+
+ public void setWindowAttr(CaptionWindowAttr windowAttr) {
+ // TODO: apply window attrs or skip this and use the style of system wide cc style as is.
+ mWindowJustify = windowAttr.justify;
+ mPrintDirection = windowAttr.printDirection;
+ }
+
+ public void sendBuffer(String buffer) {
+ appendText(buffer);
+ }
+
+ public void sendControl(char control) {
+ // TODO: there are a bunch of ASCII-style control codes.
+ }
+
+ /**
+ * This method places the window on a given CaptionLayout along with the anchor of the window.
+ * <p>
+ * According to CEA-708B, the anchor id indicates the gravity of the window as the follows.
+ * For example, A value 7 of a anchor id says that a window is align with its parent bottom and
+ * is located at the center horizontally of its parent.
+ * </p>
+ * <h4>Anchor id and the gravity of a window</h4>
+ * <table>
+ * <tr>
+ * <th>GRAVITY</th>
+ * <th>LEFT</th>
+ * <th>CENTER_HORIZONTAL</th>
+ * <th>RIGHT</th>
+ * </tr>
+ * <tr>
+ * <th>TOP</th>
+ * <td>0</td>
+ * <td>1</td>
+ * <td>2</td>
+ * </tr>
+ * <tr>
+ * <th>CENTER_VERTICAL</th>
+ * <td>3</td>
+ * <td>4</td>
+ * <td>5</td>
+ * </tr>
+ * <tr>
+ * <th>BOTTOM</th>
+ * <td>6</td>
+ * <td>7</td>
+ * <td>8</td>
+ * </tr>
+ * </table>
+ * <p>
+ * In order to handle the gravity of a window, there are two steps. First, set the size of the
+ * window. Since the window will be positioned at {@link ScaledLayout}, the size factors are
+ * determined in a ratio. Second, set the gravity of the window. {@link CaptionWindowLayout} is
+ * inherited from {@link RelativeLayout}. Hence, we could set the gravity of its child view,
+ * {@link SubtitleView}.
+ * </p>
+ * <p>
+ * The gravity of the window is also related to its size. When it should be pushed to a one of
+ * the end of the window, like LEFT, RIGHT, TOP or BOTTOM, the anchor point should be a boundary
+ * of the window. When it should be pushed in the horizontal/vertical center of its container,
+ * the horizontal/vertical center point of the window should be the same as the anchor point.
+ * </p>
+ *
+ * @param captionLayout a given {@link CaptionLayout}, which contains a safe title area
+ * @param captionWindow a given {@link CaptionWindow}, which stores the construction info of the
+ * window
+ */
+ public void initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow) {
+ if (DEBUG) {
+ Log.d(TAG, "initWindow with "
+ + (captionLayout != null ? captionLayout.getCaptionTrack() : null));
+ }
+ if (mCaptionLayout != captionLayout) {
+ if (mCaptionLayout != null) {
+ mCaptionLayout.removeOnLayoutChangeListener(this);
+ }
+ mCaptionLayout = captionLayout;
+ mCaptionLayout.addOnLayoutChangeListener(this);
+ updateWidestChar();
+ }
+
+ // Both anchor vertical and horizontal indicates the position cell number of the window.
+ float scaleRow = (float) captionWindow.anchorVertical / (captionWindow.relativePositioning
+ ? ANCHOR_RELATIVE_POSITIONING_MAX : ANCHOR_VERTICAL_MAX);
+ float scaleCol = (float) captionWindow.anchorHorizontal /
+ (captionWindow.relativePositioning ? ANCHOR_RELATIVE_POSITIONING_MAX
+ : (isWideAspectRatio()
+ ? ANCHOR_HORIZONTAL_16_9_MAX : ANCHOR_HORIZONTAL_4_3_MAX));
+
+ // The range of scaleRow/Col need to be verified to be in [0, 1].
+ // Otherwise a {@link RuntimeException} will be raised in {@link ScaledLayout}.
+ if (scaleRow < 0 || scaleRow > 1) {
+ Log.i(TAG, "The vertical position of the anchor point should be at the range of 0 and 1"
+ + " but " + scaleRow);
+ scaleRow = Math.max(0, Math.min(scaleRow, 1));
+ }
+ if (scaleCol < 0 || scaleCol > 1) {
+ Log.i(TAG, "The horizontal position of the anchor point should be at the range of 0 and"
+ + " 1 but " + scaleCol);
+ scaleCol = Math.max(0, Math.min(scaleCol, 1));
+ }
+ int gravity = Gravity.CENTER;
+ int horizontalMode = captionWindow.anchorId % ANCHOR_MODE_DIVIDER;
+ int verticalMode = captionWindow.anchorId / ANCHOR_MODE_DIVIDER;
+ float scaleStartRow = 0;
+ float scaleEndRow = 1;
+ float scaleStartCol = 0;
+ float scaleEndCol = 1;
+ switch (horizontalMode) {
+ case ANCHOR_HORIZONTAL_MODE_LEFT:
+ gravity = Gravity.LEFT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL);
+ scaleStartCol = scaleCol;
+ break;
+ case ANCHOR_HORIZONTAL_MODE_CENTER:
+ float gap = Math.min(1 - scaleCol, scaleCol);
+
+ // Since all TV sets use left text alignment instead of center text alignment
+ // for this case, we follow the industry convention if possible.
+ int columnCount = captionWindow.columnCount + 1;
+ if (isKoreanLanguageTrack()) {
+ columnCount /= 2;
+ }
+ columnCount = Math.min(getScreenColumnCount(), columnCount);
+ StringBuilder widestTextBuilder = new StringBuilder();
+ for (int i = 0; i < columnCount; ++i) {
+ widestTextBuilder.append(mWidestChar);
+ }
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ paint.setTextSize(mTextSize);
+ float maxWindowWidth = paint.measureText(widestTextBuilder.toString());
+ float halfMaxWidthScale = mCaptionLayout.getWidth() > 0
+ ? maxWindowWidth / 2.0f / (mCaptionLayout.getWidth() * 0.8f) : 0.0f;
+ if (halfMaxWidthScale > 0f && halfMaxWidthScale < scaleCol) {
+ // Calculate the expected max window size based on the column count of the
+ // caption window multiplied by average alphabets char width, then align the
+ // left side of the window with the left side of the expected max window.
+ gravity = Gravity.LEFT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL);
+ scaleStartCol = scaleCol - halfMaxWidthScale;
+ scaleEndCol = 1.0f;
+ } else {
+ // The gap will be the minimum distance value of the distances from both
+ // horizontal end points to the anchor point.
+ // If scaleCol <= 0.5, the range of scaleCol is [0, the anchor point * 2].
+ // If scaleCol > 0.5, the range of scaleCol is [(1 - the anchor point) * 2, 1].
+ // The anchor point is located at the horizontal center of the window in both
+ // cases.
+ gravity = Gravity.CENTER_HORIZONTAL;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER);
+ scaleStartCol = scaleCol - gap;
+ scaleEndCol = scaleCol + gap;
+ }
+ break;
+ case ANCHOR_HORIZONTAL_MODE_RIGHT:
+ gravity = Gravity.RIGHT;
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_OPPOSITE);
+ scaleEndCol = scaleCol;
+ break;
+ }
+ switch (verticalMode) {
+ case ANCHOR_VERTICAL_MODE_TOP:
+ gravity |= Gravity.TOP;
+ scaleStartRow = scaleRow;
+ break;
+ case ANCHOR_VERTICAL_MODE_CENTER:
+ gravity |= Gravity.CENTER_VERTICAL;
+
+ // See the above comment.
+ float gap = Math.min(1 - scaleRow, scaleRow);
+ scaleStartRow = scaleRow - gap;
+ scaleEndRow = scaleRow + gap;
+ break;
+ case ANCHOR_VERTICAL_MODE_BOTTOM:
+ gravity |= Gravity.BOTTOM;
+ scaleEndRow = scaleRow;
+ break;
+ }
+ mCaptionLayout.addOrUpdateViewToSafeTitleArea(this, new ScaledLayout
+ .ScaledLayoutParams(scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
+ setCaptionWindowId(captionWindow.id);
+ setRowLimit(captionWindow.rowCount);
+ setGravity(gravity);
+ setWindowStyle(captionWindow.windowStyle);
+ if (mWindowJustify == CaptionWindowAttr.JUSTIFY_CENTER) {
+ mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER);
+ }
+ if (captionWindow.visible) {
+ show();
+ } else {
+ hide();
+ }
+ }
+
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
+ int oldTop, int oldRight, int oldBottom) {
+ int width = right - left;
+ int height = bottom - top;
+ if (width != mLastCaptionLayoutWidth || height != mLastCaptionLayoutHeight) {
+ mLastCaptionLayoutWidth = width;
+ mLastCaptionLayoutHeight = height;
+ updateTextSize();
+ }
+ }
+
+ private boolean isKoreanLanguageTrack() {
+ return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null
+ && mCaptionLayout.getCaptionTrack().language != null
+ && "KOR".compareToIgnoreCase(mCaptionLayout.getCaptionTrack().language) == 0;
+ }
+
+ private boolean isWideAspectRatio() {
+ return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null
+ && mCaptionLayout.getCaptionTrack().wideAspectRatio;
+ }
+
+ private void updateWidestChar() {
+ if (isKoreanLanguageTrack()) {
+ mWidestChar = KOR_ALPHABET;
+ } else {
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ Charset latin1 = Charset.forName("ISO-8859-1");
+ float widestCharWidth = 0f;
+ for (int i = 0; i < 256; ++i) {
+ String ch = new String(new byte[]{(byte) i}, latin1);
+ float charWidth = paint.measureText(ch);
+ if (widestCharWidth < charWidth) {
+ widestCharWidth = charWidth;
+ mWidestChar = ch;
+ }
+ }
+ }
+ updateTextSize();
+ }
+
+ private void updateTextSize() {
+ if (mCaptionLayout == null) return;
+
+ // Calculate text size based on the max window size.
+ StringBuilder widestTextBuilder = new StringBuilder();
+ int screenColumnCount = getScreenColumnCount();
+ for (int i = 0; i < screenColumnCount; ++i) {
+ widestTextBuilder.append(mWidestChar);
+ }
+ String widestText = widestTextBuilder.toString();
+ Paint paint = new Paint();
+ paint.setTypeface(mCaptionStyleCompat.typeface);
+ float startFontSize = 0f;
+ float endFontSize = 255f;
+ Rect boundRect = new Rect();
+ while (startFontSize < endFontSize) {
+ float testTextSize = (startFontSize + endFontSize) / 2f;
+ paint.setTextSize(testTextSize);
+ float width = paint.measureText(widestText);
+ paint.getTextBounds(widestText, 0, widestText.length(), boundRect);
+ float height = boundRect.height() + width - boundRect.width();
+ // According to CEA-708B Section 9.13, the height of standard font size shouldn't taller
+ // than 1/15 of the height of the safe-title area, and the width shouldn't wider than
+ // 1/{@code getScreenColumnCount()} of the width of the safe-title area.
+ if (mCaptionLayout.getWidth() * 0.8f > width
+ && mCaptionLayout.getHeight() * 0.8f / MAX_ROW_COUNT > height) {
+ startFontSize = testTextSize + 0.01f;
+ } else {
+ endFontSize = testTextSize - 0.01f;
+ }
+ }
+ mTextSize = endFontSize * mFontScale;
+ paint.setTextSize(mTextSize);
+ float whiteSpaceWidth = paint.measureText(" ");
+ mSubtitleView.setWhiteSpaceWidth(whiteSpaceWidth);
+ mSubtitleView.setTextSize(mTextSize);
+ }
+
+ private int getScreenColumnCount() {
+ float screenAspectRatio = (float) mCaptionLayout.getWidth() / mCaptionLayout.getHeight();
+ boolean isWideAspectRationScreen = screenAspectRatio > WIDE_SCREEN_ASPECT_RATIO_THRESHOLD;
+ if (isKoreanLanguageTrack()) {
+ // Each korean character consumes two slots.
+ if (isWideAspectRationScreen || isWideAspectRatio()) {
+ return KR_MAX_COLUMN_COUNT_16_9 / 2;
+ } else {
+ return KR_MAX_COLUMN_COUNT_4_3 / 2;
+ }
+ } else {
+ if (isWideAspectRationScreen || isWideAspectRatio()) {
+ return US_MAX_COLUMN_COUNT_16_9;
+ } else {
+ return US_MAX_COLUMN_COUNT_4_3;
+ }
+ }
+ }
+
+ public void removeFromCaptionView() {
+ if (mCaptionLayout != null) {
+ mCaptionLayout.removeViewFromSafeTitleArea(this);
+ mCaptionLayout.removeOnLayoutChangeListener(this);
+ mCaptionLayout = null;
+ }
+ }
+
+ public void setText(String text) {
+ updateText(text, false);
+ }
+
+ public void appendText(String text) {
+ updateText(text, true);
+ }
+
+ public void clearText() {
+ mBuilder.clear();
+ mSubtitleView.setText("");
+ }
+
+ private void updateText(String text, boolean appended) {
+ if (!appended) {
+ mBuilder.clear();
+ }
+ if (text != null && text.length() > 0) {
+ int length = mBuilder.length();
+ mBuilder.append(text);
+ for (CharacterStyle characterStyle : mCharacterStyles) {
+ mBuilder.setSpan(characterStyle, length, mBuilder.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ String[] lines = TextUtils.split(mBuilder.toString(), "\n");
+
+ // Truncate text not to exceed the row limit.
+ // Plus one here since the range of the rows is [0, mRowLimit].
+ int startRow = Math.max(0, lines.length - (mRowLimit + 1));
+ String truncatedText = TextUtils.join("\n", Arrays.copyOfRange(
+ lines, startRow, lines.length));
+ mBuilder.delete(0, mBuilder.length() - truncatedText.length());
+ mCurrentTextRow = lines.length - startRow - 1;
+
+ // Trim the buffer first then set text to {@link SubtitleView}.
+ int start = 0, last = mBuilder.length() - 1;
+ int end = last;
+ while ((start <= end) && (mBuilder.charAt(start) <= ' ')) {
+ ++start;
+ }
+ while (start - 1 >= 0 && start <= end && mBuilder.charAt(start - 1) != '\n') {
+ --start;
+ }
+ while ((end >= start) && (mBuilder.charAt(end) <= ' ')) {
+ --end;
+ }
+ if (start == 0 && end == last) {
+ mSubtitleView.setPrefixSpaces(getPrefixSpaces(mBuilder));
+ mSubtitleView.setText(mBuilder);
+ } else {
+ SpannableStringBuilder trim = new SpannableStringBuilder();
+ trim.append(mBuilder);
+ if (end < last) {
+ trim.delete(end + 1, last + 1);
+ }
+ if (start > 0) {
+ trim.delete(0, start);
+ }
+ mSubtitleView.setPrefixSpaces(getPrefixSpaces(trim));
+ mSubtitleView.setText(trim);
+ }
+ }
+
+ private static ArrayList<Integer> getPrefixSpaces(SpannableStringBuilder builder) {
+ ArrayList<Integer> prefixSpaces = new ArrayList<>();
+ String[] lines = TextUtils.split(builder.toString(), "\n");
+ for (String line : lines) {
+ int start = 0;
+ while (start < line.length() && line.charAt(start) <= ' ') {
+ start++;
+ }
+ prefixSpaces.add(start);
+ }
+ return prefixSpaces;
+ }
+
+ public void setRowLimit(int rowLimit) {
+ if (rowLimit < 0) {
+ throw new IllegalArgumentException("A rowLimit should have a positive number");
+ }
+ mRowLimit = rowLimit;
+ }
+
+ private void setWindowStyle(int windowStyle) {
+ // TODO: Set other attributes of window style. Like fill opacity and fill color.
+ switch (windowStyle) {
+ case 2:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 3:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 4:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 5:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 6:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_CENTER;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ case 7:
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_TOP_TO_BOTTOM;
+ break;
+ default:
+ if (windowStyle != 0 && windowStyle != 1) {
+ Log.e(TAG, "Error predefined window style:" + windowStyle);
+ }
+ mWindowJustify = CaptionWindowAttr.JUSTIFY_LEFT;
+ mPrintDirection = CaptionWindowAttr.PRINT_LEFT_TO_RIGHT;
+ break;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/cc/Cea708Parser.java b/src/com/android/tv/tuner/cc/Cea708Parser.java
new file mode 100644
index 00000000..92ab0620
--- /dev/null
+++ b/src/com/android/tv/tuner/cc/Cea708Parser.java
@@ -0,0 +1,808 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.cc;
+
+import android.os.SystemClock;
+import android.support.annotation.IntDef;
+import android.util.Log;
+import android.util.SparseIntArray;
+
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Cea708Data.CaptionColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenAttr;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenColor;
+import com.android.tv.tuner.data.Cea708Data.CaptionPenLocation;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindow;
+import com.android.tv.tuner.data.Cea708Data.CaptionWindowAttr;
+import com.android.tv.tuner.data.Cea708Data.CcPacket;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.TreeSet;
+
+/**
+ * A class for parsing CEA-708, which is the standard for closed captioning for ATSC DTV.
+ *
+ * <p>ATSC DTV closed caption data are carried on picture user data of video streams.
+ * This class starts to parse from picture user data payload, so extraction process of user_data
+ * from video streams is up to outside of this code.
+ *
+ * <p>There are 4 steps to decode user_data to provide closed caption services.
+ *
+ * <h3>Step 1. user_data -&gt; CcPacket ({@link #parseClosedCaption} method)</h3>
+ *
+ * <p>First, user_data consists of cc_data packets, which are 3-byte segments. Here, CcPacket is a
+ * collection of cc_data packets in a frame along with same presentation timestamp. Because cc_data
+ * packets must be reassembled in the frame display order, CcPackets are reordered.
+ *
+ * <h3>Step 2. CcPacket -&gt; DTVCC packet ({@link #parseCcPacket} method)</h3>
+ *
+ * <p>Each cc_data packet has a one byte for declaring a type of itself and data validity, and the
+ * subsequent two bytes for input data of a DTVCC packet. There are 4 types for cc_data packet.
+ * We're interested in DTVCC_PACKET_START(type 3) and DTVCC_PACKET_DATA(type 2). Each DTVCC packet
+ * begins with DTVCC_PACKET_START(type 3) and the following cc_data packets which has
+ * DTVCC_PACKET_DATA(type 2) are appended into the DTVCC packet being assembled.
+ *
+ * <h3>Step 3. DTVCC packet -&gt; Service Blocks ({@link #parseDtvCcPacket} method)</h3>
+ *
+ * <p>A DTVCC packet consists of multiple service blocks. Each service block represents a caption
+ * track and has a service number, which ranges from 1 to 63, that denotes caption track identity.
+ * In here, we listen at most one chosen caption track by {@link #mListenServiceNumber}.
+ * Otherwise, just skip the other service blocks.
+ *
+ * <h3>Step 4. Interpreting Service Block Data ({@link #parseServiceBlockData}, {@code parseXX},
+ * and {@link #parseExt1} methods)</h3>
+ *
+ * <p>Service block data is actual caption stream. it looks similar to telnet. It uses most parts of
+ * ASCII table and consists of specially defined commands and some ASCII control codes which work
+ * in a behavior slightly different from their original purpose. ASCII control codes and caption
+ * commands are explicit instructions that control the state of a closed caption service and the
+ * other ASCII and text codes are implicit instructions that send their characters to buffer.
+ *
+ * <p>There are 4 main code groups and 4 extended code groups. Both the range of code groups are the
+ * same as the range of a byte.
+ *
+ * <p>4 main code groups: C0, C1, G0, G1
+ * <br>4 extended code groups: C2, C3, G2, G3
+ *
+ * <p>Each code group has its own handle method. For example, {@link #parseC0} handles C0 code group
+ * and so on. And {@link #parseServiceBlockData} method maps a stream on the main code groups while
+ * {@link #parseExt1} method maps on the extended code groups.
+ *
+ * <p>The main code groups:
+ * <ul>
+ * <li>C0 - contains modified ASCII control codes. It is not intended by CEA-708 but Korea TTA
+ * standard for ATSC CC uses P16 character heavily, which is unclear entity in CEA-708 doc,
+ * even for the alphanumeric characters instead of ASCII characters.</li>
+ * <li>C1 - contains the caption commands. There are 3 categories of a caption command.</li>
+ * <ul>
+ * <li>Window commands: The window commands control a caption window which is addressable area being
+ * with in the Safe title area. (CWX, CLW, DSW, HDW, TGW, DLW, SWA, DFX)</li>
+ * <li>Pen commands: Th pen commands control text style and location. (SPA, SPC, SPL)</li>
+ * <li>Job commands: The job commands make a delay and recover from the delay. (DLY, DLC, RST)</li>
+ * </ul>
+ * <li>G0 - same as printable ASCII character set except music note character.</li>
+ * <li>G1 - same as ISO 8859-1 Latin 1 character set.</li>
+ * </ul>
+ * <p>Most of the extended code groups are being skipped.
+ *
+ */
+public class Cea708Parser {
+ private static final String TAG = "Cea708Parser";
+ private static final boolean DEBUG = false;
+
+ // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps.
+ private static final int MAX_ALLOCATED_SIZE = 9600 / 8;
+ private static final String MUSIC_NOTE_CHAR = new String(
+ "\u266B".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
+
+ // The following values are denoting the type of closed caption data.
+ // See CEA-708B section 4.4.1.
+ private static final int CC_TYPE_DTVCC_PACKET_START = 3;
+ private static final int CC_TYPE_DTVCC_PACKET_DATA = 2;
+
+ // The following values are defined in CEA-708B Figure 4 and 6.
+ private static final int DTVCC_MAX_PACKET_SIZE = 64;
+ private static final int DTVCC_PACKET_SIZE_SCALE_FACTOR = 2;
+ private static final int DTVCC_EXTENDED_SERVICE_NUMBER_POINT = 7;
+
+ // The following values are for seeking closed caption tracks.
+ private static final int DISCOVERY_PERIOD_MS = 10000; // 10 sec
+ private static final int DISCOVERY_NUM_BYTES_THRESHOLD = 10; // 10 bytes
+ private static final int DISCOVERY_CC_SERVICE_NUMBER_START = 1; // CC1
+ private static final int DISCOVERY_CC_SERVICE_NUMBER_END = 4; // CC4
+
+ private final ByteArrayBuffer mDtvCcPacket = new ByteArrayBuffer(MAX_ALLOCATED_SIZE);
+ private final TreeSet<CcPacket> mCcPackets = new TreeSet<>();
+ private final StringBuffer mBuffer = new StringBuffer();
+ private final SparseIntArray mDiscoveredNumBytes = new SparseIntArray(); // per service number
+ private long mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime();
+ private int mCommand = 0;
+ private int mListenServiceNumber = 0;
+ private boolean mDtvCcPacking = false;
+
+ // Assign a dummy listener in order to avoid null checks.
+ private OnCea708ParserListener mListener = new OnCea708ParserListener() {
+ @Override
+ public void emitEvent(CaptionEvent event) {
+ // do nothing
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ // do nothing
+ }
+ };
+
+ /**
+ * {@link Cea708Parser} emits caption event of three different types.
+ * {@link OnCea708ParserListener#emitEvent} is invoked with the parameter
+ * {@link CaptionEvent} to pass all the results to an observer of the decoding process.
+ *
+ * <p>{@link CaptionEvent#type} determines the type of the result and
+ * {@link CaptionEvent#obj} contains the output value of a caption event.
+ * The observer must do the casting to the corresponding type.
+ *
+ * <ul><li>{@code CAPTION_EMIT_TYPE_BUFFER}: Passes a caption text buffer to a observer.
+ * {@code obj} must be of {@link String}.</li>
+ *
+ * <li>{@code CAPTION_EMIT_TYPE_CONTROL}: Passes a caption character control code to a observer.
+ * {@code obj} must be of {@link Character}.</li>
+ *
+ * <li>{@code CAPTION_EMIT_TYPE_CLEAR_COMMAND}: Passes a clear command to a observer.
+ * {@code obj} must be {@code NULL}.</li></ul>
+ */
+ @IntDef({CAPTION_EMIT_TYPE_BUFFER, CAPTION_EMIT_TYPE_CONTROL, CAPTION_EMIT_TYPE_COMMAND_CWX,
+ CAPTION_EMIT_TYPE_COMMAND_CLW, CAPTION_EMIT_TYPE_COMMAND_DSW, CAPTION_EMIT_TYPE_COMMAND_HDW,
+ CAPTION_EMIT_TYPE_COMMAND_TGW, CAPTION_EMIT_TYPE_COMMAND_DLW, CAPTION_EMIT_TYPE_COMMAND_DLY,
+ CAPTION_EMIT_TYPE_COMMAND_DLC, CAPTION_EMIT_TYPE_COMMAND_RST, CAPTION_EMIT_TYPE_COMMAND_SPA,
+ CAPTION_EMIT_TYPE_COMMAND_SPC, CAPTION_EMIT_TYPE_COMMAND_SPL, CAPTION_EMIT_TYPE_COMMAND_SWA,
+ CAPTION_EMIT_TYPE_COMMAND_DFX})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface CaptionEmitType {}
+ public static final int CAPTION_EMIT_TYPE_BUFFER = 1;
+ public static final int CAPTION_EMIT_TYPE_CONTROL = 2;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_CWX = 3;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_CLW = 4;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DSW = 5;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_HDW = 6;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_TGW = 7;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLW = 8;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLY = 9;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DLC = 10;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_RST = 11;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPA = 12;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPC = 13;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SPL = 14;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_SWA = 15;
+ public static final int CAPTION_EMIT_TYPE_COMMAND_DFX = 16;
+
+ public interface OnCea708ParserListener {
+ void emitEvent(CaptionEvent event);
+ void discoverServiceNumber(int serviceNumber);
+ }
+
+ public void setListener(OnCea708ParserListener listener) {
+ if (listener != null) {
+ mListener = listener;
+ }
+ }
+
+ public void setListenServiceNumber(int serviceNumber) {
+ mListenServiceNumber = serviceNumber;
+ }
+
+ private void emitCaptionEvent(CaptionEvent captionEvent) {
+ // Emit the existing string buffer before a new event is arrived.
+ emitCaptionBuffer();
+ mListener.emitEvent(captionEvent);
+ }
+
+ private void emitCaptionBuffer() {
+ if (mBuffer.length() > 0) {
+ mListener.emitEvent(new CaptionEvent(CAPTION_EMIT_TYPE_BUFFER, mBuffer.toString()));
+ mBuffer.setLength(0);
+ }
+ }
+
+ // Step 1. user_data -> CcPacket ({@link #parseClosedCaption} method)
+ public void parseClosedCaption(ByteBuffer data, long framePtsUs) {
+ int ccCount = data.limit() / 3;
+ byte[] ccBytes = new byte[3 * ccCount];
+ for (int i = 0; i < 3 * ccCount; i++) {
+ ccBytes[i] = data.get(i);
+ }
+ CcPacket ccPacket = new CcPacket(ccBytes, ccCount, framePtsUs);
+ mCcPackets.add(ccPacket);
+ }
+
+ public boolean processClosedCaptions(long framePtsUs) {
+ // To get the sorted cc packets that have lower frame pts than current frame pts,
+ // the following offset divides off the lower side of the packets.
+ CcPacket offsetPacket = new CcPacket(new byte[0], 0, framePtsUs);
+ offsetPacket = mCcPackets.lower(offsetPacket);
+ boolean processed = false;
+ if (offsetPacket != null) {
+ while (!mCcPackets.isEmpty() && offsetPacket.compareTo(mCcPackets.first()) >= 0) {
+ CcPacket packet = mCcPackets.pollFirst();
+ parseCcPacket(packet);
+ processed = true;
+ }
+ }
+ return processed;
+ }
+
+ // Step 2. CcPacket -> DTVCC packet ({@link #parseCcPacket} method)
+ private void parseCcPacket(CcPacket ccPacket) {
+ // For the details of cc packet, see ATSC TSG-676 - Table A8.
+ byte[] bytes = ccPacket.bytes;
+ int pos = 0;
+ for (int i = 0; i < ccPacket.ccCount; ++i) {
+ boolean ccValid = (bytes[pos] & 0x04) != 0;
+ int ccType = bytes[pos] & 0x03;
+
+ // The dtvcc should be considered complete:
+ // - if either ccValid is set and ccType is 3
+ // - or ccValid is clear and ccType is 2 or 3.
+ if (ccValid) {
+ if (ccType == CC_TYPE_DTVCC_PACKET_START) {
+ if (mDtvCcPacking) {
+ parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length());
+ mDtvCcPacket.clear();
+ }
+ mDtvCcPacking = true;
+ mDtvCcPacket.append(bytes[pos + 1]);
+ mDtvCcPacket.append(bytes[pos + 2]);
+ } else if (mDtvCcPacking && ccType == CC_TYPE_DTVCC_PACKET_DATA) {
+ mDtvCcPacket.append(bytes[pos + 1]);
+ mDtvCcPacket.append(bytes[pos + 2]);
+ }
+ } else {
+ if ((ccType == CC_TYPE_DTVCC_PACKET_START || ccType == CC_TYPE_DTVCC_PACKET_DATA)
+ && mDtvCcPacking) {
+ mDtvCcPacking = false;
+ parseDtvCcPacket(mDtvCcPacket.buffer(), mDtvCcPacket.length());
+ mDtvCcPacket.clear();
+ }
+ }
+ pos += 3;
+ }
+ }
+
+ // Step 3. DTVCC packet -> Service Blocks ({@link #parseDtvCcPacket} method)
+ private void parseDtvCcPacket(byte[] data, int limit) {
+ // For the details of DTVCC packet, see CEA-708B Figure 4.
+ int pos = 0;
+ int packetSize = data[pos] & 0x3f;
+ if (packetSize == 0) {
+ packetSize = DTVCC_MAX_PACKET_SIZE;
+ }
+ int calculatedPacketSize = packetSize * DTVCC_PACKET_SIZE_SCALE_FACTOR;
+ if (limit != calculatedPacketSize) {
+ return;
+ }
+ ++pos;
+ int len = pos + calculatedPacketSize;
+ while (pos < len) {
+ // For the details of Service Block, see CEA-708B Figure 5 and 6.
+ int serviceNumber = (data[pos] & 0xe0) >> 5;
+ int blockSize = data[pos] & 0x1f;
+ ++pos;
+ if (serviceNumber == DTVCC_EXTENDED_SERVICE_NUMBER_POINT) {
+ serviceNumber = (data[pos] & 0x3f);
+ ++pos;
+
+ // Return if invalid service number
+ if (serviceNumber < DTVCC_EXTENDED_SERVICE_NUMBER_POINT) {
+ return;
+ }
+ }
+ if (pos + blockSize > limit) {
+ return;
+ }
+
+ // Send parsed service number in order to find unveiled closed caption tracks which
+ // are not specified in any ATSC PSIP sections. Since some broadcasts send empty closed
+ // caption tracks, it detects the proper closed caption tracks by counting the number of
+ // bytes sent with the same service number during a discovery period.
+ // The viewer in most TV sets chooses between CC1, CC2, CC3, CC4 to view different
+ // language captions. Therefore, only CC1, CC2, CC3, CC4 are allowed to be reported.
+ if (blockSize > 0 && serviceNumber >= DISCOVERY_CC_SERVICE_NUMBER_START
+ && serviceNumber <= DISCOVERY_CC_SERVICE_NUMBER_END) {
+ mDiscoveredNumBytes.put(
+ serviceNumber, blockSize + mDiscoveredNumBytes.get(serviceNumber, 0));
+ }
+ if (mLastDiscoveryLaunchedMs + DISCOVERY_PERIOD_MS < SystemClock.elapsedRealtime()) {
+ for (int i = 0; i < mDiscoveredNumBytes.size(); ++i) {
+ int discoveredNumBytes = mDiscoveredNumBytes.valueAt(i);
+ if (discoveredNumBytes >= DISCOVERY_NUM_BYTES_THRESHOLD) {
+ int discoveredServiceNumber = mDiscoveredNumBytes.keyAt(i);
+ mListener.discoverServiceNumber(discoveredServiceNumber);
+ }
+ }
+ mDiscoveredNumBytes.clear();
+ mLastDiscoveryLaunchedMs = SystemClock.elapsedRealtime();
+ }
+
+ // Skip current service block if either there is no block data or the service number
+ // is not same as listening service number.
+ if (blockSize == 0 || serviceNumber != mListenServiceNumber) {
+ pos += blockSize;
+ continue;
+ }
+
+ // From this point, starts to read DTVCC coding layer.
+ // First, identify code groups, which is defined in CEA-708B Section 7.1.
+ int blockLimit = pos + blockSize;
+ while (pos < blockLimit) {
+ pos = parseServiceBlockData(data, pos);
+ }
+
+ // Emit the buffer after reading codes.
+ emitCaptionBuffer();
+ pos = blockLimit;
+ }
+ }
+
+ // Step 4. Main code groups
+ private int parseServiceBlockData(byte[] data, int pos) {
+ // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6.
+ mCommand = data[pos] & 0xff;
+ ++pos;
+ if (mCommand == Cea708Data.CODE_C0_EXT1) {
+ pos = parseExt1(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C0_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_RANGE_END) {
+ pos = parseC0(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C1_RANGE_END) {
+ pos = parseC1(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G0_RANGE_START
+ && mCommand <= Cea708Data.CODE_G0_RANGE_END) {
+ pos = parseG0(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G1_RANGE_START
+ && mCommand <= Cea708Data.CODE_G1_RANGE_END) {
+ pos = parseG1(data, pos);
+ }
+ return pos;
+ }
+
+ private int parseC0(byte[] data, int pos) {
+ // For the details of C0 code group, see CEA-708B Section 7.4.1.
+ // CL Group: C0 Subset of ASCII Control codes
+ if (mCommand >= Cea708Data.CODE_C0_SKIP2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_SKIP2_RANGE_END) {
+ if (mCommand == Cea708Data.CODE_C0_P16) {
+ // TODO : P16 escapes next two bytes for the large character maps.(no standard rule)
+ // TODO : For korea broadcasting, express whole letters by using this.
+ try {
+ if (data[pos] == 0) {
+ mBuffer.append((char) data[pos + 1]);
+ } else {
+ String value = new String(
+ Arrays.copyOfRange(data, pos, pos + 2),
+ "EUC-KR");
+ mBuffer.append(value);
+ }
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "P16 Code - Could not find supported encoding", e);
+ }
+ }
+ pos += 2;
+ } else if (mCommand >= Cea708Data.CODE_C0_SKIP1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C0_SKIP1_RANGE_END) {
+ ++pos;
+ } else {
+ // NUL, BS, FF, CR interpreted as they are in ASCII control codes.
+ // HCR moves the pen location to th beginning of the current line and deletes contents.
+ // FF clears the screen and moves the pen location to (0,0).
+ // ETX is the NULL command which is used to flush text to the current window when no
+ // other command is pending.
+ switch (mCommand) {
+ case Cea708Data.CODE_C0_NUL:
+ break;
+ case Cea708Data.CODE_C0_ETX:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_BS:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_FF:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ case Cea708Data.CODE_C0_CR:
+ mBuffer.append('\n');
+ break;
+ case Cea708Data.CODE_C0_HCR:
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_CONTROL, (char) mCommand));
+ break;
+ default:
+ break;
+ }
+ }
+ return pos;
+ }
+
+ private int parseC1(byte[] data, int pos) {
+ // For the details of C1 code group, see CEA-708B Section 8.10.
+ // CR Group: C1 Caption Control Codes
+ switch (mCommand) {
+ case Cea708Data.CODE_C1_CW0:
+ case Cea708Data.CODE_C1_CW1:
+ case Cea708Data.CODE_C1_CW2:
+ case Cea708Data.CODE_C1_CW3:
+ case Cea708Data.CODE_C1_CW4:
+ case Cea708Data.CODE_C1_CW5:
+ case Cea708Data.CODE_C1_CW6:
+ case Cea708Data.CODE_C1_CW7: {
+ // SetCurrentWindow0-7
+ int windowId = mCommand - Cea708Data.CODE_C1_CW0;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CWX, windowId));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand CWX windowId: %d", windowId));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_CLW: {
+ // ClearWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_CLW, windowBitmap));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand CLW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DSW: {
+ // DisplayWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DSW, windowBitmap));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand DSW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_HDW: {
+ // HideWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_HDW, windowBitmap));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand HDW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_TGW: {
+ // ToggleWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_TGW, windowBitmap));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand TGW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DLW: {
+ // DeleteWindows
+ int windowBitmap = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLW, windowBitmap));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand DLW windowBitmap: %d", windowBitmap));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DLY: {
+ // Delay
+ int tenthsOfSeconds = data[pos] & 0xff;
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLY, tenthsOfSeconds));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand DLY %d tenths of seconds",
+ tenthsOfSeconds));
+ }
+ break;
+ }
+ case Cea708Data.CODE_C1_DLC: {
+ // DelayCancel
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DLC, null));
+ if (DEBUG) {
+ Log.d(TAG, "CaptionCommand DLC");
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_RST: {
+ // Reset
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_RST, null));
+ if (DEBUG) {
+ Log.d(TAG, "CaptionCommand RST");
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPA: {
+ // SetPenAttributes
+ int textTag = (data[pos] & 0xf0) >> 4;
+ int penSize = data[pos] & 0x03;
+ int penOffset = (data[pos] & 0x0c) >> 2;
+ boolean italic = (data[pos + 1] & 0x80) != 0;
+ boolean underline = (data[pos + 1] & 0x40) != 0;
+ int edgeType = (data[pos + 1] & 0x38) >> 3;
+ int fontTag = data[pos + 1] & 0x7;
+ pos += 2;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPA,
+ new CaptionPenAttr(penSize, penOffset, textTag, fontTag, edgeType,
+ underline, italic)));
+ if (DEBUG) {
+ Log.d(TAG, String.format(
+ "CaptionCommand SPA penSize: %d, penOffset: %d, textTag: %d, "
+ + "fontTag: %d, edgeType: %d, underline: %s, italic: %s",
+ penSize, penOffset, textTag, fontTag, edgeType, underline, italic));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPC: {
+ // SetPenColor
+ int opacity = (data[pos] & 0xc0) >> 6;
+ int red = (data[pos] & 0x30) >> 4;
+ int green = (data[pos] & 0x0c) >> 2;
+ int blue = data[pos] & 0x03;
+ CaptionColor foregroundColor = new CaptionColor(opacity, red, green, blue);
+ ++pos;
+ opacity = (data[pos] & 0xc0) >> 6;
+ red = (data[pos] & 0x30) >> 4;
+ green = (data[pos] & 0x0c) >> 2;
+ blue = data[pos] & 0x03;
+ CaptionColor backgroundColor = new CaptionColor(opacity, red, green, blue);
+ ++pos;
+ red = (data[pos] & 0x30) >> 4;
+ green = (data[pos] & 0x0c) >> 2;
+ blue = data[pos] & 0x03;
+ CaptionColor edgeColor = new CaptionColor(
+ CaptionColor.OPACITY_SOLID, red, green, blue);
+ ++pos;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPC,
+ new CaptionPenColor(foregroundColor, backgroundColor, edgeColor)));
+ if (DEBUG) {
+ Log.d(TAG, String.format(
+ "CaptionCommand SPC foregroundColor %s backgroundColor %s edgeColor %s",
+ foregroundColor, backgroundColor, edgeColor));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SPL: {
+ // SetPenLocation
+ // column is normally 0-31 for 4:3 formats, and 0-41 for 16:9 formats
+ int row = data[pos] & 0x0f;
+ int column = data[pos + 1] & 0x3f;
+ pos += 2;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SPL,
+ new CaptionPenLocation(row, column)));
+ if (DEBUG) {
+ Log.d(TAG, String.format("CaptionCommand SPL row: %d, column: %d",
+ row, column));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_SWA: {
+ // SetWindowAttributes
+ int opacity = (data[pos] & 0xc0) >> 6;
+ int red = (data[pos] & 0x30) >> 4;
+ int green = (data[pos] & 0x0c) >> 2;
+ int blue = data[pos] & 0x03;
+ CaptionColor fillColor = new CaptionColor(opacity, red, green, blue);
+ int borderType = (data[pos + 1] & 0xc0) >> 6 | (data[pos + 2] & 0x80) >> 5;
+ red = (data[pos + 1] & 0x30) >> 4;
+ green = (data[pos + 1] & 0x0c) >> 2;
+ blue = data[pos + 1] & 0x03;
+ CaptionColor borderColor = new CaptionColor(
+ CaptionColor.OPACITY_SOLID, red, green, blue);
+ boolean wordWrap = (data[pos + 2] & 0x40) != 0;
+ int printDirection = (data[pos + 2] & 0x30) >> 4;
+ int scrollDirection = (data[pos + 2] & 0x0c) >> 2;
+ int justify = (data[pos + 2] & 0x03);
+ int effectSpeed = (data[pos + 3] & 0xf0) >> 4;
+ int effectDirection = (data[pos + 3] & 0x0c) >> 2;
+ int displayEffect = data[pos + 3] & 0x3;
+ pos += 4;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_SWA,
+ new CaptionWindowAttr(fillColor, borderColor, borderType, wordWrap,
+ printDirection, scrollDirection, justify,
+ effectDirection, effectSpeed, displayEffect)));
+ if (DEBUG) {
+ Log.d(TAG, String.format(
+ "CaptionCommand SWA fillColor: %s, borderColor: %s, borderType: %d"
+ + "wordWrap: %s, printDirection: %d, scrollDirection: %d, "
+ + "justify: %s, effectDirection: %d, effectSpeed: %d, "
+ + "displayEffect: %d",
+ fillColor, borderColor, borderType, wordWrap, printDirection,
+ scrollDirection, justify, effectDirection, effectSpeed, displayEffect));
+ }
+ break;
+ }
+
+ case Cea708Data.CODE_C1_DF0:
+ case Cea708Data.CODE_C1_DF1:
+ case Cea708Data.CODE_C1_DF2:
+ case Cea708Data.CODE_C1_DF3:
+ case Cea708Data.CODE_C1_DF4:
+ case Cea708Data.CODE_C1_DF5:
+ case Cea708Data.CODE_C1_DF6:
+ case Cea708Data.CODE_C1_DF7: {
+ // DefineWindow0-7
+ int windowId = mCommand - Cea708Data.CODE_C1_DF0;
+ boolean visible = (data[pos] & 0x20) != 0;
+ boolean rowLock = (data[pos] & 0x10) != 0;
+ boolean columnLock = (data[pos] & 0x08) != 0;
+ int priority = data[pos] & 0x07;
+ boolean relativePositioning = (data[pos + 1] & 0x80) != 0;
+ int anchorVertical = data[pos + 1] & 0x7f;
+ int anchorHorizontal = data[pos + 2] & 0xff;
+ int anchorId = (data[pos + 3] & 0xf0) >> 4;
+ int rowCount = data[pos + 3] & 0x0f;
+ int columnCount = data[pos + 4] & 0x3f;
+ int windowStyle = (data[pos + 5] & 0x38) >> 3;
+ int penStyle = data[pos + 5] & 0x07;
+ pos += 6;
+ emitCaptionEvent(new CaptionEvent(CAPTION_EMIT_TYPE_COMMAND_DFX,
+ new CaptionWindow(windowId, visible, rowLock, columnLock, priority,
+ relativePositioning, anchorVertical, anchorHorizontal, anchorId,
+ rowCount, columnCount, penStyle, windowStyle)));
+ if (DEBUG) {
+ Log.d(TAG, String.format(
+ "CaptionCommand DFx windowId: %d, priority: %d, columnLock: %s, "
+ + "rowLock: %s, visible: %s, anchorVertical: %d, "
+ + "relativePositioning: %s, anchorHorizontal: %d, "
+ + "rowCount: %d, anchorId: %d, columnCount: %d, penStyle: %d, "
+ + "windowStyle: %d",
+ windowId, priority, columnLock, rowLock, visible, anchorVertical,
+ relativePositioning, anchorHorizontal, rowCount, anchorId, columnCount,
+ penStyle, windowStyle));
+ }
+ break;
+ }
+
+ default:
+ break;
+ }
+ return pos;
+ }
+
+ private int parseG0(byte[] data, int pos) {
+ // For the details of G0 code group, see CEA-708B Section 7.4.3.
+ // GL Group: G0 Modified version of ANSI X3.4 Printable Character Set (ASCII)
+ if (mCommand == Cea708Data.CODE_G0_MUSICNOTE) {
+ // Music note.
+ mBuffer.append(MUSIC_NOTE_CHAR);
+ } else {
+ // Put ASCII code into buffer.
+ mBuffer.append((char) mCommand);
+ }
+ return pos;
+ }
+
+ private int parseG1(byte[] data, int pos) {
+ // For the details of G0 code group, see CEA-708B Section 7.4.4.
+ // GR Group: G1 ISO 8859-1 Latin 1 Characters
+ // Put ASCII Extended character set into buffer.
+ mBuffer.append((char) mCommand);
+ return pos;
+ }
+
+ // Step 4. Extended code groups
+ private int parseExt1(byte[] data, int pos) {
+ // For the details of EXT1 code group, see CEA-708B Section 7.2.
+ mCommand = data[pos] & 0xff;
+ ++pos;
+ if (mCommand >= Cea708Data.CODE_C2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_RANGE_END) {
+ pos = parseC2(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_C3_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_RANGE_END) {
+ pos = parseC3(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G2_RANGE_START
+ && mCommand <= Cea708Data.CODE_G2_RANGE_END) {
+ pos = parseG2(data, pos);
+ } else if (mCommand >= Cea708Data.CODE_G3_RANGE_START
+ && mCommand <= Cea708Data.CODE_G3_RANGE_END) {
+ pos = parseG3(data ,pos);
+ }
+ return pos;
+ }
+
+ private int parseC2(byte[] data, int pos) {
+ // For the details of C2 code group, see CEA-708B Section 7.4.7.
+ // Extended Miscellaneous Control Codes
+ // C2 Table : No commands as of CEA-708B. A decoder must skip.
+ if (mCommand >= Cea708Data.CODE_C2_SKIP0_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP0_RANGE_END) {
+ // Do nothing.
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP1_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP1_RANGE_END) {
+ ++pos;
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP2_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP2_RANGE_END) {
+ pos += 2;
+ } else if (mCommand >= Cea708Data.CODE_C2_SKIP3_RANGE_START
+ && mCommand <= Cea708Data.CODE_C2_SKIP3_RANGE_END) {
+ pos += 3;
+ }
+ return pos;
+ }
+
+ private int parseC3(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.8.
+ // Extended Control Code Set 2
+ // C3 Table : No commands as of CEA-708B. A decoder must skip.
+ if (mCommand >= Cea708Data.CODE_C3_SKIP4_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_SKIP4_RANGE_END) {
+ pos += 4;
+ } else if (mCommand >= Cea708Data.CODE_C3_SKIP5_RANGE_START
+ && mCommand <= Cea708Data.CODE_C3_SKIP5_RANGE_END) {
+ pos += 5;
+ }
+ return pos;
+ }
+
+ private int parseG2(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.5.
+ // Extended Control Code Set 1(G2 Table)
+ switch (mCommand) {
+ case Cea708Data.CODE_G2_TSP:
+ // TODO : TSP is the Transparent space
+ break;
+ case Cea708Data.CODE_G2_NBTSP:
+ // TODO : NBTSP is Non-Breaking Transparent Space.
+ break;
+ case Cea708Data.CODE_G2_BLK:
+ // TODO : BLK indicates a solid block which fills the entire character block
+ // TODO : with a solid foreground color.
+ break;
+ default:
+ break;
+ }
+ return pos;
+ }
+
+ private int parseG3(byte[] data, int pos) {
+ // For the details of C3 code group, see CEA-708B Section 7.4.6.
+ // Future characters and icons(G3 Table)
+ if (mCommand == Cea708Data.CODE_G3_CC) {
+ // TODO : [CC] icon with square corners
+ }
+
+ // Do nothing
+ return pos;
+ }
+}
diff --git a/src/com/android/tv/tuner/data/Cea708Data.java b/src/com/android/tv/tuner/data/Cea708Data.java
new file mode 100644
index 00000000..6350d63c
--- /dev/null
+++ b/src/com/android/tv/tuner/data/Cea708Data.java
@@ -0,0 +1,320 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.data;
+
+import com.android.tv.tuner.cc.Cea708Parser;
+
+import android.graphics.Color;
+import android.support.annotation.NonNull;
+
+/**
+ * Collection of CEA-708 structures.
+ */
+public class Cea708Data {
+
+ private Cea708Data() {
+ }
+
+ // According to CEA-708B, the range of valid service number is between 1 and 63.
+ public static final int EMPTY_SERVICE_NUMBER = 0;
+
+ // For the details of the ranges of DTVCC code groups, see CEA-708B Table 6.
+ public static final int CODE_C0_RANGE_START = 0x00;
+ public static final int CODE_C0_RANGE_END = 0x1f;
+ public static final int CODE_C1_RANGE_START = 0x80;
+ public static final int CODE_C1_RANGE_END = 0x9f;
+ public static final int CODE_G0_RANGE_START = 0x20;
+ public static final int CODE_G0_RANGE_END = 0x7f;
+ public static final int CODE_G1_RANGE_START = 0xa0;
+ public static final int CODE_G1_RANGE_END = 0xff;
+ public static final int CODE_C2_RANGE_START = 0x00;
+ public static final int CODE_C2_RANGE_END = 0x1f;
+ public static final int CODE_C3_RANGE_START = 0x80;
+ public static final int CODE_C3_RANGE_END = 0x9f;
+ public static final int CODE_G2_RANGE_START = 0x20;
+ public static final int CODE_G2_RANGE_END = 0x7f;
+ public static final int CODE_G3_RANGE_START = 0xa0;
+ public static final int CODE_G3_RANGE_END = 0xff;
+
+ // The following ranges are defined in CEA-708B Section 7.4.1.
+ public static final int CODE_C0_SKIP2_RANGE_START = 0x18;
+ public static final int CODE_C0_SKIP2_RANGE_END = 0x1f;
+ public static final int CODE_C0_SKIP1_RANGE_START = 0x10;
+ public static final int CODE_C0_SKIP1_RANGE_END = 0x17;
+
+ // The following ranges are defined in CEA-708B Section 7.4.7.
+ public static final int CODE_C2_SKIP0_RANGE_START = 0x00;
+ public static final int CODE_C2_SKIP0_RANGE_END = 0x07;
+ public static final int CODE_C2_SKIP1_RANGE_START = 0x08;
+ public static final int CODE_C2_SKIP1_RANGE_END = 0x0f;
+ public static final int CODE_C2_SKIP2_RANGE_START = 0x10;
+ public static final int CODE_C2_SKIP2_RANGE_END = 0x17;
+ public static final int CODE_C2_SKIP3_RANGE_START = 0x18;
+ public static final int CODE_C2_SKIP3_RANGE_END = 0x1f;
+
+ // The following ranges are defined in CEA-708B Section 7.4.8.
+ public static final int CODE_C3_SKIP4_RANGE_START = 0x80;
+ public static final int CODE_C3_SKIP4_RANGE_END = 0x87;
+ public static final int CODE_C3_SKIP5_RANGE_START = 0x88;
+ public static final int CODE_C3_SKIP5_RANGE_END = 0x8f;
+
+ // The following values are the special characters of CEA-708 spec.
+ public static final int CODE_C0_NUL = 0x00;
+ public static final int CODE_C0_ETX = 0x03;
+ public static final int CODE_C0_BS = 0x08;
+ public static final int CODE_C0_FF = 0x0c;
+ public static final int CODE_C0_CR = 0x0d;
+ public static final int CODE_C0_HCR = 0x0e;
+ public static final int CODE_C0_EXT1 = 0x10;
+ public static final int CODE_C0_P16 = 0x18;
+ public static final int CODE_G0_MUSICNOTE = 0x7f;
+ public static final int CODE_G2_TSP = 0x20;
+ public static final int CODE_G2_NBTSP = 0x21;
+ public static final int CODE_G2_BLK = 0x30;
+ public static final int CODE_G3_CC = 0xa0;
+
+ // The following values are the command bits of CEA-708 spec.
+ public static final int CODE_C1_CW0 = 0x80;
+ public static final int CODE_C1_CW1 = 0x81;
+ public static final int CODE_C1_CW2 = 0x82;
+ public static final int CODE_C1_CW3 = 0x83;
+ public static final int CODE_C1_CW4 = 0x84;
+ public static final int CODE_C1_CW5 = 0x85;
+ public static final int CODE_C1_CW6 = 0x86;
+ public static final int CODE_C1_CW7 = 0x87;
+ public static final int CODE_C1_CLW = 0x88;
+ public static final int CODE_C1_DSW = 0x89;
+ public static final int CODE_C1_HDW = 0x8a;
+ public static final int CODE_C1_TGW = 0x8b;
+ public static final int CODE_C1_DLW = 0x8c;
+ public static final int CODE_C1_DLY = 0x8d;
+ public static final int CODE_C1_DLC = 0x8e;
+ public static final int CODE_C1_RST = 0x8f;
+ public static final int CODE_C1_SPA = 0x90;
+ public static final int CODE_C1_SPC = 0x91;
+ public static final int CODE_C1_SPL = 0x92;
+ public static final int CODE_C1_SWA = 0x97;
+ public static final int CODE_C1_DF0 = 0x98;
+ public static final int CODE_C1_DF1 = 0x99;
+ public static final int CODE_C1_DF2 = 0x9a;
+ public static final int CODE_C1_DF3 = 0x9b;
+ public static final int CODE_C1_DF4 = 0x9c;
+ public static final int CODE_C1_DF5 = 0x9d;
+ public static final int CODE_C1_DF6 = 0x9e;
+ public static final int CODE_C1_DF7 = 0x9f;
+
+ public static class CcPacket implements Comparable<CcPacket> {
+ public final byte[] bytes;
+ public final int ccCount;
+ public final long pts;
+
+ public CcPacket(byte[] bytes, int ccCount, long pts) {
+ this.bytes = bytes;
+ this.ccCount = ccCount;
+ this.pts = pts;
+ }
+
+ @Override
+ public int compareTo(@NonNull CcPacket another) {
+ return Long.compare(pts, another.pts);
+ }
+ }
+
+ /**
+ * CEA-708B-specific color.
+ */
+ public static class CaptionColor {
+ public static final int OPACITY_SOLID = 0;
+ public static final int OPACITY_FLASH = 1;
+ public static final int OPACITY_TRANSLUCENT = 2;
+ public static final int OPACITY_TRANSPARENT = 3;
+
+ private static final int[] COLOR_MAP = new int[] { 0x00, 0x0f, 0xf0, 0xff };
+ private static final int[] OPACITY_MAP = new int[] { 0xff, 0xfe, 0x80, 0x00 };
+
+ public final int opacity;
+ public final int red;
+ public final int green;
+ public final int blue;
+
+ public CaptionColor(int opacity, int red, int green, int blue) {
+ this.opacity = opacity;
+ this.red = red;
+ this.green = green;
+ this.blue = blue;
+ }
+
+ public int getArgbValue() {
+ return Color.argb(
+ OPACITY_MAP[opacity], COLOR_MAP[red], COLOR_MAP[green], COLOR_MAP[blue]);
+ }
+ }
+
+ /**
+ * Caption event generated by {@link Cea708Parser}.
+ */
+ public static class CaptionEvent {
+ @Cea708Parser.CaptionEmitType public final int type;
+ public final Object obj;
+
+ public CaptionEvent(int type, Object obj) {
+ this.type = type;
+ this.obj = obj;
+ }
+ }
+
+ /**
+ * Pen style information.
+ */
+ public static class CaptionPenAttr {
+ // Pen sizes
+ public static final int PEN_SIZE_SMALL = 0;
+ public static final int PEN_SIZE_STANDARD = 1;
+ public static final int PEN_SIZE_LARGE = 2;
+
+ // Offsets
+ public static final int OFFSET_SUBSCRIPT = 0;
+ public static final int OFFSET_NORMAL = 1;
+ public static final int OFFSET_SUPERSCRIPT = 2;
+
+ public final int penSize;
+ public final int penOffset;
+ public final int textTag;
+ public final int fontTag;
+ public final int edgeType;
+ public final boolean underline;
+ public final boolean italic;
+
+ public CaptionPenAttr(int penSize, int penOffset, int textTag, int fontTag, int edgeType,
+ boolean underline, boolean italic) {
+ this.penSize = penSize;
+ this.penOffset = penOffset;
+ this.textTag = textTag;
+ this.fontTag = fontTag;
+ this.edgeType = edgeType;
+ this.underline = underline;
+ this.italic = italic;
+ }
+ }
+
+ /**
+ * {@link CaptionColor} objects that indicate the foreground, background, and edge color of a
+ * pen.
+ */
+ public static class CaptionPenColor {
+ public final CaptionColor foregroundColor;
+ public final CaptionColor backgroundColor;
+ public final CaptionColor edgeColor;
+
+ public CaptionPenColor(CaptionColor foregroundColor, CaptionColor backgroundColor,
+ CaptionColor edgeColor) {
+ this.foregroundColor = foregroundColor;
+ this.backgroundColor = backgroundColor;
+ this.edgeColor = edgeColor;
+ }
+ }
+
+ /**
+ * Location information of a pen.
+ */
+ public static class CaptionPenLocation {
+ public final int row;
+ public final int column;
+
+ public CaptionPenLocation(int row, int column) {
+ this.row = row;
+ this.column = column;
+ }
+ }
+
+ /**
+ * Attributes of a caption window, which is defined in CEA-708B.
+ */
+ public static class CaptionWindowAttr {
+ public static final int JUSTIFY_LEFT = 0;
+ public static final int JUSTIFY_CENTER = 2;
+ public static final int PRINT_LEFT_TO_RIGHT = 0;
+ public static final int PRINT_RIGHT_TO_LEFT = 1;
+ public static final int PRINT_TOP_TO_BOTTOM = 2;
+ public static final int PRINT_BOTTOM_TO_TOP = 3;
+
+ public final CaptionColor fillColor;
+ public final CaptionColor borderColor;
+ public final int borderType;
+ public final boolean wordWrap;
+ public final int printDirection;
+ public final int scrollDirection;
+ public final int justify;
+ public final int effectDirection;
+ public final int effectSpeed;
+ public final int displayEffect;
+
+ public CaptionWindowAttr(CaptionColor fillColor, CaptionColor borderColor, int borderType,
+ boolean wordWrap, int printDirection, int scrollDirection, int justify,
+ int effectDirection,
+ int effectSpeed, int displayEffect) {
+ this.fillColor = fillColor;
+ this.borderColor = borderColor;
+ this.borderType = borderType;
+ this.wordWrap = wordWrap;
+ this.printDirection = printDirection;
+ this.scrollDirection = scrollDirection;
+ this.justify = justify;
+ this.effectDirection = effectDirection;
+ this.effectSpeed = effectSpeed;
+ this.displayEffect = displayEffect;
+ }
+ }
+
+ /**
+ * Construction information of the caption window of CEA-708B.
+ */
+ public static class CaptionWindow {
+ public final int id;
+ public final boolean visible;
+ public final boolean rowLock;
+ public final boolean columnLock;
+ public final int priority;
+ public final boolean relativePositioning;
+ public final int anchorVertical;
+ public final int anchorHorizontal;
+ public final int anchorId;
+ public final int rowCount;
+ public final int columnCount;
+ public final int penStyle;
+ public final int windowStyle;
+
+ public CaptionWindow(int id, boolean visible,
+ boolean rowLock, boolean columnLock, int priority, boolean relativePositioning,
+ int anchorVertical, int anchorHorizontal, int anchorId,
+ int rowCount, int columnCount, int penStyle, int windowStyle) {
+ this.id = id;
+ this.visible = visible;
+ this.rowLock = rowLock;
+ this.columnLock = columnLock;
+ this.priority = priority;
+ this.relativePositioning = relativePositioning;
+ this.anchorVertical = anchorVertical;
+ this.anchorHorizontal = anchorHorizontal;
+ this.anchorId = anchorId;
+ this.rowCount = rowCount;
+ this.columnCount = columnCount;
+ this.penStyle = penStyle;
+ this.windowStyle = windowStyle;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/data/PsiData.java b/src/com/android/tv/tuner/data/PsiData.java
new file mode 100644
index 00000000..2c8a52db
--- /dev/null
+++ b/src/com/android/tv/tuner/data/PsiData.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.android.tv.tuner.data;
+
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+
+import java.util.List;
+
+/**
+ * Collection of MPEG PSI table items.
+ */
+public class PsiData {
+
+ private PsiData() {
+ }
+
+ public static class PatItem {
+ private final int mProgramNo;
+ private final int mPmtPid;
+
+ public PatItem(int programNo, int pmtPid) {
+ mProgramNo = programNo;
+ mPmtPid = pmtPid;
+ }
+
+ public int getProgramNo() {
+ return mProgramNo;
+ }
+
+ public int getPmtPid() {
+ return mPmtPid;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Program No: %x PMT Pid: %x", mProgramNo, mPmtPid);
+ }
+ }
+
+ public static class PmtItem {
+ public static final int ES_PID_PCR = 0x100;
+
+ private final int mStreamType;
+ private final int mEsPid;
+ private final List<AtscAudioTrack> mAudioTracks;
+ private final List<AtscCaptionTrack> mCaptionTracks;
+
+ public PmtItem(int streamType, int esPid,
+ List<AtscAudioTrack> audioTracks, List<AtscCaptionTrack> captionTracks) {
+ mStreamType = streamType;
+ mEsPid = esPid;
+ mAudioTracks = audioTracks;
+ mCaptionTracks = captionTracks;
+ }
+
+ public int getStreamType() {
+ return mStreamType;
+ }
+
+ public int getEsPid() {
+ return mEsPid;
+ }
+
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Stream Type: %x ES Pid: %x AudioTracks: %s CaptionTracks: %s",
+ mStreamType, mEsPid, mAudioTracks, mCaptionTracks);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/data/PsipData.java b/src/com/android/tv/tuner/data/PsipData.java
new file mode 100644
index 00000000..e3cdb3a9
--- /dev/null
+++ b/src/com/android/tv/tuner/data/PsipData.java
@@ -0,0 +1,689 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.data;
+
+import android.support.annotation.NonNull;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.ts.SectionParser;
+import com.android.tv.tuner.util.ConvertUtils;
+import com.android.tv.tuner.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Collection of ATSC PSIP table items.
+ */
+public class PsipData {
+
+ private PsipData() {
+ }
+
+ public static class PsipSection {
+ private final int mTableId;
+ private final int mTableIdExtension;
+ private final int mSectionNumber;
+ private final boolean mCurrentNextIndicator;
+
+ public static PsipSection create(byte[] data) {
+ if (data.length < 9) {
+ return null;
+ }
+ int tableId = data[0] & 0xff;
+ int tableIdExtension = (data[3] & 0xff) << 8 | (data[4] & 0xff);
+ int sectionNumber = data[6] & 0xff;
+ boolean currentNextIndicator = (data[5] & 0x01) != 0;
+ return new PsipSection(tableId, tableIdExtension, sectionNumber, currentNextIndicator);
+ }
+
+ private PsipSection(int tableId, int tableIdExtension, int sectionNumber,
+ boolean currentNextIndicator) {
+ mTableId = tableId;
+ mTableIdExtension = tableIdExtension;
+ mSectionNumber = sectionNumber;
+ mCurrentNextIndicator = currentNextIndicator;
+ }
+
+ public int getTableId() {
+ return mTableId;
+ }
+
+ public int getTableIdExtension() {
+ return mTableIdExtension;
+ }
+
+ public int getSectionNumber() {
+ return mSectionNumber;
+ }
+
+ // This is for indicating that the section sent is applicable.
+ // We only consider a situation where currentNextIndicator is expected to have a true value.
+ // So, we are not going to compare this variable in hashCode() and equals() methods.
+ public boolean getCurrentNextIndicator() {
+ return mCurrentNextIndicator;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + mTableId;
+ result = 31 * result + mTableIdExtension;
+ result = 31 * result + mSectionNumber;
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof PsipSection) {
+ PsipSection another = (PsipSection) obj;
+ return mTableId == another.getTableId()
+ && mTableIdExtension == another.getTableIdExtension()
+ && mSectionNumber == another.getSectionNumber();
+ }
+ return false;
+ }
+ }
+
+ /**
+ * {@link TvTracksInterface} for serving the audio and caption tracks.
+ */
+ public interface TvTracksInterface {
+ /**
+ * Set the flag that tells the caption tracks have been found in this section container.
+ */
+ void setHasCaptionTrack();
+
+ /**
+ * Returns whether or not the caption tracks have been found in this section container.
+ * If true, zero caption track will be interpreted as a clearance of the caption tracks.
+ */
+ boolean hasCaptionTrack();
+
+ /**
+ * Returns the audio tracks received.
+ */
+ List<AtscAudioTrack> getAudioTracks();
+
+ /**
+ * Returns the caption tracks received.
+ */
+ List<AtscCaptionTrack> getCaptionTracks();
+ }
+
+ public static class MgtItem {
+ public static final int TABLE_TYPE_EIT_RANGE_START = 0x0100;
+ public static final int TABLE_TYPE_EIT_RANGE_END = 0x017f;
+ public static final int TABLE_TYPE_CHANNEL_ETT = 0x0004;
+ public static final int TABLE_TYPE_ETT_RANGE_START = 0x0200;
+ public static final int TABLE_TYPE_ETT_RANGE_END = 0x027f;
+
+ private final int mTableType;
+ private final int mTableTypePid;
+
+ public MgtItem(int tableType, int tableTypePid) {
+ mTableType = tableType;
+ mTableTypePid = tableTypePid;
+ }
+
+ public int getTableType() {
+ return mTableType;
+ }
+
+ public int getTableTypePid() {
+ return mTableTypePid;
+ }
+ }
+
+ public static class VctItem {
+ private final String mShortName;
+ private final String mLongName;
+ private final int mServiceType;
+ private final int mChannelTsid;
+ private final int mProgramNumber;
+ private final int mMajorChannelNumber;
+ private final int mMinorChannelNumber;
+ private final int mSourceId;
+ private String mDescription;
+
+ public VctItem(String shortName, String longName, int serviceType, int channelTsid,
+ int programNumber, int majorChannelNumber, int minorChannelNumber, int sourceId) {
+ mShortName = shortName;
+ mLongName = longName;
+ mServiceType = serviceType;
+ mChannelTsid = channelTsid;
+ mProgramNumber = programNumber;
+ mMajorChannelNumber = majorChannelNumber;
+ mMinorChannelNumber = minorChannelNumber;
+ mSourceId = sourceId;
+ }
+
+ public String getShortName() {
+ return mShortName;
+ }
+
+ public String getLongName() {
+ return mLongName;
+ }
+
+ public int getServiceType() {
+ return mServiceType;
+ }
+
+ public int getChannelTsid() {
+ return mChannelTsid;
+ }
+
+ public int getProgramNumber() {
+ return mProgramNumber;
+ }
+
+ public int getMajorChannelNumber() {
+ return mMajorChannelNumber;
+ }
+
+ public int getMinorChannelNumber() {
+ return mMinorChannelNumber;
+ }
+
+ public int getSourceId() {
+ return mSourceId;
+ }
+
+ @Override
+ public String toString() {
+ return String
+ .format(Locale.US, "ShortName: %s LongName: %s ServiceType: %d ChannelTsid: %x "
+ + "ProgramNumber:%d %d-%d SourceId: %x",
+ mShortName, mLongName, mServiceType, mChannelTsid,
+ mProgramNumber, mMajorChannelNumber, mMinorChannelNumber, mSourceId);
+ }
+
+ public void setDescription(String description) {
+ mDescription = description;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+ }
+
+ /**
+ * A base class for descriptors of Ts packets.
+ */
+ public abstract static class TsDescriptor {
+ public abstract int getTag();
+ }
+
+ public static class ContentAdvisoryDescriptor extends TsDescriptor {
+ private final List<RatingRegion> mRatingRegions;
+
+ public ContentAdvisoryDescriptor(List<RatingRegion> ratingRegions) {
+ mRatingRegions = ratingRegions;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_CONTENT_ADVISORY;
+ }
+
+ public List<RatingRegion> getRatingRegions() {
+ return mRatingRegions;
+ }
+ }
+
+ public static class CaptionServiceDescriptor extends TsDescriptor {
+ private final List<AtscCaptionTrack> mCaptionTracks;
+
+ public CaptionServiceDescriptor(List<AtscCaptionTrack> captionTracks) {
+ mCaptionTracks = captionTracks;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_CAPTION_SERVICE;
+ }
+
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+ }
+
+ public static class ExtendedChannelNameDescriptor extends TsDescriptor {
+ private final String mLongChannelName;
+
+ public ExtendedChannelNameDescriptor(String longChannelName) {
+ mLongChannelName = longChannelName;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME;
+ }
+
+ public String getLongChannelName() {
+ return mLongChannelName;
+ }
+ }
+
+ public static class GenreDescriptor extends TsDescriptor {
+ private final String[] mBroadcastGenres;
+ private final String[] mCanonicalGenres;
+
+ public GenreDescriptor(String[] broadcastGenres, String[] canonicalGenres) {
+ mBroadcastGenres = broadcastGenres;
+ mCanonicalGenres = canonicalGenres;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_GENRE;
+ }
+
+ public String[] getBroadcastGenres() {
+ return mBroadcastGenres;
+ }
+
+ public String[] getCanonicalGenres() {
+ return mCanonicalGenres;
+ }
+ }
+
+ public static class Ac3AudioDescriptor extends TsDescriptor {
+ // See A/52 Annex A. Table A4.2
+ private static final byte SAMPLE_RATE_CODE_48000HZ = 0;
+ private static final byte SAMPLE_RATE_CODE_44100HZ = 1;
+ private static final byte SAMPLE_RATE_CODE_32000HZ = 2;
+
+ private final byte mSampleRateCode;
+ private final byte mBsid;
+ private final byte mBitRateCode;
+ private final byte mSurroundMode;
+ private final byte mBsmod;
+ private final int mNumChannels;
+ private final boolean mFullSvc;
+ private final byte mLangCod;
+ private final byte mLangCod2;
+ private final byte mMainId;
+ private final byte mPriority;
+ private final byte mAsvcflags;
+ private final String mText;
+ private final String mLanguage;
+ private final String mLanguage2;
+
+ public Ac3AudioDescriptor(byte sampleRateCode, byte bsid, byte bitRateCode,
+ byte surroundMode, byte bsmod, int numChannels, boolean fullSvc, byte langCod,
+ byte langCod2, byte mainId, byte priority, byte asvcflags, String text,
+ String language, String language2) {
+ mSampleRateCode = sampleRateCode;
+ mBsid = bsid;
+ mBitRateCode = bitRateCode;
+ mSurroundMode = surroundMode;
+ mBsmod = bsmod;
+ mNumChannels = numChannels;
+ mFullSvc = fullSvc;
+ mLangCod = langCod;
+ mLangCod2 = langCod2;
+ mMainId = mainId;
+ mPriority = priority;
+ mAsvcflags = asvcflags;
+ mText = text;
+ mLanguage = language;
+ mLanguage2 = language2;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_AC3_AUDIO_STREAM;
+ }
+
+ public byte getSampleRateCode() {
+ return mSampleRateCode;
+ }
+
+ public int getSampleRate() {
+ switch (mSampleRateCode) {
+ case SAMPLE_RATE_CODE_48000HZ:
+ return 48000;
+ case SAMPLE_RATE_CODE_44100HZ:
+ return 44100;
+ case SAMPLE_RATE_CODE_32000HZ:
+ return 32000;
+ default:
+ return 0;
+ }
+ }
+
+ public byte getBsid() {
+ return mBsid;
+ }
+
+ public byte getBitRateCode() {
+ return mBitRateCode;
+ }
+
+ public byte getSurroundMode() {
+ return mSurroundMode;
+ }
+
+ public byte getBsmod() {
+ return mBsmod;
+ }
+
+ public int getNumChannels() {
+ return mNumChannels;
+ }
+
+ public boolean isFullSvc() {
+ return mFullSvc;
+ }
+
+ public byte getLangCod() {
+ return mLangCod;
+ }
+
+ public byte getLangCod2() {
+ return mLangCod2;
+ }
+
+ public byte getMainId() {
+ return mMainId;
+ }
+
+ public byte getPriority() {
+ return mPriority;
+ }
+
+ public byte getAsvcflags() {
+ return mAsvcflags;
+ }
+
+ public String getText() {
+ return mText;
+ }
+
+ public String getLanguage() {
+ return mLanguage;
+ }
+
+ public String getLanguage2() {
+ return mLanguage2;
+ }
+
+ @Override
+ public String toString() {
+ return String.format(Locale.US,
+ "AC3 audio stream sampleRateCode: %d, bsid: %d, bitRateCode: %d, "
+ + "surroundMode: %d, bsmod: %d, numChannels: %d, fullSvc: %s, langCod: %d, "
+ + "langCod2: %d, mainId: %d, priority: %d, avcflags: %d, text: %s, language: %s"
+ + ", language2: %s", mSampleRateCode, mBsid, mBitRateCode, mSurroundMode,
+ mBsmod, mNumChannels, mFullSvc, mLangCod, mLangCod2, mMainId, mPriority,
+ mAsvcflags, mText, mLanguage, mLanguage2);
+ }
+ }
+
+ public static class Iso639LanguageDescriptor extends TsDescriptor {
+ private final List<AtscAudioTrack> mAudioTracks;
+
+ public Iso639LanguageDescriptor(List<AtscAudioTrack> audioTracks) {
+ mAudioTracks = audioTracks;
+ }
+
+ @Override
+ public int getTag() {
+ return SectionParser.DESCRIPTOR_TAG_ISO639LANGUAGE;
+ }
+
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%s %s", getClass().getName(), mAudioTracks);
+ }
+ }
+
+ public static class RatingRegion {
+ private final int mName;
+ private final String mDescription;
+ private final List<RegionalRating> mRegionalRatings;
+
+ public RatingRegion(int name, String description, List<RegionalRating> regionalRatings) {
+ mName = name;
+ mDescription = description;
+ mRegionalRatings = regionalRatings;
+ }
+
+ public int getName() {
+ return mName;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+
+ public List<RegionalRating> getRegionalRatings() {
+ return mRegionalRatings;
+ }
+ }
+
+ public static class RegionalRating {
+ private final int mDimension;
+ private final int mRating;
+
+ public RegionalRating(int dimension, int rating) {
+ mDimension = dimension;
+ mRating = rating;
+ }
+
+ public int getDimension() {
+ return mDimension;
+ }
+
+ public int getRating() {
+ return mRating;
+ }
+ }
+
+ public static class EitItem implements Comparable<EitItem>, TvTracksInterface {
+ public static final long INVALID_PROGRAM_ID = -1;
+
+ // A program id is a primary key of TvContract.Programs table. So it must be positive.
+ private final long mProgramId;
+ private final int mEventId;
+ private final String mTitleText;
+ private String mDescription;
+ private final long mStartTime;
+ private final int mLengthInSecond;
+ private final String mContentRating;
+ private final List<AtscAudioTrack> mAudioTracks;
+ private final List<AtscCaptionTrack> mCaptionTracks;
+ private boolean mHasCaptionTrack;
+ private final String mBroadcastGenre;
+ private final String mCanonicalGenre;
+
+ public EitItem(long programId, int eventId, String titleText, long startTime,
+ int lengthInSecond, String contentRating, List<AtscAudioTrack> audioTracks,
+ List<AtscCaptionTrack> captionTracks, String broadcastGenre, String canonicalGenre,
+ String description) {
+ mProgramId = programId;
+ mEventId = eventId;
+ mTitleText = titleText;
+ mStartTime = startTime;
+ mLengthInSecond = lengthInSecond;
+ mContentRating = contentRating;
+ mAudioTracks = audioTracks;
+ mCaptionTracks = captionTracks;
+ mBroadcastGenre = broadcastGenre;
+ mCanonicalGenre = canonicalGenre;
+ mDescription = description;
+ }
+
+ public long getProgramId() {
+ return mProgramId;
+ }
+
+ public int getEventId() {
+ return mEventId;
+ }
+
+ public String getTitleText() {
+ return mTitleText;
+ }
+
+ public void setDescription(String description) {
+ mDescription = description;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+
+ public long getStartTime() {
+ return mStartTime;
+ }
+
+ public int getLengthInSecond() {
+ return mLengthInSecond;
+ }
+
+ public long getStartTimeUtcMillis() {
+ return ConvertUtils.convertGPSTimeToUnixEpoch(mStartTime) * DateUtils.SECOND_IN_MILLIS;
+ }
+
+ public long getEndTimeUtcMillis() {
+ return ConvertUtils.convertGPSTimeToUnixEpoch(
+ mStartTime + mLengthInSecond) * DateUtils.SECOND_IN_MILLIS;
+ }
+
+ public String getContentRating() {
+ return mContentRating;
+ }
+
+ @Override
+ public List<AtscAudioTrack> getAudioTracks() {
+ return mAudioTracks;
+ }
+
+ @Override
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return mCaptionTracks;
+ }
+
+ public String getBroadcastGenre() {
+ return mBroadcastGenre;
+ }
+
+ public String getCanonicalGenre() {
+ return mCanonicalGenre;
+ }
+
+ @Override
+ public void setHasCaptionTrack() {
+ mHasCaptionTrack = true;
+ }
+
+ @Override
+ public boolean hasCaptionTrack() {
+ return mHasCaptionTrack;
+ }
+
+ @Override
+ public int compareTo(@NonNull EitItem item) {
+ // The list of caption tracks and the program ids are not compared in here because the
+ // channels in TIF have the concept of the caption and audio tracks while the programs
+ // do not and the programs in TIF only have a program id since they are the rows of
+ // Content Provider.
+ int ret = mEventId - item.getEventId();
+ if (ret != 0) {
+ return ret;
+ }
+ ret = StringUtils.compare(mTitleText, item.getTitleText());
+ if (ret != 0) {
+ return ret;
+ }
+ if (mStartTime > item.getStartTime()) {
+ return 1;
+ } else if (mStartTime < item.getStartTime()) {
+ return -1;
+ }
+ if (mLengthInSecond > item.getLengthInSecond()) {
+ return 1;
+ } else if (mLengthInSecond < item.getLengthInSecond()) {
+ return -1;
+ }
+
+ // Compares content ratings
+ ret = StringUtils.compare(mContentRating, item.getContentRating());
+ if (ret != 0) {
+ return ret;
+ }
+
+ // Compares broadcast genres
+ ret = StringUtils.compare(mBroadcastGenre, item.getBroadcastGenre());
+ if (ret != 0) {
+ return ret;
+ }
+ // Compares canonical genres
+ ret = StringUtils.compare(mCanonicalGenre, item.getCanonicalGenre());
+ if (ret != 0) {
+ return ret;
+ }
+
+ // Compares descriptions
+ return StringUtils.compare(mDescription, item.getDescription());
+ }
+
+ public String getAudioLanguage() {
+ if (mAudioTracks == null) {
+ return "";
+ }
+ ArrayList<String> languages = new ArrayList<>();
+ for (AtscAudioTrack audioTrack : mAudioTracks) {
+ languages.add(audioTrack.language);
+ }
+ return TextUtils.join(",", languages);
+ }
+
+ @Override
+ public String toString() {
+ return String.format(Locale.US,
+ "EitItem programId: %d, eventId: %d, title: %s, startTime: %10d, "
+ + "length: %6d, rating: %s, audio tracks: %d, caption tracks: %d, "
+ + "genres (broadcast: %s, canonical: %s), description: %s",
+ mProgramId, mEventId, mTitleText, mStartTime, mLengthInSecond, mContentRating,
+ mAudioTracks != null ? mAudioTracks.size() : 0,
+ mCaptionTracks != null ? mCaptionTracks.size() : 0,
+ mBroadcastGenre, mCanonicalGenre, mDescription);
+ }
+ }
+
+ public static class EttItem {
+ public final int eventId;
+ public final String text;
+
+ public EttItem(int eventId, String text) {
+ this.eventId = eventId;
+ this.text = text;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/data/TunerChannel.java b/src/com/android/tv/tuner/data/TunerChannel.java
new file mode 100644
index 00000000..22cf2aa6
--- /dev/null
+++ b/src/com/android/tv/tuner/data/TunerChannel.java
@@ -0,0 +1,396 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.data;
+
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.Channel.TunerChannelProto;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.util.Ints;
+import com.android.tv.tuner.util.StringUtils;
+import com.google.protobuf.nano.MessageNano;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A class that represents a single channel accessible through a tuner.
+ */
+public class TunerChannel implements Comparable<TunerChannel>, PsipData.TvTracksInterface {
+ private static final String TAG = "TunerChannel";
+
+ // See ATSC Code Points Registry.
+ private static final String[] ATSC_SERVICE_TYPE_NAMES = new String[] {
+ "ATSC Reserved",
+ "Analog television channels",
+ "ATSC_digital_television",
+ "ATSC_audio",
+ "ATSC_data_only_service",
+ "Software Download",
+ "Unassociated/Small Screen Service",
+ "Parameterized Service",
+ "ATSC NRT Service",
+ "Extended Parameterized Service" };
+ private static final String ATSC_SERVICE_TYPE_NAME_RESERVED =
+ ATSC_SERVICE_TYPE_NAMES[Channel.SERVICE_TYPE_ATSC_RESERVED];
+
+ public static final int INVALID_FREQUENCY = -1;
+
+ // According to RFC4259, The number of available PIDs ranges from 0 to 8191.
+ public static final int INVALID_PID = -1;
+
+ // According to ISO13818-1, Mpeg2 StreamType has a range from 0x00 to 0xff.
+ public static final int INVALID_STREAMTYPE = -1;
+
+ private final TunerChannelProto mProto;
+
+ private TunerChannel(PsipData.VctItem channel, int programNumber,
+ List<PsiData.PmtItem> pmtItems, int type) {
+ mProto = new TunerChannelProto();
+ if (channel == null) {
+ mProto.shortName = "";
+ mProto.tsid = 0;
+ mProto.programNumber = programNumber;
+ mProto.virtualMajor = 0;
+ mProto.virtualMinor = 0;
+ } else {
+ mProto.shortName = channel.getShortName();
+ if (channel.getLongName() != null) {
+ mProto.longName = channel.getLongName();
+ }
+ mProto.tsid = channel.getChannelTsid();
+ mProto.programNumber = channel.getProgramNumber();
+ mProto.virtualMajor = channel.getMajorChannelNumber();
+ mProto.virtualMinor = channel.getMinorChannelNumber();
+ if (channel.getDescription() != null) {
+ mProto.description = channel.getDescription();
+ }
+ mProto.serviceType = channel.getServiceType();
+ }
+ mProto.type = type;
+ mProto.channelId = -1L;
+ mProto.frequency = INVALID_FREQUENCY;
+ mProto.videoPid = INVALID_PID;
+ mProto.videoStreamType = INVALID_STREAMTYPE;
+ List<Integer> audioPids = new ArrayList<>();
+ List<Integer> audioStreamTypes = new ArrayList<>();
+ for (PsiData.PmtItem pmt : pmtItems) {
+ switch (pmt.getStreamType()) {
+ // MPEG ES stream video types
+ case Channel.MPEG1:
+ case Channel.MPEG2:
+ case Channel.H263:
+ case Channel.H264:
+ case Channel.H265:
+ mProto.videoPid = pmt.getEsPid();
+ mProto.videoStreamType = pmt.getStreamType();
+ break;
+
+ // MPEG ES stream audio types
+ case Channel.MPEG1AUDIO:
+ case Channel.MPEG2AUDIO:
+ case Channel.MPEG2AACAUDIO:
+ case Channel.MPEG4LATMAACAUDIO:
+ case Channel.A52AC3AUDIO:
+ case Channel.EAC3AUDIO:
+ audioPids.add(pmt.getEsPid());
+ audioStreamTypes.add(pmt.getStreamType());
+ break;
+
+ // Non MPEG ES stream types
+ case 0x100: // PmtItem.ES_PID_PCR:
+ mProto.pcrPid = pmt.getEsPid();
+ break;
+ }
+ }
+ mProto.audioPids = Ints.toArray(audioPids);
+ mProto.audioStreamTypes = Ints.toArray(audioStreamTypes);
+ mProto.audioTrackIndex = (audioPids.size() > 0) ? 0 : -1;
+ }
+
+ public TunerChannel(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ this(channel, 0, pmtItems, Channel.TYPE_TUNER);
+ }
+
+ public TunerChannel(int programNumber, List<PsiData.PmtItem> pmtItems) {
+ this(null, programNumber, pmtItems, Channel.TYPE_TUNER);
+ }
+
+ private TunerChannel(TunerChannelProto tunerChannelProto) {
+ mProto = tunerChannelProto;
+ }
+
+ public static TunerChannel forFile(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ return new TunerChannel(channel, 0, pmtItems, Channel.TYPE_FILE);
+ }
+
+ public String getName() {
+ return (!mProto.shortName.isEmpty()) ? mProto.shortName : mProto.longName;
+ }
+
+ public String getShortName() {
+ return mProto.shortName;
+ }
+
+ public int getProgramNumber() {
+ return mProto.programNumber;
+ }
+
+ public int getServiceType() {
+ return mProto.serviceType;
+ }
+
+ public String getServiceTypeName() {
+ int serviceType = mProto.serviceType;
+ if (serviceType >= 0 && serviceType < ATSC_SERVICE_TYPE_NAMES.length) {
+ return ATSC_SERVICE_TYPE_NAMES[serviceType];
+ }
+ return ATSC_SERVICE_TYPE_NAME_RESERVED;
+ }
+
+ public int getVirtualMajor() {
+ return mProto.virtualMajor;
+ }
+
+ public int getVirtualMinor() {
+ return mProto.virtualMinor;
+ }
+
+ public int getFrequency() {
+ return mProto.frequency;
+ }
+
+ public String getModulation() {
+ return mProto.modulation;
+ }
+
+ public int getTsid() {
+ return mProto.tsid;
+ }
+
+ public int getVideoPid() {
+ return mProto.videoPid;
+ }
+
+ public void setVideoPid(int videoPid) {
+ mProto.videoPid = videoPid;
+ }
+
+ public int getVideoStreamType() {
+ return mProto.videoStreamType;
+ }
+
+ public int getAudioPid() {
+ if (mProto.audioTrackIndex == -1) {
+ return INVALID_PID;
+ }
+ return mProto.audioPids[mProto.audioTrackIndex];
+ }
+
+ public int getAudioStreamType() {
+ if (mProto.audioTrackIndex == -1) {
+ return INVALID_STREAMTYPE;
+ }
+ return mProto.audioStreamTypes[mProto.audioTrackIndex];
+ }
+
+ public List<Integer> getAudioPids() {
+ return Ints.asList(mProto.audioPids);
+ }
+
+ public void setAudioPids(List<Integer> audioPids) {
+ mProto.audioPids = Ints.toArray(audioPids);
+ }
+
+ public List<Integer> getAudioStreamTypes() {
+ return Ints.asList(mProto.audioStreamTypes);
+ }
+
+ public void setAudioStreamTypes(List<Integer> audioStreamTypes) {
+ mProto.audioStreamTypes = Ints.toArray(audioStreamTypes);
+ }
+
+ public int getPcrPid() {
+ return mProto.pcrPid;
+ }
+
+ public int getType() {
+ return mProto.type;
+ }
+
+ public void setFilepath(String filepath) {
+ mProto.filepath = filepath;
+ }
+
+ public String getFilepath() {
+ return mProto.filepath;
+ }
+
+ public void setVirtualMajor(int virtualMajor) {
+ mProto.virtualMajor = virtualMajor;
+ }
+
+ public void setVirtualMinor(int virtualMinor) {
+ mProto.virtualMinor = virtualMinor;
+ }
+
+ public void setShortName(String shortName) {
+ mProto.shortName = shortName;
+ }
+
+ public void setFrequency(int frequency) {
+ mProto.frequency = frequency;
+ }
+
+ public void setModulation(String modulation) {
+ mProto.modulation = modulation;
+ }
+
+ public boolean hasVideo() {
+ return mProto.videoPid != INVALID_PID;
+ }
+
+ public boolean hasAudio() {
+ return getAudioPid() != INVALID_PID;
+ }
+
+ public long getChannelId() {
+ return mProto.channelId;
+ }
+
+ public void setChannelId(long channelId) {
+ mProto.channelId = channelId;
+ }
+
+ public String getDisplayNumber() {
+ if (mProto.virtualMajor != 0 && mProto.virtualMinor != 0) {
+ return String.format("%d-%d", mProto.virtualMajor, mProto.virtualMinor);
+ } else if (mProto.virtualMajor != 0) {
+ return Integer.toString(mProto.virtualMajor);
+ } else {
+ return Integer.toString(mProto.programNumber);
+ }
+ }
+
+ public String getDescription() {
+ return mProto.description;
+ }
+
+ @Override
+ public void setHasCaptionTrack() {
+ mProto.hasCaptionTrack = true;
+ }
+
+ @Override
+ public boolean hasCaptionTrack() {
+ return mProto.hasCaptionTrack;
+ }
+
+ @Override
+ public List<AtscAudioTrack> getAudioTracks() {
+ return Collections.unmodifiableList(Arrays.asList(mProto.audioTracks));
+ }
+
+ public void setAudioTracks(List<AtscAudioTrack> audioTracks) {
+ mProto.audioTracks = audioTracks.toArray(new AtscAudioTrack[audioTracks.size()]);
+ }
+
+ @Override
+ public List<AtscCaptionTrack> getCaptionTracks() {
+ return Collections.unmodifiableList(Arrays.asList(mProto.captionTracks));
+ }
+
+ public void setCaptionTracks(List<AtscCaptionTrack> captionTracks) {
+ mProto.captionTracks = captionTracks.toArray(new AtscCaptionTrack[captionTracks.size()]);
+ }
+
+ public void selectAudioTrack(int index) {
+ if (0 <= index && index < mProto.audioPids.length) {
+ mProto.audioTrackIndex = index;
+ } else {
+ mProto.audioTrackIndex = -1;
+ }
+ }
+
+ @Override
+ public String toString() {
+ switch (mProto.type) {
+ case Channel.TYPE_FILE:
+ return String.format("{%d-%d %s} Filepath: %s, ProgramNumber %d",
+ mProto.virtualMajor, mProto.virtualMinor, mProto.shortName,
+ mProto.filepath, mProto.programNumber);
+ //case Channel.TYPE_TUNER:
+ default:
+ return String.format("{%d-%d %s} Frequency: %d, ProgramNumber %d",
+ mProto.virtualMajor, mProto.virtualMinor, mProto.shortName,
+ mProto.frequency, mProto.programNumber);
+ }
+ }
+
+ @Override
+ public int compareTo(@NonNull TunerChannel channel) {
+ // In the same frequency, the program number acts as the sub-channel number.
+ int ret = getFrequency() - channel.getFrequency();
+ if (ret != 0) {
+ return ret;
+ }
+ ret = getProgramNumber() - channel.getProgramNumber();
+ if (ret != 0) {
+ return ret;
+ }
+
+ // For FileTsStreamer, file paths should be compared.
+ return StringUtils.compare(getFilepath(), channel.getFilepath());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof TunerChannel)) {
+ return false;
+ }
+ return compareTo((TunerChannel) o) == 0;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getFrequency(), getProgramNumber(), getFilepath());
+ }
+
+ // Serialization
+ public byte[] toByteArray() {
+ return MessageNano.toByteArray(mProto);
+ }
+
+ public static TunerChannel parseFrom(byte[] data) {
+ if (data == null) {
+ return null;
+ }
+ try {
+ return new TunerChannel(TunerChannelProto.parseFrom(data));
+ } catch (IOException e) {
+ Log.e(TAG, "Could not parse from byte array", e);
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
new file mode 100644
index 00000000..5e839223
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/Cea708TextTrackRenderer.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import android.util.Log;
+
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.util.Assertions;
+import com.android.tv.tuner.cc.Cea708Parser;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+
+import java.io.IOException;
+
+/**
+ * A {@link TrackRenderer} for CEA-708 textual subtitles.
+ */
+public class Cea708TextTrackRenderer extends TrackRenderer implements
+ Cea708Parser.OnCea708ParserListener {
+ private static final String TAG = "Cea708TextTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ public static final int MSG_SERVICE_NUMBER = 1;
+
+ // According to CEA-708B, the maximum value of closed caption bandwidth is 9600bps.
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 9600 / 8;
+
+ private final SampleSource.SampleSourceReader mSource;
+ private final SampleHolder mSampleHolder;
+ private final MediaFormatHolder mFormatHolder;
+ private int mServiceNumber;
+ private boolean mInputStreamEnded;
+ private long mCurrentPositionUs;
+ private long mPresentationTimeUs;
+ private int mTrackIndex;
+ private Cea708Parser mCea708Parser;
+ private CcListener mCcListener;
+
+ public interface CcListener {
+ void emitEvent(CaptionEvent captionEvent);
+ void discoverServiceNumber(int serviceNumber);
+ }
+
+ public Cea708TextTrackRenderer(SampleSource source) {
+ mSource = source.register();
+ mTrackIndex = -1;
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return null;
+ }
+
+ private boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MpegTsSampleExtractor.MIMETYPE_TEXT_CEA_708);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ int trackCount = mSource.getTrackCount();
+ for (int i = 0; i < trackCount; ++i) {
+ MediaFormat trackFormat = mSource.getFormat(i);
+ if (handlesMimeType(trackFormat.mimeType)) {
+ mTrackIndex = i;
+ clearDecodeState();
+ return true;
+ }
+ }
+ // TODO: Check this case. (Source do not have the proper mime type.)
+ return true;
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ mSource.enable(mTrackIndex, positionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onDisabled() {
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ mSource.release();
+ mCea708Parser = null;
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mInputStreamEnded;
+ }
+
+ @Override
+ protected boolean isReady() {
+ // Since this track will be fed by {@link VideoTrackRenderer},
+ // it is not required to control transition between ready state and buffering state.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTrackIndex < 0 ? 0 : 1;
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(mTrackIndex != -1 && track == 0);
+ return mSource.getFormat(mTrackIndex);
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ try {
+ mPresentationTimeUs = positionUs;
+ if (!mInputStreamEnded) {
+ processOutput();
+ feedInputBuffer();
+ }
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private boolean processOutput() {
+ return !mInputStreamEnded && mCea708Parser != null &&
+ mCea708Parser.processClosedCaptions(mPresentationTimeUs);
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ if (DEBUG) {
+ Log.d(TAG, "Read discontinuity happened");
+ }
+
+ // TODO: handle input discontinuity for trickplay.
+ clearDecodeState();
+ mPresentationTimeUs = discontinuity;
+ return false;
+ }
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ int result = mSource.readData(mTrackIndex, mPresentationTimeUs,
+ mFormatHolder, mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ: {
+ return false;
+ }
+ case SampleSource.FORMAT_READ: {
+ if (DEBUG) {
+ Log.i(TAG, "Format was read again");
+ }
+ return true;
+ }
+ case SampleSource.END_OF_STREAM: {
+ if (DEBUG) {
+ Log.i(TAG, "End of stream from SampleSource");
+ }
+ mInputStreamEnded = true;
+ return false;
+ }
+ case SampleSource.SAMPLE_READ: {
+ mSampleHolder.data.flip();
+ if (mCea708Parser != null) {
+ mCea708Parser.parseClosedCaption(mSampleHolder.data, mSampleHolder.timeUs);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void clearDecodeState() {
+ mCea708Parser = new Cea708Parser();
+ mCea708Parser.setListener(this);
+ mCea708Parser.setListenServiceNumber(mServiceNumber);
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ return mSource.getBufferedPositionUs();
+ }
+
+ @Override
+ protected void seekTo(long currentPositionUs) throws ExoPlaybackException {
+ mSource.seekToUs(currentPositionUs);
+ mInputStreamEnded = false;
+ mPresentationTimeUs = currentPositionUs;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+
+ @Override
+ protected void onStarted() {
+ // do nothing.
+ }
+
+ @Override
+ protected void onStopped() {
+ // do nothing.
+ }
+
+ private void setServiceNumber(int serviceNumber) {
+ mServiceNumber = serviceNumber;
+ if (mCea708Parser != null) {
+ mCea708Parser.setListenServiceNumber(serviceNumber);
+ }
+ }
+
+ @Override
+ public void emitEvent(CaptionEvent event) {
+ if (mCcListener != null) {
+ mCcListener.emitEvent(event);
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mCcListener != null) {
+ mCcListener.discoverServiceNumber(serviceNumber);
+ }
+ }
+
+ public void setCcListener(CcListener ccListener) {
+ mCcListener = ccListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ if (messageType == MSG_SERVICE_NUMBER) {
+ setServiceNumber((int) message);
+ } else {
+ super.handleMessage(messageType, message);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
new file mode 100644
index 00000000..c105e222
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ExoPlayerSampleExtractor.java
@@ -0,0 +1,398 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import android.net.Uri;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.SystemClock;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.extractor.ExtractorSampleSource;
+import com.google.android.exoplayer.extractor.ExtractorSampleSource.EventListener;
+import com.google.android.exoplayer.upstream.Allocator;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer.upstream.DefaultAllocator;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.exoplayer.buffer.SimpleSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * A class that extracts samples from a live broadcast stream while storing the sample on the disk.
+ * For demux, this class relies on {@link com.google.android.exoplayer.extractor.ts.TsExtractor}.
+ */
+public class ExoPlayerSampleExtractor implements SampleExtractor {
+ private static final String TAG = "ExoPlayerSampleExtracto";
+
+ // Buffer segment size for memory allocator. Copied from demo implementation of ExoPlayer.
+ private static final int BUFFER_SEGMENT_SIZE_IN_BYTES = 64 * 1024;
+ // Buffer segment count for sample source. Copied from demo implementation of ExoPlayer.
+ private static final int BUFFER_SEGMENT_COUNT = 256;
+
+ private final HandlerThread mSourceReaderThread;
+ private final long mId;
+
+ private final Handler.Callback mSourceReaderWorker;
+
+ private BufferManager.SampleBuffer mSampleBuffer;
+ private Handler mSourceReaderHandler;
+ private volatile boolean mPrepared;
+ private AtomicBoolean mOnCompletionCalled = new AtomicBoolean();
+ private IOException mExceptionOnPrepare;
+ private List<MediaFormat> mTrackFormats;
+ private HashMap<Integer, Long> mLastExtractedPositionUsMap = new HashMap<>();
+ private OnCompletionListener mOnCompletionListener;
+ private Handler mOnCompletionListenerHandler;
+ private IOException mError;
+
+ public ExoPlayerSampleExtractor(Uri uri, DataSource source, BufferManager bufferManager,
+ PlaybackBufferListener bufferListener, boolean isRecording) {
+ // It'll be used as a timeshift file chunk name's prefix.
+ mId = System.currentTimeMillis();
+ Allocator allocator = new DefaultAllocator(BUFFER_SEGMENT_SIZE_IN_BYTES);
+
+ EventListener eventListener = new EventListener() {
+
+ @Override
+ public void onLoadError(int sourceId, IOException e) {
+ mError = e;
+ }
+ };
+
+ mSourceReaderThread = new HandlerThread("SourceReaderThread");
+ mSourceReaderWorker = new SourceReaderWorker(new ExtractorSampleSource(uri, source,
+ allocator, BUFFER_SEGMENT_COUNT * BUFFER_SEGMENT_SIZE_IN_BYTES,
+ // Do not create a handler if we not on a looper. e.g. test.
+ Looper.myLooper() != null ? new Handler() : null,
+ eventListener, 0));
+ if (isRecording) {
+ mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, false,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDING);
+ } else {
+ if (bufferManager == null || bufferManager.isDisabled()) {
+ mSampleBuffer = new SimpleSampleBuffer(bufferListener);
+ } else {
+ mSampleBuffer = new RecordingSampleBuffer(bufferManager, bufferListener, true,
+ RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK);
+ }
+ }
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) {
+ mOnCompletionListener = listener;
+ mOnCompletionListenerHandler = handler;
+ }
+
+ private class SourceReaderWorker implements Handler.Callback {
+ public static final int MSG_PREPARE = 1;
+ public static final int MSG_FETCH_SAMPLES = 2;
+ public static final int MSG_RELEASE = 3;
+ private static final int RETRY_INTERVAL_MS = 50;
+
+ private final SampleSource mSampleSource;
+ private SampleSource.SampleSourceReader mSampleSourceReader;
+ private boolean[] mTrackMetEos;
+ private boolean mMetEos = false;
+ private long mCurrentPosition;
+
+ public SourceReaderWorker(SampleSource sampleSource) {
+ mSampleSource = sampleSource;
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ switch (message.what) {
+ case MSG_PREPARE:
+ mPrepared = prepare();
+ if (!mPrepared && mExceptionOnPrepare == null) {
+ mSourceReaderHandler
+ .sendEmptyMessageDelayed(MSG_PREPARE, RETRY_INTERVAL_MS);
+ } else{
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ }
+ return true;
+ case MSG_FETCH_SAMPLES:
+ boolean didSomething = false;
+ SampleHolder sample = new SampleHolder(
+ SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ ConditionVariable conditionVariable = new ConditionVariable();
+ int trackCount = mSampleSourceReader.getTrackCount();
+ for (int i = 0; i < trackCount; ++i) {
+ if (!mTrackMetEos[i] && SampleSource.NOTHING_READ
+ != fetchSample(i, sample, conditionVariable)) {
+ if (mMetEos) {
+ // If mMetEos was on during fetchSample() due to an error,
+ // fetching from other tracks is not necessary.
+ break;
+ }
+ didSomething = true;
+ }
+ }
+ if (!mMetEos) {
+ if (didSomething) {
+ mSourceReaderHandler.sendEmptyMessage(MSG_FETCH_SAMPLES);
+ } else {
+ mSourceReaderHandler.sendEmptyMessageDelayed(MSG_FETCH_SAMPLES,
+ RETRY_INTERVAL_MS);
+ }
+ } else {
+ notifyCompletionIfNeeded(false);
+ }
+ return true;
+ case MSG_RELEASE:
+ if (mSampleSourceReader != null) {
+ if (mPrepared) {
+ // ExtractorSampleSource expects all the tracks should be disabled
+ // before releasing.
+ int count = mSampleSourceReader.getTrackCount();
+ for (int i = 0; i < count; ++i) {
+ mSampleSourceReader.disable(i);
+ }
+ }
+ mSampleSourceReader.release();
+ mSampleSourceReader = null;
+ }
+ cleanUp();
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ return true;
+ }
+ return false;
+ }
+
+ private boolean prepare() {
+ if (mSampleSourceReader == null) {
+ mSampleSourceReader = mSampleSource.register();
+ }
+ if(!mSampleSourceReader.prepare(0)) {
+ return false;
+ }
+ if (mTrackFormats == null) {
+ int trackCount = mSampleSourceReader.getTrackCount();
+ mTrackMetEos = new boolean[trackCount];
+ List<MediaFormat> trackFormats = new ArrayList<>();
+ for (int i = 0; i < trackCount; i++) {
+ trackFormats.add(mSampleSourceReader.getFormat(i));
+ mSampleSourceReader.enable(i, 0);
+
+ }
+ mTrackFormats = trackFormats;
+ List<String> ids = new ArrayList<>();
+ for (int i = 0; i < mTrackFormats.size(); i++) {
+ ids.add(String.format(Locale.ENGLISH, "%s_%x", Long.toHexString(mId), i));
+ }
+ try {
+ mSampleBuffer.init(ids, mTrackFormats);
+ } catch (IOException e) {
+ // In this case, we will not schedule any further operation.
+ // mExceptionOnPrepare will be notified to ExoPlayer, and ExoPlayer will
+ // call release() eventually.
+ mExceptionOnPrepare = e;
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private int fetchSample(int track, SampleHolder sample,
+ ConditionVariable conditionVariable) {
+ mSampleSourceReader.continueBuffering(track, mCurrentPosition);
+
+ MediaFormatHolder formatHolder = new MediaFormatHolder();
+ sample.clearData();
+ int ret = mSampleSourceReader.readData(track, mCurrentPosition, formatHolder, sample);
+ if (ret == SampleSource.SAMPLE_READ) {
+ if (mCurrentPosition < sample.timeUs) {
+ mCurrentPosition = sample.timeUs;
+ }
+ try {
+ Long lastExtractedPositionUs = mLastExtractedPositionUsMap.get(track);
+ if (lastExtractedPositionUs == null) {
+ mLastExtractedPositionUsMap.put(track, sample.timeUs);
+ } else {
+ mLastExtractedPositionUsMap.put(track,
+ Math.max(lastExtractedPositionUs, sample.timeUs));
+ }
+ queueSample(track, sample, conditionVariable);
+ } catch (IOException e) {
+ mLastExtractedPositionUsMap.clear();
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ } else if (ret == SampleSource.END_OF_STREAM) {
+ mTrackMetEos[track] = true;
+ for (int i = 0; i < mTrackMetEos.length; ++i) {
+ if (!mTrackMetEos[i]) {
+ break;
+ }
+ if (i == mTrackMetEos.length -1) {
+ mMetEos = true;
+ mSampleBuffer.setEos();
+ }
+ }
+ }
+ // TODO: Handle SampleSource.FORMAT_READ for dynamic resolution change. b/28169263
+ return ret;
+ }
+ }
+
+ private void queueSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ long writeStartTimeNs = SystemClock.elapsedRealtimeNanos();
+ mSampleBuffer.writeSample(index, sample, conditionVariable);
+
+ // Checks whether the storage has enough bandwidth for recording samples.
+ if (mSampleBuffer.isWriteSpeedSlow(sample.size,
+ SystemClock.elapsedRealtimeNanos() - writeStartTimeNs)) {
+ mSampleBuffer.handleWriteSpeedSlow();
+ }
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mError != null) {
+ IOException e = mError;
+ mError = null;
+ throw e;
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if (!mSourceReaderThread.isAlive()) {
+ mSourceReaderThread.start();
+ mSourceReaderHandler = new Handler(mSourceReaderThread.getLooper(),
+ mSourceReaderWorker);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_PREPARE);
+ }
+ if (mExceptionOnPrepare != null) {
+ throw mExceptionOnPrepare;
+ }
+ return mPrepared;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void release() {
+ if (mSourceReaderThread.isAlive()) {
+ mSourceReaderHandler.removeCallbacksAndMessages(null);
+ mSourceReaderHandler.sendEmptyMessage(SourceReaderWorker.MSG_RELEASE);
+ mSourceReaderThread.quitSafely();
+ // Return early in this case so that session worker can start working on the next
+ // request as early as it can. The clean up will be done in the reader thread while
+ // handling MSG_RELEASE.
+ } else {
+ cleanUp();
+ }
+ }
+
+ private void cleanUp() {
+ boolean result = true;
+ try {
+ if (mSampleBuffer != null) {
+ mSampleBuffer.release();
+ mSampleBuffer = null;
+ }
+ } catch (IOException e) {
+ result = false;
+ }
+ notifyCompletionIfNeeded(result);
+ setOnCompletionListener(null, null);
+ }
+
+ private void notifyCompletionIfNeeded(final boolean result) {
+ if (!mOnCompletionCalled.getAndSet(true)) {
+ final OnCompletionListener listener = mOnCompletionListener;
+ final long lastExtractedPositionUs = getLastExtractedPositionUs();
+ if (mOnCompletionListenerHandler != null && mOnCompletionListener != null) {
+ mOnCompletionListenerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ listener.onCompletion(result, lastExtractedPositionUs);
+ }
+ });
+ }
+ }
+ }
+
+ private long getLastExtractedPositionUs() {
+ long lastExtractedPositionUs = Long.MAX_VALUE;
+ for (long value : mLastExtractedPositionUsMap.values()) {
+ lastExtractedPositionUs = Math.min(lastExtractedPositionUs, value);
+ }
+ if (lastExtractedPositionUs == Long.MAX_VALUE) {
+ lastExtractedPositionUs = C.UNKNOWN_TIME_US;
+ }
+ return lastExtractedPositionUs;
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
new file mode 100644
index 00000000..ec7b4b16
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/FileSampleExtractor.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaFormatUtil;
+import com.google.android.exoplayer.SampleHolder;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+
+import android.os.Handler;
+import android.util.Pair;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A class that plays a recorded stream without using {@link android.media.MediaExtractor},
+ * since all samples are extracted and stored to the permanent storage already.
+ */
+public class FileSampleExtractor implements SampleExtractor{
+ private static final String TAG = "FileSampleExtractor";
+ private static final boolean DEBUG = false;
+
+ private int mTrackCount;
+ private boolean mReleased;
+
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private BufferManager.SampleBuffer mSampleBuffer;
+
+ public FileSampleExtractor(
+ BufferManager bufferManager, PlaybackBufferListener bufferListener) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ mTrackCount = -1;
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ // Do nothing.
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ ArrayList<Pair<String, android.media.MediaFormat>> trackInfos =
+ mBufferManager.readTrackInfoFiles();
+ if (trackInfos == null || trackInfos.isEmpty()) {
+ throw new IOException("Cannot find meta files for the recording.");
+ }
+ mTrackCount = trackInfos.size();
+ List<String> ids = new ArrayList<>();
+ mTrackFormats.clear();
+ for (int i = 0; i < mTrackCount; ++i) {
+ Pair<String, android.media.MediaFormat> pair = trackInfos.get(i);
+ ids.add(pair.first);
+ mTrackFormats.add(MediaFormatUtil.createMediaFormat(pair.second));
+ }
+ mSampleBuffer = new RecordingSampleBuffer(mBufferManager, mBufferListener, true,
+ RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK);
+ mSampleBuffer.init(ids, mTrackFormats);
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ outMediaFormatHolder.format = mTrackFormats.get(track);
+ outMediaFormatHolder.drmInitData = null;
+ }
+
+ @Override
+ public void release() {
+ if (!mReleased) {
+ if (mSampleBuffer != null) {
+ try {
+ mSampleBuffer.release();
+ } catch (IOException e) {
+ // Do nothing. Playback ends now.
+ }
+ }
+ }
+ mReleased = true;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ mSampleBuffer.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ mSampleBuffer.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleBuffer.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleBuffer.seekTo(positionUs);
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ return mSampleBuffer.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleBuffer.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) { }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
new file mode 100644
index 00000000..381b22e9
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/MpegTsPlayer.java
@@ -0,0 +1,653 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import android.content.Context;
+import android.media.AudioFormat;
+import android.media.MediaCodec.CryptoException;
+import android.media.PlaybackParams;
+import android.os.Handler;
+import android.support.annotation.IntDef;
+import android.view.Surface;
+
+import com.google.android.exoplayer.DummyTrackRenderer;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.ExoPlayer;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecTrackRenderer.DecoderInitializationException;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.ac3.Ac3PassthroughTrackRenderer;
+import com.android.tv.tuner.exoplayer.ac3.Ac3TrackRenderer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.tvinput.EventDetector;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * MPEG-2 TS stream player implementation using ExoPlayer.
+ */
+public class MpegTsPlayer implements ExoPlayer.Listener, MediaCodecVideoTrackRenderer.EventListener,
+ Ac3PassthroughTrackRenderer.EventListener, Ac3TrackRenderer.Ac3EventListener {
+ private int mCaptionServiceNumber = Cea708Data.EMPTY_SERVICE_NUMBER;
+
+ /**
+ * Interface definition for building specific track renderers.
+ */
+ public interface RendererBuilder {
+ void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource,
+ RendererBuilderCallback callback);
+ }
+
+ /**
+ * Interface definition for {@link RendererBuilder#buildRenderers} to notify the result.
+ */
+ public interface RendererBuilderCallback {
+ void onRenderers(String[][] trackNames, TrackRenderer[] renderers);
+ void onRenderersError(Exception e);
+ }
+
+ /**
+ * Interface definition for a callback to be notified of changes in player state.
+ */
+ public interface Listener {
+ void onStateChanged(boolean playWhenReady, int playbackState);
+ void onError(Exception e);
+ void onVideoSizeChanged(int width, int height,
+ float pixelWidthHeightRatio);
+ void onDrawnToSurface(MpegTsPlayer player, Surface surface);
+ void onAudioUnplayable();
+ void onSmoothTrickplayForceStopped();
+ }
+
+ /**
+ * Interface definition for a callback to be notified of changes on video display.
+ */
+ public interface VideoEventListener {
+ /**
+ * Notifies the caption event.
+ */
+ void onEmitCaptionEvent(CaptionEvent event);
+
+ /**
+ * Notifies the discovered caption service number.
+ */
+ void onDiscoverCaptionServiceNumber(int serviceNumber);
+ }
+
+ public static final int RENDERER_COUNT = 3;
+ public static final int MIN_BUFFER_MS = 0;
+ public static final int MIN_REBUFFER_MS = 500;
+
+ @IntDef({TRACK_TYPE_VIDEO, TRACK_TYPE_AUDIO, TRACK_TYPE_TEXT})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface TrackType {}
+ public static final int TRACK_TYPE_VIDEO = 0;
+ public static final int TRACK_TYPE_AUDIO = 1;
+ public static final int TRACK_TYPE_TEXT = 2;
+
+ @IntDef({RENDERER_BUILDING_STATE_IDLE, RENDERER_BUILDING_STATE_BUILDING,
+ RENDERER_BUILDING_STATE_BUILT})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface RendererBuildingState {}
+ private static final int RENDERER_BUILDING_STATE_IDLE = 1;
+ private static final int RENDERER_BUILDING_STATE_BUILDING = 2;
+ private static final int RENDERER_BUILDING_STATE_BUILT = 3;
+
+ private static final float MAX_SMOOTH_TRICKPLAY_SPEED = 9.0f;
+ private static final float MIN_SMOOTH_TRICKPLAY_SPEED = 0.1f;
+
+ private final RendererBuilder mRendererBuilder;
+ private final ExoPlayer mPlayer;
+ private final Handler mMainHandler;
+ private final AudioCapabilities mAudioCapabilities;
+ private final TsDataSourceManager mSourceManager;
+
+ private Listener mListener;
+ @RendererBuildingState private int mRendererBuildingState;
+
+ private Surface mSurface;
+ private TsDataSource mDataSource;
+ private InternalRendererBuilderCallback mBuilderCallback;
+ private TrackRenderer mVideoRenderer;
+ private TrackRenderer mAudioRenderer;
+ private Cea708TextTrackRenderer mTextRenderer;
+ private final Cea708TextTrackRenderer.CcListener mCcListener;
+ private VideoEventListener mVideoEventListener;
+ private boolean mTrickplayRunning;
+ private float mVolume;
+
+ /**
+ * Creates MPEG2-TS stream player.
+ *
+ * @param rendererBuilder the builder of track renderers
+ * @param handler the handler for the playback events in track renderers
+ * @param sourceManager the manager for {@link DataSource}
+ * @param capabilities the {@link AudioCapabilities} of the current device
+ * @param listener the listener for playback state changes
+ */
+ public MpegTsPlayer(RendererBuilder rendererBuilder, Handler handler,
+ TsDataSourceManager sourceManager, AudioCapabilities capabilities,
+ Listener listener) {
+ mRendererBuilder = rendererBuilder;
+ mPlayer = ExoPlayer.Factory.newInstance(RENDERER_COUNT, MIN_BUFFER_MS, MIN_REBUFFER_MS);
+ mPlayer.addListener(this);
+ mMainHandler = handler;
+ mAudioCapabilities = capabilities;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mCcListener = new MpegTsCcListener();
+ mSourceManager = sourceManager;
+ mListener = listener;
+ }
+
+ /**
+ * Sets the video event listener.
+ *
+ * @param videoEventListener the listener for video events
+ */
+ public void setVideoEventListener(VideoEventListener videoEventListener) {
+ mVideoEventListener = videoEventListener;
+ }
+
+ /**
+ * Sets the closed caption service number.
+ *
+ * @param captionServiceNumber the service number of CEA-708 closed caption
+ */
+ public void setCaptionServiceNumber(int captionServiceNumber) {
+ mCaptionServiceNumber = captionServiceNumber;
+ if (mTextRenderer != null) {
+ mPlayer.sendMessage(mTextRenderer,
+ Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber);
+ }
+ }
+
+ /**
+ * Sets the surface for the player.
+ *
+ * @param surface the {@link Surface} to render video
+ */
+ public void setSurface(Surface surface) {
+ mSurface = surface;
+ pushSurface(false);
+ }
+
+ /**
+ * Returns the current surface of the player.
+ */
+ public Surface getSurface() {
+ return mSurface;
+ }
+
+ /**
+ * Clears the surface and waits until the surface is being cleaned.
+ */
+ public void blockingClearSurface() {
+ mSurface = null;
+ pushSurface(true);
+ }
+
+ /**
+ * Creates renderers and {@link DataSource} and initializes player.
+ * @param context a {@link Context} instance
+ * @param channel to play
+ * @param eventListener for program information which will be scanned from MPEG2-TS stream
+ * @return true when everything is created and initialized well, false otherwise
+ */
+ public boolean prepare(Context context, TunerChannel channel,
+ EventDetector.EventListener eventListener) {
+ TsDataSource source = null;
+ if (channel != null) {
+ source = mSourceManager.createDataSource(context, channel, eventListener);
+ if (source == null) {
+ return false;
+ }
+ }
+ mDataSource = source;
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILT) {
+ mPlayer.stop();
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILDING;
+ mBuilderCallback = new InternalRendererBuilderCallback();
+ mRendererBuilder.buildRenderers(this, source, mBuilderCallback);
+ return true;
+ }
+
+ /**
+ * Returns {@link TsDataSource} which provides MPEG2-TS stream.
+ */
+ public TsDataSource getDataSource() {
+ return mDataSource;
+ }
+
+ private void onRenderers(TrackRenderer[] renderers) {
+ mBuilderCallback = null;
+ for (int i = 0; i < RENDERER_COUNT; i++) {
+ if (renderers[i] == null) {
+ // Convert a null renderer to a dummy renderer.
+ renderers[i] = new DummyTrackRenderer();
+ }
+ }
+ mVideoRenderer = renderers[TRACK_TYPE_VIDEO];
+ mAudioRenderer = renderers[TRACK_TYPE_AUDIO];
+ mTextRenderer = (Cea708TextTrackRenderer) renderers[TRACK_TYPE_TEXT];
+ mTextRenderer.setCcListener(mCcListener);
+ mPlayer.sendMessage(
+ mTextRenderer, Cea708TextTrackRenderer.MSG_SERVICE_NUMBER, mCaptionServiceNumber);
+ mRendererBuildingState = RENDERER_BUILDING_STATE_BUILT;
+ pushSurface(false);
+ mPlayer.prepare(renderers);
+ pushTrackSelection(TRACK_TYPE_VIDEO, true);
+ pushTrackSelection(TRACK_TYPE_AUDIO, true);
+ pushTrackSelection(TRACK_TYPE_TEXT, true);
+ }
+
+ private void onRenderersError(Exception e) {
+ mBuilderCallback = null;
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(e);
+ }
+ }
+
+ /**
+ * Sets the player state to pause or play.
+ *
+ * @param playWhenReady sets the player state to being ready to play when {@code true},
+ * sets the player state to being paused when {@code false}
+ *
+ */
+ public void setPlayWhenReady(boolean playWhenReady) {
+ mPlayer.setPlayWhenReady(playWhenReady);
+ stopSmoothTrickplay(false);
+ }
+
+ /**
+ * Returns true, if trickplay is supported.
+ */
+ public boolean supportSmoothTrickPlay(float playbackSpeed) {
+ return playbackSpeed > MIN_SMOOTH_TRICKPLAY_SPEED
+ && playbackSpeed < MAX_SMOOTH_TRICKPLAY_SPEED;
+ }
+
+ /**
+ * Starts trickplay. It'll be reset, if {@link #seekTo} or {@link #setPlayWhenReady} is called.
+ */
+ public void startSmoothTrickplay(PlaybackParams playbackParams) {
+ SoftPreconditions.checkState(supportSmoothTrickPlay(playbackParams.getSpeed()));
+ mPlayer.setPlayWhenReady(true);
+ mTrickplayRunning = true;
+ if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) {
+ mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ playbackParams.getSpeed());
+ } else {
+ mPlayer.sendMessage(mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ playbackParams);
+ }
+ }
+
+ private void stopSmoothTrickplay(boolean calledBySeek) {
+ if (mTrickplayRunning) {
+ mTrickplayRunning = false;
+ if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) {
+ mPlayer.sendMessage(mAudioRenderer,
+ Ac3PassthroughTrackRenderer.MSG_SET_PLAYBACK_SPEED,
+ 1.0f);
+ } else {
+ mPlayer.sendMessage(mAudioRenderer,
+ MediaCodecAudioTrackRenderer.MSG_SET_PLAYBACK_PARAMS,
+ new PlaybackParams().setSpeed(1.0f));
+ }
+ if (!calledBySeek) {
+ mPlayer.seekTo(mPlayer.getCurrentPosition());
+ }
+ }
+ }
+
+ /**
+ * Seeks to the specified position of the current playback.
+ *
+ * @param positionMs the specified position in milli seconds.
+ */
+ public void seekTo(long positionMs) {
+ mPlayer.seekTo(positionMs);
+ stopSmoothTrickplay(true);
+ }
+
+ /**
+ * Releases the player.
+ */
+ public void release() {
+ if (mDataSource != null) {
+ mSourceManager.releaseDataSource(mDataSource);
+ mDataSource = null;
+ }
+ if (mBuilderCallback != null) {
+ mBuilderCallback.cancel();
+ mBuilderCallback = null;
+ }
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ mSurface = null;
+ mListener = null;
+ mPlayer.release();
+ }
+
+ /**
+ * Returns the current status of the player.
+ */
+ public int getPlaybackState() {
+ if (mRendererBuildingState == RENDERER_BUILDING_STATE_BUILDING) {
+ return ExoPlayer.STATE_PREPARING;
+ }
+ return mPlayer.getPlaybackState();
+ }
+
+ /**
+ * Returns {@code true} when the player is prepared to play, {@code false} otherwise.
+ */
+ public boolean isPrepared() {
+ int state = getPlaybackState();
+ return state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /**
+ * Returns {@code true} when the player is being ready to play, {@code false} otherwise.
+ */
+ public boolean isPlaying() {
+ int state = getPlaybackState();
+ return (state == ExoPlayer.STATE_READY || state == ExoPlayer.STATE_BUFFERING)
+ && mPlayer.getPlayWhenReady();
+ }
+
+ /**
+ * Returns {@code true} when the player is buffering, {@code false} otherwise.
+ */
+ public boolean isBuffering() {
+ return getPlaybackState() == ExoPlayer.STATE_BUFFERING;
+ }
+
+ /**
+ * Returns the current position of the playback in milli seconds.
+ */
+ public long getCurrentPosition() {
+ return mPlayer.getCurrentPosition();
+ }
+
+ /**
+ * Returns the total duration of the playback.
+ */
+ public long getDuration() {
+ return mPlayer.getDuration();
+ }
+
+ /**
+ * Returns {@code true} when the player is being ready to play,
+ * {@code false} when the player is paused.
+ */
+ public boolean getPlayWhenReady() {
+ return mPlayer.getPlayWhenReady();
+ }
+
+ /**
+ * Sets the volume of the audio.
+ *
+ * @param volume see also {@link AudioTrack#setVolume(float)}
+ */
+ public void setVolume(float volume) {
+ mVolume = volume;
+ if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) {
+ mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_VOLUME, volume);
+ } else {
+ mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME,
+ volume);
+ }
+ }
+
+ /**
+ * Enables or disables audio.
+ *
+ * @param enable enables the audio when {@code true}, disables otherwise.
+ */
+ public void setAudioTrack(boolean enable) {
+ if (mAudioRenderer instanceof Ac3PassthroughTrackRenderer) {
+ mPlayer.sendMessage(mAudioRenderer, Ac3PassthroughTrackRenderer.MSG_SET_AUDIO_TRACK,
+ enable ? 1 : 0);
+ } else {
+ mPlayer.sendMessage(mAudioRenderer, MediaCodecAudioTrackRenderer.MSG_SET_VOLUME,
+ enable ? mVolume : 0.0f);
+ }
+ }
+
+ /**
+ * Returns {@code true} when AC3 audio can be played, {@code false} otherwise.
+ */
+ public boolean isAc3Playable() {
+ return mAudioCapabilities != null
+ && mAudioCapabilities.supportsEncoding(AudioFormat.ENCODING_AC3);
+ }
+
+ /**
+ * Notifies when the audio cannot be played by the current device.
+ */
+ public void onAudioUnplayable() {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ /**
+ * Returns {@code true} if the player has any video track, {@code false} otherwise.
+ */
+ public boolean hasVideo() {
+ return mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0;
+ }
+
+ /**
+ * Returns {@code true} if the player has any audio trock, {@code false} otherwise.
+ */
+ public boolean hasAudio() {
+ return mPlayer.getTrackCount(TRACK_TYPE_AUDIO) > 0;
+ }
+
+ /**
+ * Returns the number of tracks exposed by the specified renderer.
+ */
+ public int getTrackCount(int rendererIndex) {
+ return mPlayer.getTrackCount(rendererIndex);
+ }
+
+ /**
+ * Selects a track for the specified renderer.
+ */
+ public void setSelectedTrack(int rendererIndex, int trackIndex) {
+ if (trackIndex >= getTrackCount(rendererIndex)) {
+ return;
+ }
+ mPlayer.setSelectedTrack(rendererIndex, trackIndex);
+ }
+
+ /**
+ * Gets the main handler of the player.
+ */
+ /* package */ Handler getMainHandler() {
+ return mMainHandler;
+ }
+
+ @Override
+ public void onPlayerStateChanged(boolean playWhenReady, int state) {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onStateChanged(playWhenReady, state);
+ if (state == ExoPlayer.STATE_READY && mPlayer.getTrackCount(TRACK_TYPE_VIDEO) > 0
+ && playWhenReady) {
+ MediaFormat format = mPlayer.getTrackFormat(TRACK_TYPE_VIDEO, 0);
+ mListener.onVideoSizeChanged(format.width,
+ format.height, format.pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException exception) {
+ mRendererBuildingState = RENDERER_BUILDING_STATE_IDLE;
+ if (mListener != null) {
+ mListener.onError(exception);
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees,
+ float pixelWidthHeightRatio) {
+ if (mListener != null) {
+ mListener.onVideoSizeChanged(width, height, pixelWidthHeightRatio);
+ }
+ }
+
+ @Override
+ public void onDecoderInitialized(String decoderName, long elapsedRealtimeMs,
+ long initializationDurationMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDecoderInitializationError(DecoderInitializationException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackInitializationError(AudioTrack.InitializationException e) {
+ if (mListener != null) {
+ mListener.onAudioUnplayable();
+ }
+ }
+
+ @Override
+ public void onAudioTrackWriteError(AudioTrack.WriteException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs,
+ long elapsedSinceLastFeedMs) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onCryptoError(CryptoException e) {
+ // Do nothing.
+ }
+
+ @Override
+ public void onPlayWhenReadyCommitted() {
+ // Do nothing.
+ }
+
+ @Override
+ public void onDrawnToSurface(Surface surface) {
+ if (mListener != null) {
+ mListener.onDrawnToSurface(this, surface);
+ }
+ }
+
+ @Override
+ public void onDroppedFrames(int count, long elapsed) {
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ @Override
+ public void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (mTrickplayRunning && mListener != null) {
+ mListener.onSmoothTrickplayForceStopped();
+ }
+ }
+
+ private void pushSurface(boolean blockForSurfacePush) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+
+ if (blockForSurfacePush) {
+ mPlayer.blockingSendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ } else {
+ mPlayer.sendMessage(
+ mVideoRenderer, MediaCodecVideoTrackRenderer.MSG_SET_SURFACE, mSurface);
+ }
+ }
+
+ private void pushTrackSelection(@TrackType int type, boolean allowRendererEnable) {
+ if (mRendererBuildingState != RENDERER_BUILDING_STATE_BUILT) {
+ return;
+ }
+ mPlayer.setSelectedTrack(type, allowRendererEnable ? 0 : -1);
+ }
+
+ private class MpegTsCcListener implements Cea708TextTrackRenderer.CcListener {
+
+ @Override
+ public void emitEvent(CaptionEvent captionEvent) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onEmitCaptionEvent(captionEvent);
+ }
+ }
+
+ @Override
+ public void discoverServiceNumber(int serviceNumber) {
+ if (mVideoEventListener != null) {
+ mVideoEventListener.onDiscoverCaptionServiceNumber(serviceNumber);
+ }
+ }
+ }
+
+ private class InternalRendererBuilderCallback implements RendererBuilderCallback {
+ private boolean canceled;
+
+ public void cancel() {
+ canceled = true;
+ }
+
+ @Override
+ public void onRenderers(String[][] trackNames, TrackRenderer[] renderers) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderers(renderers);
+ }
+ }
+
+ @Override
+ public void onRenderersError(Exception e) {
+ if (!canceled) {
+ MpegTsPlayer.this.onRenderersError(e);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
new file mode 100644
index 00000000..0e46c9cf
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/MpegTsRendererBuilder.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import android.content.Context;
+
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilder;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer.RendererBuilderCallback;
+import com.android.tv.tuner.exoplayer.ac3.Ac3PassthroughTrackRenderer;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+
+/**
+ * Builder for renderer objects for {@link MpegTsPlayer}.
+ */
+public class MpegTsRendererBuilder implements RendererBuilder {
+ private final Context mContext;
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+
+ public MpegTsRendererBuilder(Context context, BufferManager bufferManager,
+ PlaybackBufferListener bufferListener) {
+ mContext = context;
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ }
+
+ @Override
+ public void buildRenderers(MpegTsPlayer mpegTsPlayer, DataSource dataSource,
+ RendererBuilderCallback callback) {
+ // Build the video and audio renderers.
+ SampleExtractor extractor = dataSource == null ?
+ new MpegTsSampleExtractor(mBufferManager, mBufferListener) :
+ new MpegTsSampleExtractor(dataSource, mBufferManager, mBufferListener);
+ SampleSource sampleSource = new MpegTsSampleSource(extractor);
+ MpegTsVideoTrackRenderer videoRenderer = new MpegTsVideoTrackRenderer(mContext,
+ sampleSource, mpegTsPlayer.getMainHandler(), mpegTsPlayer);
+ // TODO: Only using Ac3PassthroughTrackRenderer for A/V sync issue. We will use
+ // {@link Ac3TrackRenderer} when we use ExoPlayer's extractor.
+ TrackRenderer audioRenderer = new Ac3PassthroughTrackRenderer(sampleSource,
+ mpegTsPlayer.getMainHandler(), mpegTsPlayer);
+ Cea708TextTrackRenderer textRenderer = new Cea708TextTrackRenderer(sampleSource);
+
+ TrackRenderer[] renderers = new TrackRenderer[MpegTsPlayer.RENDERER_COUNT];
+ renderers[MpegTsPlayer.TRACK_TYPE_VIDEO] = videoRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_AUDIO] = audioRenderer;
+ renderers[MpegTsPlayer.TRACK_TYPE_TEXT] = textRenderer;
+ callback.onRenderers(null, renderers);
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
new file mode 100644
index 00000000..7bf116c8
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/MpegTsSampleExtractor.java
@@ -0,0 +1,335 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer;
+
+import android.net.Uri;
+import android.os.Handler;
+
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.upstream.DataSource;
+import com.google.android.exoplayer.util.MimeTypes;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.SamplePool;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * Extracts samples from {@link DataSource} for MPEG-TS streams.
+ */
+public final class MpegTsSampleExtractor implements SampleExtractor {
+ public static final String MIMETYPE_TEXT_CEA_708 = "text/cea-708";
+
+ private static final int CC_BUFFER_SIZE_IN_BYTES = 9600 / 8;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<MediaFormat> mTrackFormats = new ArrayList<>();
+ private final List<Boolean> mReachedEos = new ArrayList<>();
+ private int mVideoTrackIndex;
+ private final SamplePool mCcSamplePool = new SamplePool();
+ private final List<SampleHolder> mPendingCcSamples = new LinkedList<>();
+
+ private int mCea708TextTrackIndex;
+ private boolean mCea708TextTrackSelected;
+
+ private CcParser mCcParser;
+
+ private void init() {
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for {@link DataSource}.
+ *
+ * @param source the {@link DataSource} to extract from
+ * @param bufferManager the manager for reading & writing samples backed by physical storage
+ * @param bufferListener the {@link PlaybackBufferListener}
+ * to notify buffer storage status change
+ */
+ public MpegTsSampleExtractor(DataSource source, BufferManager bufferManager,
+ PlaybackBufferListener bufferListener) {
+ mSampleExtractor = new ExoPlayerSampleExtractor(Uri.EMPTY, source, bufferManager,
+ bufferListener, false);
+ init();
+ }
+
+ /**
+ * Creates MpegTsSampleExtractor for a recorded program.
+ *
+ * @param bufferManager the samples provider which is stored in physical storage
+ * @param bufferListener the {@link PlaybackBufferListener}
+ * to notify buffer storage status change
+ */
+ public MpegTsSampleExtractor(BufferManager bufferManager,
+ PlaybackBufferListener bufferListener) {
+ mSampleExtractor = new FileSampleExtractor(bufferManager, bufferListener);
+ init();
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public boolean prepare() throws IOException {
+ if(!mSampleExtractor.prepare()) {
+ return false;
+ }
+ List<MediaFormat> formats = mSampleExtractor.getTrackFormats();
+ int trackCount = formats.size();
+ mTrackFormats.clear();
+ mReachedEos.clear();
+
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackFormats.add(formats.get(i));
+ mReachedEos.add(false);
+ String mime = formats.get(i).mimeType;
+ if (MimeTypes.isVideo(mime) && mVideoTrackIndex == -1) {
+ mVideoTrackIndex = i;
+ if (android.media.MediaFormat.MIMETYPE_VIDEO_MPEG2.equals(mime)) {
+ mCcParser = new Mpeg2CcParser();
+ } else if (android.media.MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
+ mCcParser = new H264CcParser();
+ }
+ }
+ }
+
+ if (mVideoTrackIndex != -1) {
+ mCea708TextTrackIndex = trackCount;
+ }
+ if (mCea708TextTrackIndex >= 0) {
+ mTrackFormats.add(MediaFormat.createTextFormat(null, MIMETYPE_TEXT_CEA_708, 0,
+ mTrackFormats.get(0).durationUs, ""));
+ }
+ return true;
+ }
+
+ @Override
+ public List<MediaFormat> getTrackFormats() {
+ return mTrackFormats;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = true;
+ return;
+ }
+ mSampleExtractor.selectTrack(index);
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (index == mCea708TextTrackIndex) {
+ mCea708TextTrackSelected = false;
+ return;
+ }
+ mSampleExtractor.deselectTrack(index);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ mSampleExtractor.seekTo(positionUs);
+ for (SampleHolder holder : mPendingCcSamples) {
+ mCcSamplePool.releaseSample(holder);
+ }
+ mPendingCcSamples.clear();
+ }
+
+ @Override
+ public void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder) {
+ if (track != mCea708TextTrackIndex) {
+ mSampleExtractor.getTrackMediaFormat(track, outMediaFormatHolder);
+ }
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder sampleHolder) {
+ if (track == mCea708TextTrackIndex) {
+ if (mCea708TextTrackSelected && !mPendingCcSamples.isEmpty()) {
+ SampleHolder holder = mPendingCcSamples.remove(0);
+ holder.data.flip();
+ sampleHolder.timeUs = holder.timeUs;
+ sampleHolder.data.put(holder.data);
+ mCcSamplePool.releaseSample(holder);
+ return SampleSource.SAMPLE_READ;
+ } else {
+ return mVideoTrackIndex < 0 || mReachedEos.get(mVideoTrackIndex)
+ ? SampleSource.END_OF_STREAM : SampleSource.NOTHING_READ;
+ }
+ }
+
+ int result = mSampleExtractor.readSample(track, sampleHolder);
+ switch (result) {
+ case SampleSource.END_OF_STREAM: {
+ mReachedEos.set(track, true);
+ break;
+ }
+ case SampleSource.SAMPLE_READ: {
+ if (mCea708TextTrackSelected && track == mVideoTrackIndex
+ && sampleHolder.data != null) {
+ mCcParser.mayParseClosedCaption(sampleHolder.data, sampleHolder.timeUs);
+ }
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void release() {
+ mSampleExtractor.release();
+ mVideoTrackIndex = -1;
+ mCea708TextTrackIndex = -1;
+ mCea708TextTrackSelected = false;
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public void setOnCompletionListener(OnCompletionListener listener, Handler handler) { }
+
+ private abstract class CcParser {
+ // Interim buffer for reduce direct access to ByteBuffer which is expensive. Using
+ // relatively small buffer size in order to minimize memory footprint increase.
+ protected final byte[] mBuffer = new byte[1024];
+
+ abstract void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs);
+
+ protected int parseClosedCaption(ByteBuffer buffer, int offset, long presentationTimeUs) {
+ // For the details of user_data_type_structure, see ATSC A/53 Part 4 - Table 6.9.
+ int pos = offset;
+ if (pos + 2 >= buffer.position()) {
+ return offset;
+ }
+ boolean processCcDataFlag = (buffer.get(pos) & 64) != 0;
+ int ccCount = buffer.get(pos) & 0x1f;
+ pos += 2;
+ if (!processCcDataFlag || pos + 3 * ccCount >= buffer.position() || ccCount == 0) {
+ return offset;
+ }
+ SampleHolder holder = mCcSamplePool.acquireSample(CC_BUFFER_SIZE_IN_BYTES);
+ for (int i = 0; i < 3 * ccCount; i++) {
+ holder.data.put(buffer.get(pos++));
+ }
+ holder.timeUs = presentationTimeUs;
+ mPendingCcSamples.add(holder);
+ return pos;
+ }
+ }
+
+ private class Mpeg2CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 9;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of private user data.
+ if (mBuffer[j] == 0
+ && mBuffer[j + 1] == 0
+ && mBuffer[j + 2] == 1
+ && (mBuffer[j + 3] & 0xff) == 0xb2) {
+ // ATSC closed caption data embedded in MPEG2VIDEO stream has 'GA94' user
+ // identifier and user data type code 3.
+ if (mBuffer[j + 4] == 'G'
+ && mBuffer[j + 5] == 'A'
+ && mBuffer[j + 6] == '9'
+ && mBuffer[j + 7] == '4'
+ && mBuffer[j + 8] == 3) {
+ j = parseClosedCaption(buffer, i + j + PATTERN_LENGTH,
+ presentationTimeUs) - i;
+ } else {
+ j += PATTERN_LENGTH;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+
+ private class H264CcParser extends CcParser {
+ private static final int PATTERN_LENGTH = 14;
+
+ @Override
+ public void mayParseClosedCaption(ByteBuffer buffer, long presentationTimeUs) {
+ int totalSize = buffer.position();
+ // Reading the frame in bulk to reduce the overhead from ByteBuffer.get() with
+ // overlapping to handle the case that the pattern exists in the boundary.
+ for (int i = 0; i < totalSize; i += mBuffer.length - PATTERN_LENGTH) {
+ buffer.position(i);
+ int size = Math.min(totalSize - i, mBuffer.length);
+ buffer.get(mBuffer, 0, size);
+ int j = 0;
+ while (j < size - PATTERN_LENGTH) {
+ // Find the start prefix code of a NAL Unit.
+ if (mBuffer[j] == 0
+ && mBuffer[j + 1] == 0
+ && mBuffer[j + 2] == 1) {
+ int nalType = mBuffer[j + 3] & 0x1f;
+ int payloadType = mBuffer[j + 4] & 0xff;
+
+ // ATSC closed caption data embedded in H264 private user data has NAL type
+ // 6, payload type 4, and 'GA94' user identifier for ATSC.
+ if (nalType == 6 && payloadType == 4 && mBuffer[j + 9] == 'G'
+ && mBuffer[j + 10] == 'A'
+ && mBuffer[j + 11] == '9'
+ && mBuffer[j + 12] == '4') {
+ j = parseClosedCaption(buffer, i + j + PATTERN_LENGTH,
+ presentationTimeUs) - i;
+ } else {
+ j += 7;
+ }
+ } else {
+ ++j;
+ }
+ }
+ }
+ buffer.position(totalSize);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java b/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
new file mode 100644
index 00000000..6007b0be
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/MpegTsSampleSource.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tv.tuner.exoplayer;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.SampleSource.SampleSourceReader;
+import com.google.android.exoplayer.util.Assertions;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/** {@link SampleSource} that extracts sample data using a {@link SampleExtractor}. */
+public final class MpegTsSampleSource implements SampleSource, SampleSourceReader {
+
+ private static final int TRACK_STATE_DISABLED = 0;
+ private static final int TRACK_STATE_ENABLED = 1;
+ private static final int TRACK_STATE_FORMAT_SENT = 2;
+
+ private final SampleExtractor mSampleExtractor;
+ private final List<Integer> mTrackStates = new ArrayList<>();
+ private final List<Boolean> mPendingDiscontinuities = new ArrayList<>();
+
+ private boolean mPrepared;
+ private IOException mPreparationError;
+ private int mRemainingReleaseCount;
+
+ private long mLastSeekPositionUs;
+ private long mPendingSeekPositionUs;
+
+ /**
+ * Creates a new sample source that extracts samples using {@code mSampleExtractor}.
+ *
+ * @param sampleExtractor a sample extractor for accessing media samples
+ */
+ public MpegTsSampleSource(SampleExtractor sampleExtractor) {
+ mSampleExtractor = Assertions.checkNotNull(sampleExtractor);
+ }
+
+ @Override
+ public SampleSourceReader register() {
+ mRemainingReleaseCount++;
+ return this;
+ }
+
+ @Override
+ public boolean prepare(long positionUs) {
+ if (!mPrepared) {
+ if (mPreparationError != null) {
+ return false;
+ }
+ try {
+ if (mSampleExtractor.prepare()) {
+ int trackCount = mSampleExtractor.getTrackFormats().size();
+ mTrackStates.clear();
+ mPendingDiscontinuities.clear();
+ for (int i = 0; i < trackCount; ++i) {
+ mTrackStates.add(i, TRACK_STATE_DISABLED);
+ mPendingDiscontinuities.add(i, false);
+ }
+ mPrepared = true;
+ } else {
+ return false;
+ }
+ } catch (IOException e) {
+ mPreparationError = e;
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int getTrackCount() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().size();
+ }
+
+ @Override
+ public MediaFormat getFormat(int track) {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getTrackFormats().get(track);
+ }
+
+ @Override
+ public void enable(int track, long positionUs) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) == TRACK_STATE_DISABLED);
+ mTrackStates.set(track, TRACK_STATE_ENABLED);
+ mSampleExtractor.selectTrack(track);
+ seekToUsInternal(positionUs, positionUs != 0);
+ }
+
+ @Override
+ public void disable(int track) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ mSampleExtractor.deselectTrack(track);
+ mPendingDiscontinuities.set(track, false);
+ mTrackStates.set(track, TRACK_STATE_DISABLED);
+ }
+
+ @Override
+ public boolean continueBuffering(int track, long positionUs) {
+ return mSampleExtractor.continueBuffering(positionUs);
+ }
+
+ @Override
+ public long readDiscontinuity(int track) {
+ if (mPendingDiscontinuities.get(track)) {
+ mPendingDiscontinuities.set(track, false);
+ return mLastSeekPositionUs;
+ }
+ return NO_DISCONTINUITY;
+ }
+
+ @Override
+ public int readData(int track, long positionUs, MediaFormatHolder formatHolder,
+ SampleHolder sampleHolder) {
+ Assertions.checkState(mPrepared);
+ Assertions.checkState(mTrackStates.get(track) != TRACK_STATE_DISABLED);
+ if (mPendingDiscontinuities.get(track)) {
+ return NOTHING_READ;
+ }
+ if (mTrackStates.get(track) != TRACK_STATE_FORMAT_SENT) {
+ mSampleExtractor.getTrackMediaFormat(track, formatHolder);
+ mTrackStates.set(track, TRACK_STATE_FORMAT_SENT);
+ return FORMAT_READ;
+ }
+
+ mPendingSeekPositionUs = C.UNKNOWN_TIME_US;
+ return mSampleExtractor.readSample(track, sampleHolder);
+ }
+
+ @Override
+ public void maybeThrowError() throws IOException {
+ if (mPreparationError != null) {
+ throw mPreparationError;
+ }
+ if (mSampleExtractor != null) {
+ mSampleExtractor.maybeThrowError();
+ }
+ }
+
+ @Override
+ public void seekToUs(long positionUs) {
+ Assertions.checkState(mPrepared);
+ seekToUsInternal(positionUs, false);
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Assertions.checkState(mPrepared);
+ return mSampleExtractor.getBufferedPositionUs();
+ }
+
+ @Override
+ public void release() {
+ Assertions.checkState(mRemainingReleaseCount > 0);
+ if (--mRemainingReleaseCount == 0) {
+ mSampleExtractor.release();
+ }
+ }
+
+ private void seekToUsInternal(long positionUs, boolean force) {
+ // Unless forced, avoid duplicate calls to the underlying extractor's seek method
+ // in the case that there have been no interleaving calls to readSample.
+ if (force || mPendingSeekPositionUs != positionUs) {
+ mLastSeekPositionUs = positionUs;
+ mPendingSeekPositionUs = positionUs;
+ mSampleExtractor.seekTo(positionUs);
+ for (int i = 0; i < mTrackStates.size(); ++i) {
+ if (mTrackStates.get(i) != TRACK_STATE_DISABLED) {
+ mPendingDiscontinuities.set(i, true);
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
new file mode 100644
index 00000000..19360c69
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/MpegTsVideoTrackRenderer.java
@@ -0,0 +1,101 @@
+package com.android.tv.tuner.exoplayer;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.os.Handler;
+import android.util.Log;
+
+import com.google.android.exoplayer.DecoderInfo;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.MediaCodecUtil;
+import com.google.android.exoplayer.MediaCodecVideoTrackRenderer;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaSoftwareCodecUtil;
+import com.google.android.exoplayer.SampleSource;
+import com.android.tv.common.feature.CommonFeatures;
+
+import java.lang.reflect.Field;
+
+/**
+ * MPEG-2 TS video track renderer
+ */
+public class MpegTsVideoTrackRenderer extends MediaCodecVideoTrackRenderer {
+ private static final String TAG = "MpegTsVideoTrackRender";
+
+ private static final int VIDEO_PLAYBACK_DEADLINE_IN_MS = 5000;
+ // If DROPPED_FRAMES_NOTIFICATION_THRESHOLD frames are consecutively dropped, it'll be notified.
+ private static final int DROPPED_FRAMES_NOTIFICATION_THRESHOLD = 10;
+ private static final int MIN_HD_HEIGHT = 720;
+ private static final String MIMETYPE_MPEG2 = "video/mpeg2";
+ private static Field sRenderedFirstFrameField;
+
+ private final boolean mIsSwCodecEnabled;
+ private boolean mCodecIsSwPreferred;
+ private boolean mSetRenderedFirstFrame;
+
+ static {
+ // Remove the reflection below once b/31223646 is resolved.
+ try {
+ sRenderedFirstFrameField = MediaCodecVideoTrackRenderer.class.getDeclaredField(
+ "renderedFirstFrame");
+ sRenderedFirstFrameField.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ // Null-checking for {@code sRenderedFirstFrameField} will do the error handling.
+ }
+ }
+
+ public MpegTsVideoTrackRenderer(Context context, SampleSource source, Handler handler,
+ MediaCodecVideoTrackRenderer.EventListener listener) {
+ super(context, source, MediaCodecSelector.DEFAULT,
+ MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_PLAYBACK_DEADLINE_IN_MS, handler,
+ listener, DROPPED_FRAMES_NOTIFICATION_THRESHOLD);
+ mIsSwCodecEnabled = CommonFeatures.USE_SW_CODEC_FOR_SD.isEnabled(context);
+ }
+
+ @Override
+ protected DecoderInfo getDecoderInfo(MediaCodecSelector codecSelector, String mimeType,
+ boolean requiresSecureDecoder) throws MediaCodecUtil.DecoderQueryException {
+ try {
+ if (mIsSwCodecEnabled && mCodecIsSwPreferred) {
+ DecoderInfo swCodec = MediaSoftwareCodecUtil.getSoftwareDecoderInfo(
+ mimeType, requiresSecureDecoder);
+ if (swCodec != null) {
+ return swCodec;
+ }
+ }
+ } catch (MediaSoftwareCodecUtil.DecoderQueryException e) {
+ }
+ return super.getDecoderInfo(codecSelector, mimeType,requiresSecureDecoder);
+ }
+
+ @Override
+ protected void onInputFormatChanged(MediaFormatHolder holder) throws ExoPlaybackException {
+ mCodecIsSwPreferred = MIMETYPE_MPEG2.equalsIgnoreCase(holder.format.mimeType)
+ && holder.format.height < MIN_HD_HEIGHT;
+ super.onInputFormatChanged(holder);
+ }
+
+ @Override
+ protected void onDiscontinuity(long positionUs) throws ExoPlaybackException {
+ super.onDiscontinuity(positionUs);
+ // Disabling pre-rendering of the first frame in order to avoid a frozen picture when
+ // starting the playback. We do this only once, when the renderer is enabled at first, since
+ // we need to pre-render the frame in advance when we do trickplay backed by seeking.
+ if (!mSetRenderedFirstFrame) {
+ setRenderedFirstFrame(true);
+ mSetRenderedFirstFrame = true;
+ }
+ }
+
+ private void setRenderedFirstFrame(boolean renderedFirstFrame) {
+ if (sRenderedFirstFrameField != null) {
+ try {
+ sRenderedFirstFrameField.setBoolean(this, renderedFirstFrame);
+ } catch (IllegalAccessException e) {
+ Log.w(TAG, "renderedFirstFrame is not accessible. Playback may start with a frozen"
+ +" picture.");
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/SampleExtractor.java b/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
new file mode 100644
index 00000000..543588c7
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/SampleExtractor.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tv.tuner.exoplayer;
+
+import android.os.Handler;
+
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Extractor for reading track metadata and samples stored in tracks.
+ *
+ * <p>Call {@link #prepare} until it returns {@code true}, then access track metadata via
+ * {@link #getTrackFormats} and {@link #getTrackMediaFormat}.
+ *
+ * <p>Pass indices of tracks to read from to {@link #selectTrack}. A track can later be deselected
+ * by calling {@link #deselectTrack}. It is safe to select/deselect tracks after reading sample
+ * data or seeking. Initially, all tracks are deselected.
+ *
+ * <p>Call {@link #release()} when the extractor is no longer needed to free resources.
+ */
+public interface SampleExtractor {
+
+ /**
+ * If the extractor is currently having difficulty preparing or loading samples, then this
+ * method throws the underlying error. Otherwise does nothing.
+ *
+ * @throws IOException The underlying error.
+ */
+ void maybeThrowError() throws IOException;
+
+ /**
+ * Prepares the extractor for reading track metadata and samples.
+ *
+ * @return whether the source is ready; if {@code false}, this method must be called again.
+ * @throws IOException thrown if the source can't be read
+ */
+ boolean prepare() throws IOException;
+
+ /** Returns track information about all tracks that can be selected. */
+ List<MediaFormat> getTrackFormats();
+
+ /** Selects the track at {@code index} for reading sample data. */
+ void selectTrack(int index);
+
+ /** Deselects the track at {@code index}, so no more samples will be read from that track. */
+ void deselectTrack(int index);
+
+ /**
+ * Returns an estimate of the position up to which data is buffered.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @return an estimate of the absolute position in microseconds up to which data is buffered,
+ * or {@link TrackRenderer#END_OF_TRACK_US} if data is buffered to the end of the stream, or
+ * {@link TrackRenderer#UNKNOWN_TIME_US} if no estimate is available.
+ */
+ long getBufferedPositionUs();
+
+ /**
+ * Seeks to the specified time in microseconds.
+ *
+ * <p>This method should not be called until after the extractor has been successfully prepared.
+ *
+ * @param positionUs the seek position in microseconds
+ */
+ void seekTo(long positionUs);
+
+ /** Stores the {@link MediaFormat} of {@code track}. */
+ void getTrackMediaFormat(int track, MediaFormatHolder outMediaFormatHolder);
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder}, returning
+ * {@link SampleSource#SAMPLE_READ} if it is available.
+ *
+ * <p>Advances to the next sample if a sample was read.
+ *
+ * @param track the index of the track from which to read a sample
+ * @param sampleHolder the holder for read sample data, if {@link SampleSource#SAMPLE_READ} is
+ * returned
+ * @return {@link SampleSource#SAMPLE_READ} if a sample was read into {@code sampleHolder}, or
+ * {@link SampleSource#END_OF_STREAM} if the last samples in all tracks have been read, or
+ * {@link SampleSource#NOTHING_READ} if the sample cannot be read immediately as it is not
+ * loaded.
+ */
+ int readSample(int track, SampleHolder sampleHolder);
+
+ /** Releases resources associated with this extractor. */
+ void release();
+
+ /** Indicates to the source that it should still be buffering data. */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Sets OnCompletionListener for notifying the completion of SampleExtractor.
+ *
+ * @param listener the OnCompletionListener
+ * @param handler the {@link Handler} for {@link Handler#post(Runnable)} of OnCompletionListener
+ */
+ void setOnCompletionListener(OnCompletionListener listener, Handler handler);
+
+ /**
+ * The listener for SampleExtractor being completed.
+ */
+ interface OnCompletionListener {
+
+ /**
+ * Called when sample extraction is completed.
+ *
+ * @param result {@code true} when the extractor is finished without an error,
+ * {@code false} otherwise (storage error, weak signal, being reached at EoS
+ * prematurely, etc.)
+ * @param lastExtractedPositionUs the last extracted position when extractor is completed
+ */
+ void onCompletion(boolean result, long lastExtractedPositionUs);
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java b/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java
new file mode 100644
index 00000000..9dae2e34
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ac3/Ac3PassthroughTrackRenderer.java
@@ -0,0 +1,540 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.ac3;
+
+import android.os.Handler;
+import android.os.SystemClock;
+import android.util.Log;
+
+import com.google.android.exoplayer.CodecCounters;
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaClock;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.MediaFormatHolder;
+import com.google.android.exoplayer.MediaFormatUtil;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.TrackRenderer;
+import com.google.android.exoplayer.audio.AudioTrack;
+import com.google.android.exoplayer.util.Assertions;
+import com.google.android.exoplayer.util.MimeTypes;
+import com.android.tv.tuner.tvinput.TunerDebug;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+
+/**
+ * Decodes and renders AC3 audio.
+ */
+public class Ac3PassthroughTrackRenderer extends TrackRenderer implements MediaClock {
+ public static final int MSG_SET_VOLUME = 10000;
+ public static final int MSG_SET_AUDIO_TRACK = MSG_SET_VOLUME + 1;
+ public static final int MSG_SET_PLAYBACK_SPEED = MSG_SET_VOLUME + 2;
+
+ // ATSC/53 allows sample rate to be only 48Khz.
+ // One AC3 sample has 1536 frames, and its duration is 32ms.
+ public static final long AC3_SAMPLE_DURATION_US = 32000;
+
+ private static final String TAG = "Ac3PassthroughTrackRenderer";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Interface definition for a callback to be notified of
+ * {@link com.google.android.exoplayer.audio.AudioTrack} error.
+ */
+ public interface EventListener {
+ void onAudioTrackInitializationError(AudioTrack.InitializationException e);
+ void onAudioTrackWriteError(AudioTrack.WriteException e);
+ }
+
+ private static final int DEFAULT_INPUT_BUFFER_SIZE = 16384 * 2;
+ private static final int DEFAULT_OUTPUT_BUFFER_SIZE = 1024*1024;
+ private static final int MONITOR_DURATION_MS = 1000;
+ private static final int AC3_HEADER_BITRATE_OFFSET = 4;
+
+ // Keep this as static in order to prevent new framework AudioTrack creation
+ // while old AudioTrack is being released.
+ private static final AudioTrackWrapper AUDIO_TRACK = new AudioTrackWrapper();
+ private static final long KEEP_ALIVE_AFTER_EOS_DURATION_MS = 3000;
+
+ // Ignore AudioTrack backward movement if duration of movement is below the threshold.
+ private static final long BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US = 3000;
+
+ // AudioTrack position cannot go ahead beyond this limit.
+ private static final long CURRENT_POSITION_FROM_PTS_LIMIT_US = 1000000;
+
+ // Since MediaCodec processing and AudioTrack playing add delay,
+ // PTS interpolated time should be delayed reasonably when AudioTrack is not used.
+ private static final long ESTIMATED_TRACK_RENDERING_DELAY_US = 500000;
+
+ private final CodecCounters mCodecCounters;
+ private final SampleSource.SampleSourceReader mSource;
+ private final SampleHolder mSampleHolder;
+ private final MediaFormatHolder mFormatHolder;
+ private final EventListener mEventListener;
+ private final Handler mEventHandler;
+ private final AudioTrackMonitor mMonitor;
+ private final AudioClock mAudioClock;
+
+ private MediaFormat mFormat;
+ private final ByteBuffer mOutputBuffer;
+ private boolean mOutputReady;
+ private int mTrackIndex;
+ private boolean mSourceStateReady;
+ private boolean mInputStreamEnded;
+ private boolean mOutputStreamEnded;
+ private long mEndOfStreamMs;
+ private long mCurrentPositionUs;
+ private int mPresentationCount;
+ private long mPresentationTimeUs;
+ private long mInterpolatedTimeUs;
+ private long mPreviousPositionUs;
+ private boolean mIsStopped;
+ private ArrayList<Integer> mTracksIndex;
+
+ public Ac3PassthroughTrackRenderer(SampleSource source, Handler eventHandler,
+ EventListener listener) {
+ mSource = source.register();
+ mEventHandler = eventHandler;
+ mEventListener = listener;
+ mTrackIndex = -1;
+ mSampleHolder = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_DIRECT);
+ mSampleHolder.ensureSpaceForWrite(DEFAULT_INPUT_BUFFER_SIZE);
+ mOutputBuffer = ByteBuffer.allocate(DEFAULT_OUTPUT_BUFFER_SIZE);
+ mFormatHolder = new MediaFormatHolder();
+ AUDIO_TRACK.restart();
+ mCodecCounters = new CodecCounters();
+ mMonitor = new AudioTrackMonitor();
+ mAudioClock = new AudioClock();
+ mTracksIndex = new ArrayList<>();
+ }
+
+ @Override
+ protected MediaClock getMediaClock() {
+ return this;
+ }
+
+ private static boolean handlesMimeType(String mimeType) {
+ return mimeType.equals(MimeTypes.AUDIO_AC3) || mimeType.equals(MimeTypes.AUDIO_E_AC3);
+ }
+
+ @Override
+ protected boolean doPrepare(long positionUs) throws ExoPlaybackException {
+ boolean sourcePrepared = mSource.prepare(positionUs);
+ if (!sourcePrepared) {
+ return false;
+ }
+ for (int i = 0; i < mSource.getTrackCount(); i++) {
+ if (handlesMimeType(mSource.getFormat(i).mimeType)) {
+ if (mTrackIndex < 0) {
+ mTrackIndex = i;
+ }
+ mTracksIndex.add(i);
+ }
+ }
+
+ // TODO: Check this case. Source does not have the proper mime type.
+ return true;
+ }
+
+ @Override
+ protected int getTrackCount() {
+ return mTracksIndex.size();
+ }
+
+ @Override
+ protected MediaFormat getFormat(int track) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ return mSource.getFormat(mTracksIndex.get(track));
+ }
+
+ @Override
+ protected void onEnabled(int track, long positionUs, boolean joining) {
+ Assertions.checkArgument(track >= 0 && track < mTracksIndex.size());
+ mTrackIndex = mTracksIndex.get(track);
+ mSource.enable(mTrackIndex, positionUs);
+ seekToInternal(positionUs);
+ }
+
+ @Override
+ protected void onDisabled() {
+ AUDIO_TRACK.resetSessionId();
+ clearDecodeState();
+ mFormat = null;
+ mSource.disable(mTrackIndex);
+ }
+
+ @Override
+ protected void onReleased() {
+ AUDIO_TRACK.release();
+ mSource.release();
+ }
+
+ @Override
+ protected boolean isEnded() {
+ return mOutputStreamEnded && AUDIO_TRACK.isEnded();
+ }
+
+ @Override
+ protected boolean isReady() {
+ return AUDIO_TRACK.isReady() || (mFormat != null && (mSourceStateReady || mOutputReady));
+ }
+
+ private void seekToInternal(long positionUs) {
+ mMonitor.reset(MONITOR_DURATION_MS);
+ mSourceStateReady = false;
+ mInputStreamEnded = false;
+ mOutputStreamEnded = false;
+ mPresentationTimeUs = positionUs;
+ mPresentationCount = 0;
+ mPreviousPositionUs = 0;
+ mCurrentPositionUs = Long.MIN_VALUE;
+ mInterpolatedTimeUs = Long.MIN_VALUE;
+ mAudioClock.setPositionUs(positionUs);
+ }
+
+ @Override
+ protected void seekTo(long positionUs) {
+ mSource.seekToUs(positionUs);
+ AUDIO_TRACK.reset();
+ // resetSessionId() will create a new framework AudioTrack instead of reusing old one.
+ AUDIO_TRACK.resetSessionId();
+ seekToInternal(positionUs);
+ }
+
+ @Override
+ protected void onStarted() {
+ AUDIO_TRACK.play();
+ mAudioClock.start();
+ mIsStopped = false;
+ }
+
+ @Override
+ protected void onStopped() {
+ AUDIO_TRACK.pause();
+ mAudioClock.stop();
+ mIsStopped = true;
+ }
+
+ @Override
+ protected void maybeThrowError() throws ExoPlaybackException {
+ try {
+ mSource.maybeThrowError();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ @Override
+ protected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
+ mMonitor.maybeLog();
+ try {
+ if (mEndOfStreamMs != 0) {
+ // Ensure playback stops, after EoS was notified.
+ // Sometimes MediaCodecTrackRenderer does not fetch EoS timely
+ // after EoS was notified here long before.
+ long diff = SystemClock.elapsedRealtime() - mEndOfStreamMs;
+ if (diff >= KEEP_ALIVE_AFTER_EOS_DURATION_MS && !mIsStopped) {
+ throw new ExoPlaybackException("Much time has elapsed after EoS");
+ }
+ }
+ boolean continueBuffering = mSource.continueBuffering(mTrackIndex, positionUs);
+ if (mSourceStateReady != continueBuffering) {
+ mSourceStateReady = continueBuffering;
+ if (DEBUG) {
+ Log.d(TAG, "mSourceStateReady: " + String.valueOf(mSourceStateReady));
+ }
+ }
+ long discontinuity = mSource.readDiscontinuity(mTrackIndex);
+ if (discontinuity != SampleSource.NO_DISCONTINUITY) {
+ AUDIO_TRACK.handleDiscontinuity();
+ mPresentationTimeUs = discontinuity;
+ mPresentationCount = 0;
+ clearDecodeState();
+ return;
+ }
+ if (mFormat == null) {
+ readFormat();
+ return;
+ }
+
+ // Process only one sample at a time for doSomeWork()
+ if (processOutput()) {
+ if (!mOutputReady) {
+ while (feedInputBuffer()) {
+ if (mOutputReady) break;
+ }
+ }
+ }
+ mCodecCounters.ensureUpdated();
+ } catch (IOException e) {
+ throw new ExoPlaybackException(e);
+ }
+ }
+
+ private void ensureAudioTrackInitialized() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ try {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack initialized");
+ }
+ AUDIO_TRACK.initialize();
+ } catch (AudioTrack.InitializationException e) {
+ Log.e(TAG, "Error on AudioTrack initialization", e);
+ notifyAudioTrackInitializationError(e);
+
+ // Do not throw exception here but just disabling audioTrack to keep playing
+ // video without audio.
+ AUDIO_TRACK.setStatus(false);
+ }
+ if (getState() == TrackRenderer.STATE_STARTED) {
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack played");
+ }
+ AUDIO_TRACK.play();
+ }
+ }
+ }
+
+ private void clearDecodeState() {
+ mOutputReady = false;
+ AUDIO_TRACK.reset();
+ }
+
+ private void readFormat() throws IOException, ExoPlaybackException {
+ int result = mSource.readData(mTrackIndex, mCurrentPositionUs,
+ mFormatHolder, mSampleHolder);
+ if (result == SampleSource.FORMAT_READ) {
+ onInputFormatChanged(mFormatHolder);
+ }
+ }
+
+ private void onInputFormatChanged(MediaFormatHolder formatHolder)
+ throws ExoPlaybackException {
+ mFormat = formatHolder.format;
+ if (DEBUG) {
+ Log.d(TAG, "AudioTrack was configured to FORMAT: " + mFormat.toString());
+ }
+ clearDecodeState();
+ AUDIO_TRACK.reconfigure(mFormat.getFrameworkMediaFormatV16());
+ }
+
+ private boolean feedInputBuffer() throws IOException, ExoPlaybackException {
+ if (mInputStreamEnded) {
+ return false;
+ }
+
+ mSampleHolder.data.clear();
+ mSampleHolder.size = 0;
+ int result = mSource.readData(mTrackIndex, mPresentationTimeUs, mFormatHolder,
+ mSampleHolder);
+ switch (result) {
+ case SampleSource.NOTHING_READ: {
+ return false;
+ }
+ case SampleSource.FORMAT_READ: {
+ Log.i(TAG, "Format was read again");
+ onInputFormatChanged(mFormatHolder);
+ return true;
+ }
+ case SampleSource.END_OF_STREAM: {
+ Log.i(TAG, "End of stream from SampleSource");
+ mInputStreamEnded = true;
+ return false;
+ }
+ default: {
+ mSampleHolder.data.flip();
+ decodeDone(mSampleHolder.data, mSampleHolder.timeUs);
+ return true;
+ }
+ }
+ }
+
+ private boolean processOutput() throws ExoPlaybackException {
+ if (mOutputStreamEnded) {
+ return false;
+ }
+ if (!mOutputReady) {
+ if (mInputStreamEnded) {
+ mOutputStreamEnded = true;
+ mEndOfStreamMs = SystemClock.elapsedRealtime();
+ return false;
+ }
+ return true;
+ }
+
+ ensureAudioTrackInitialized();
+ int handleBufferResult;
+ try {
+ // To reduce discontinuity, interpolate presentation time.
+ mInterpolatedTimeUs = mPresentationTimeUs
+ + mPresentationCount * AC3_SAMPLE_DURATION_US;
+ handleBufferResult = AUDIO_TRACK.handleBuffer(mOutputBuffer,
+ 0, mOutputBuffer.limit(), mInterpolatedTimeUs);
+ } catch (AudioTrack.WriteException e) {
+ notifyAudioTrackWriteError(e);
+ throw new ExoPlaybackException(e);
+ }
+
+ if ((handleBufferResult & AudioTrack.RESULT_POSITION_DISCONTINUITY) != 0) {
+ Log.i(TAG, "Play discontinuity happened");
+ mCurrentPositionUs = Long.MIN_VALUE;
+ }
+ if ((handleBufferResult & AudioTrack.RESULT_BUFFER_CONSUMED) != 0) {
+ mCodecCounters.renderedOutputBufferCount++;
+ mOutputReady = false;
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ protected long getDurationUs() {
+ return mSource.getFormat(mTrackIndex).durationUs;
+ }
+
+ @Override
+ protected long getBufferedPositionUs() {
+ long pos = mSource.getBufferedPositionUs();
+ return pos == UNKNOWN_TIME_US || pos == END_OF_TRACK_US
+ ? pos : Math.max(pos, getPositionUs());
+ }
+
+ @Override
+ public long getPositionUs() {
+ if (!AUDIO_TRACK.isInitialized()) {
+ return mAudioClock.getPositionUs();
+ } else if (!AUDIO_TRACK.isEnabled()) {
+ if (mInterpolatedTimeUs > 0) {
+ return mInterpolatedTimeUs - ESTIMATED_TRACK_RENDERING_DELAY_US;
+ }
+ return mPresentationTimeUs;
+ }
+ long audioTrackCurrentPositionUs = AUDIO_TRACK.getCurrentPositionUs(isEnded());
+ if (audioTrackCurrentPositionUs == AudioTrack.CURRENT_POSITION_NOT_SET) {
+ mPreviousPositionUs = 0L;
+ if (DEBUG) {
+ long oldPositionUs = Math.max(mCurrentPositionUs, 0);
+ long currentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ Log.d(TAG, "Audio position is not set, diff in us: "
+ + String.valueOf(currentPositionUs - oldPositionUs));
+ }
+ mCurrentPositionUs = Math.max(mPresentationTimeUs, mCurrentPositionUs);
+ } else {
+ if (mPreviousPositionUs
+ > audioTrackCurrentPositionUs + BACKWARD_AUDIO_TRACK_MOVE_THRESHOLD_US) {
+ Log.e(TAG, "audio_position BACK JUMP: "
+ + (mPreviousPositionUs - audioTrackCurrentPositionUs));
+ mCurrentPositionUs = audioTrackCurrentPositionUs;
+ } else {
+ mCurrentPositionUs = Math.max(mCurrentPositionUs, audioTrackCurrentPositionUs);
+ }
+ mPreviousPositionUs = audioTrackCurrentPositionUs;
+ }
+ long upperBound = mPresentationTimeUs + CURRENT_POSITION_FROM_PTS_LIMIT_US;
+ if (mCurrentPositionUs > upperBound) {
+ mCurrentPositionUs = upperBound;
+ }
+ return mCurrentPositionUs;
+ }
+
+ private void decodeDone(ByteBuffer outputBuffer, long presentationTimeUs) {
+ if (outputBuffer == null || mOutputBuffer == null) {
+ return;
+ }
+ if (presentationTimeUs < 0) {
+ Log.e(TAG, "decodeDone - invalid presentationTimeUs");
+ return;
+ }
+
+ if (TunerDebug.ENABLED) {
+ TunerDebug.setAudioPtsUs(presentationTimeUs);
+ }
+
+ mOutputBuffer.clear();
+ Assertions.checkState(mOutputBuffer.remaining() >= outputBuffer.limit());
+
+ mOutputBuffer.put(outputBuffer);
+ mMonitor.addPts(presentationTimeUs, mOutputBuffer.position(),
+ mOutputBuffer.get(AC3_HEADER_BITRATE_OFFSET));
+ if (presentationTimeUs == mPresentationTimeUs) {
+ mPresentationCount++;
+ } else {
+ mPresentationCount = 0;
+ mPresentationTimeUs = presentationTimeUs;
+ }
+ mOutputBuffer.flip();
+ mOutputReady = true;
+ }
+
+ private void notifyAudioTrackInitializationError(final AudioTrack.InitializationException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackInitializationError(e);
+ }
+ });
+ }
+
+ private void notifyAudioTrackWriteError(final AudioTrack.WriteException e) {
+ if (mEventHandler == null || mEventListener == null) {
+ return;
+ }
+ mEventHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mEventListener.onAudioTrackWriteError(e);
+ }
+ });
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ switch (messageType) {
+ case MSG_SET_VOLUME:
+ AUDIO_TRACK.setVolume((Float) message);
+ break;
+ case MSG_SET_AUDIO_TRACK:
+ boolean enabled = (Integer) message == 1;
+ if (enabled == AUDIO_TRACK.isEnabled()) {
+ return;
+ }
+ if (!enabled) {
+ // mAudioClock can be different from getPositionUs. In order to sync them,
+ // we set mAudioClock.
+ mAudioClock.setPositionUs(getPositionUs());
+ }
+ AUDIO_TRACK.setStatus(enabled);
+ if (enabled) {
+ // When AUDIO_TRACK is enabled, we need to clear AUDIO_TRACK and seek to
+ // the current position. If not, AUDIO_TRACK has the obsolete data.
+ seekTo(mAudioClock.getPositionUs());
+ }
+ break;
+ case MSG_SET_PLAYBACK_SPEED:
+ mAudioClock.setPlaybackSpeed((Float) message);
+ break;
+ default:
+ super.handleMessage(messageType, message);
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java b/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java
new file mode 100644
index 00000000..2bf86b5a
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ac3/Ac3TrackRenderer.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.ac3;
+
+import android.os.Handler;
+
+import com.google.android.exoplayer.ExoPlaybackException;
+import com.google.android.exoplayer.MediaCodecAudioTrackRenderer;
+import com.google.android.exoplayer.MediaCodecSelector;
+import com.google.android.exoplayer.SampleSource;
+
+/**
+ * MPEG-2 TS audio track renderer.
+ * <p>Since the audio output from {@link android.media.MediaExtractor} contains extra samples at
+ * the beginning, using original {@link MediaCodecAudioTrackRenderer} as audio renderer causes
+ * asynchronous Audio/Video outputs.
+ * This class calculates the offset of audio data and adjust the presentation times to avoid the
+ * asynchronous Audio/Video problem.
+ */
+public class Ac3TrackRenderer extends MediaCodecAudioTrackRenderer {
+ private final String TAG = "Ac3TrackRenderer";
+ private final boolean DEBUG = false;
+
+ private final Ac3EventListener mListener;
+
+ public interface Ac3EventListener extends EventListener {
+ /**
+ * Invoked when a {@link android.media.PlaybackParams} set to an
+ * {@link android.media.AudioTrack} is not valid.
+ *
+ * @param e The corresponding exception.
+ */
+ void onAudioTrackSetPlaybackParamsError(IllegalArgumentException e);
+ }
+
+ public Ac3TrackRenderer(SampleSource source, MediaCodecSelector mediaCodecSelector,
+ Handler eventHandler, EventListener eventListener) {
+ super(source, mediaCodecSelector, eventHandler, eventListener);
+ mListener = (Ac3EventListener) eventListener;
+ }
+
+ @Override
+ public void handleMessage(int messageType, Object message) throws ExoPlaybackException {
+ if (messageType == MSG_SET_PLAYBACK_PARAMS) {
+ try {
+ super.handleMessage(messageType, message);
+ } catch (IllegalArgumentException e) {
+ if (isAudioTrackSetPlaybackParamsError(e)) {
+ notifyAudioTrackSetPlaybackParamsError(e);
+ }
+ }
+ return;
+ }
+ super.handleMessage(messageType, message);
+ }
+
+ private void notifyAudioTrackSetPlaybackParamsError(final IllegalArgumentException e) {
+ if (eventHandler != null && mListener != null) {
+ eventHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mListener.onAudioTrackSetPlaybackParamsError(e);
+ }
+ });
+ }
+ }
+
+ static private boolean isAudioTrackSetPlaybackParamsError(IllegalArgumentException e) {
+ if (e.getStackTrace() == null || e.getStackTrace().length < 1) {
+ return false;
+ }
+ for (StackTraceElement element : e.getStackTrace()) {
+ String elementString = element.toString();
+ if (elementString.startsWith("android.media.AudioTrack.setPlaybackParams")) {
+ return true;
+ }
+ }
+ return false;
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java
new file mode 100644
index 00000000..600c2c88
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioClock.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.ac3;
+
+import com.android.tv.common.SoftPreconditions;
+
+import android.os.SystemClock;
+
+/**
+ * Copy of {@link com.google.android.exoplayer.MediaClock}.
+ * <p>
+ * A simple clock for tracking the progression of media time. The clock can be started, stopped and
+ * its time can be set and retrieved. When started, this clock is based on
+ * {@link SystemClock#elapsedRealtime()}.
+ */
+/* package */ class AudioClock {
+ private boolean mStarted;
+
+ /**
+ * The media time when the clock was last set or stopped.
+ */
+ private long mPositionUs;
+
+ /**
+ * The difference between {@link SystemClock#elapsedRealtime()} and {@link #mPositionUs}
+ * when the clock was last set or mStarted.
+ */
+ private long mDeltaUs;
+
+ private float mPlaybackSpeed = 1.0f;
+ private long mDeltaUpdatedTimeUs;
+
+ /**
+ * Starts the clock. Does nothing if the clock is already started.
+ */
+ public void start() {
+ if (!mStarted) {
+ mStarted = true;
+ mDeltaUs = elapsedRealtimeMinus(mPositionUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+ }
+
+ /**
+ * Stops the clock. Does nothing if the clock is already stopped.
+ */
+ public void stop() {
+ if (mStarted) {
+ mPositionUs = elapsedRealtimeMinus(mDeltaUs);
+ mStarted = false;
+ }
+ }
+
+ /**
+ * @param timeUs The position to set in microseconds.
+ */
+ public void setPositionUs(long timeUs) {
+ this.mPositionUs = timeUs;
+ mDeltaUs = elapsedRealtimeMinus(timeUs);
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ }
+
+ /**
+ * @return The current position in microseconds.
+ */
+ public long getPositionUs() {
+ if (!mStarted) {
+ return mPositionUs;
+ }
+ if (mPlaybackSpeed != 1.0f) {
+ long elapsedTimeFromPlaybackSpeedChanged = SystemClock.elapsedRealtime() * 1000
+ - mDeltaUpdatedTimeUs;
+ return elapsedRealtimeMinus(mDeltaUs)
+ + (long) ((mPlaybackSpeed - 1.0f) * elapsedTimeFromPlaybackSpeedChanged);
+ } else {
+ return elapsedRealtimeMinus(mDeltaUs);
+ }
+ }
+
+ /**
+ * Sets playback speed. {@code speed} should be positive.
+ */
+ public void setPlaybackSpeed(float speed) {
+ SoftPreconditions.checkState(speed > 0);
+ mDeltaUs = elapsedRealtimeMinus(getPositionUs());
+ mDeltaUpdatedTimeUs = SystemClock.elapsedRealtime() * 1000;
+ mPlaybackSpeed = speed;
+ }
+
+ private long elapsedRealtimeMinus(long toSubtractUs) {
+ return SystemClock.elapsedRealtime() * 1000 - toSubtractUs;
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java
new file mode 100644
index 00000000..bfdf08ac
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackMonitor.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.ac3;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Monitors the rendering position of {@link AudioTrack}.
+ */
+public class AudioTrackMonitor {
+ private static final String TAG = "AudioTrackMonitor";
+ private static final boolean DEBUG = false;
+
+ // For fetched audio samples
+ private final ArrayList<Pair<Long, Integer>> mPtsList = new ArrayList<>();
+ private final Set<Integer> mSampleSize = new HashSet<>();
+ private final Set<Integer> mCurSampleSize = new HashSet<>();
+ private final Set<Integer> mAc3Header = new HashSet<>();
+
+ private long mExpireMs;
+ private long mDuration;
+ private long mSampleCount;
+ private long mTotalCount;
+ private long mStartMs;
+
+ private void flush() {
+ mExpireMs += mDuration;
+ mSampleCount = 0;
+ mCurSampleSize.clear();
+ mPtsList.clear();
+ }
+
+ /**
+ * Resets and initializes {@link AudioTrackMonitor}.
+ *
+ * @param duration the frequency of monitoring in milliseconds
+ */
+ public void reset(long duration) {
+ mExpireMs = SystemClock.elapsedRealtime();
+ mDuration = duration;
+ mTotalCount = 0;
+ mStartMs = 0;
+ mSampleSize.clear();
+ mAc3Header.clear();
+ flush();
+ }
+
+ /**
+ * Adds an audio sample information for monitoring.
+ *
+ * @param pts the presentation timestamp of the sample
+ * @param sampleSize the size in bytes of the sample
+ * @param header the bitrate &amp; sampling information header of the sample
+ */
+ public void addPts(long pts, int sampleSize, int header) {
+ mTotalCount++;
+ mSampleCount++;
+ mSampleSize.add(sampleSize);
+ mAc3Header.add(header);
+ mCurSampleSize.add(sampleSize);
+ if (mTotalCount == 1) {
+ mStartMs = SystemClock.elapsedRealtime();
+ }
+ if (mPtsList.isEmpty() || mPtsList.get(mPtsList.size() - 1).first != pts) {
+ mPtsList.add(Pair.create(pts, 1));
+ return;
+ }
+ Pair<Long, Integer> pair = mPtsList.get(mPtsList.size() - 1);
+ mPtsList.set(mPtsList.size() - 1, Pair.create(pair.first, pair.second + 1));
+ }
+
+ /**
+ * Logs if interested events are present.
+ * <p>
+ * Periodic logging is not enabled in release mode in order to avoid verbose logging.
+ */
+ public void maybeLog() {
+ long now = SystemClock.elapsedRealtime();
+ if (mExpireMs != 0 && now >= mExpireMs) {
+ if (DEBUG) {
+ long sampleDuration = (mTotalCount - 1) *
+ Ac3PassthroughTrackRenderer.AC3_SAMPLE_DURATION_US / 1000;
+ long totalDuration = now - mStartMs;
+ StringBuilder ptsBuilder = new StringBuilder();
+ ptsBuilder.append("PTS received ").append(mSampleCount).append(", ")
+ .append(totalDuration - sampleDuration).append(' ');
+
+ for (Pair<Long, Integer> pair : mPtsList) {
+ ptsBuilder.append('[').append(pair.first).append(':').append(pair.second)
+ .append("], ");
+ }
+ Log.d(TAG, ptsBuilder.toString());
+ }
+ if (DEBUG || mCurSampleSize.size() > 1) {
+ Log.d(TAG, "PTS received sample size: "
+ + String.valueOf(mSampleSize) + mCurSampleSize + mAc3Header);
+ }
+ flush();
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java
new file mode 100644
index 00000000..bc3c5d00
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/ac3/AudioTrackWrapper.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.ac3;
+
+import android.media.MediaFormat;
+
+import com.google.android.exoplayer.audio.AudioTrack;
+
+import java.nio.ByteBuffer;
+
+/**
+ * {@link AudioTrack} wrapper class for trickplay operations including FF/RW.
+ * FF/RW trickplay operations do not need framework {@link AudioTrack}.
+ * This wrapper class will do nothing in disabled status for those operations.
+ */
+public class AudioTrackWrapper {
+ private final AudioTrack mAudioTrack = new AudioTrack();
+ private int mAudioSessionID;
+ private boolean mIsEnabled;
+
+ AudioTrackWrapper() {
+ mIsEnabled = true;
+ }
+
+ public void resetSessionId() {
+ mAudioSessionID = AudioTrack.SESSION_ID_NOT_SET;
+ }
+
+ public boolean isInitialized() {
+ return mIsEnabled && mAudioTrack.isInitialized();
+ }
+
+ public void restart() {
+ if (mAudioTrack.isInitialized()) {
+ mAudioTrack.release();
+ }
+ mIsEnabled = true;
+ resetSessionId();
+ }
+
+ public void release() {
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.release();
+ }
+ }
+
+ public void initialize() throws AudioTrack.InitializationException {
+ if (!mIsEnabled) {
+ return;
+ }
+ if (mAudioSessionID != AudioTrack.SESSION_ID_NOT_SET) {
+ mAudioTrack.initialize(mAudioSessionID);
+ } else {
+ mAudioSessionID = mAudioTrack.initialize();
+ }
+ }
+
+ public void reset() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ }
+
+ public boolean isEnded() {
+ return !mIsEnabled || !mAudioTrack.hasPendingData();
+ }
+
+ public boolean isReady() {
+ // In the case of not playing actual audio data, Audio track is always ready.
+ return !mIsEnabled || mAudioTrack.hasPendingData();
+ }
+
+ public void play() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.play();
+ }
+
+ public void pause() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.pause();
+ }
+
+ public void setVolume(float volume) {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.setVolume(volume);
+ }
+
+ public void reconfigure(MediaFormat format) {
+ if (!mIsEnabled || format == null) {
+ return;
+ }
+ String mimeType = format.getString(MediaFormat.KEY_MIME);
+ int channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
+ int sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+ int pcmEncoding;
+ try {
+ pcmEncoding = format.getInteger(MediaFormat.KEY_PCM_ENCODING);
+ } catch (Exception e) {
+ pcmEncoding = com.google.android.exoplayer.MediaFormat.NO_VALUE;
+ }
+ // TODO: Handle non-AC3 or non-passthrough audio.
+ if (MediaFormat.MIMETYPE_AUDIO_AC3.equalsIgnoreCase(mimeType) && channelCount != 2) {
+ // Workarounds b/25955476.
+ // Since all devices and platforms does not support passthrough for non-stereo AC3,
+ // It is safe to fake non-stereo AC3 as AC3 stereo which is default passthrough mode.
+ // In other words, the channel count should be always 2.
+ channelCount = 2;
+ }
+ mAudioTrack.configure(mimeType, channelCount, sampleRate, pcmEncoding);
+ }
+
+ public void handleDiscontinuity() {
+ if (!mIsEnabled) {
+ return;
+ }
+ mAudioTrack.handleDiscontinuity();
+ }
+
+ public int handleBuffer(ByteBuffer buffer, int offset, int size, long presentationTimeUs)
+ throws AudioTrack.WriteException {
+ if (!mIsEnabled) {
+ return AudioTrack.RESULT_BUFFER_CONSUMED;
+ }
+ return mAudioTrack.handleBuffer(buffer, offset, size, presentationTimeUs);
+ }
+
+ public void setStatus(boolean enable) {
+ if (enable == mIsEnabled) {
+ return;
+ }
+ mAudioTrack.reset();
+ mIsEnabled = enable;
+ }
+
+ public boolean isEnabled() {
+ return mIsEnabled;
+ }
+
+ // This should be used only in case of being enabled.
+ public long getCurrentPositionUs(boolean isEnded) {
+ return mAudioTrack.getCurrentPositionUs(isEnded);
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
new file mode 100644
index 00000000..eb596e93
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/BufferManager.java
@@ -0,0 +1,644 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.media.MediaFormat;
+import android.os.ConditionVariable;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.util.ArrayMap;
+import android.util.Log;
+import android.util.Pair;
+
+import com.google.android.exoplayer.SampleHolder;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Manages {@link SampleChunk} objects.
+ * <p>
+ * The buffer manager can be disabled, while running, if the write throughput to the associated
+ * external storage is detected to be lower than a threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}".
+ * This leads to restarting playback flow.
+ */
+public class BufferManager {
+ private static final String TAG = "BufferManager";
+ private static final boolean DEBUG = false;
+
+ // Constants for the disk write speed checking
+ private static final long MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK =
+ 10L * 1024 * 1024; // Checks for every 10M disk write
+ private static final int MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK = 15 * 1024;
+ private static final int MAXIMUM_SPEED_CHECK_COUNT = 5; // Checks only 5 times
+ private static final int MINIMUM_DISK_WRITE_SPEED_MBPS = 3; // 3 Megabytes per second
+
+ private final SampleChunk.SampleChunkCreator mSampleChunkCreator;
+ // Maps from track name to a map which maps from starting position to {@link SampleChunk}.
+ private final Map<String, SortedMap<Long, SampleChunk>> mChunkMap = new ArrayMap<>();
+ private final Map<String, Long> mStartPositionMap = new ArrayMap<>();
+ private final Map<String, ChunkEvictedListener> mEvictListeners = new ArrayMap<>();
+ private final StorageManager mStorageManager;
+ private long mBufferSize = 0;
+ private final EvictChunkQueueMap mPendingDelete = new EvictChunkQueueMap();
+ private final SampleChunk.ChunkCallback mChunkCallback = new SampleChunk.ChunkCallback() {
+ @Override
+ public void onChunkWrite(SampleChunk chunk) {
+ mBufferSize += chunk.getSize();
+ }
+
+ @Override
+ public void onChunkDelete(SampleChunk chunk) {
+ mBufferSize -= chunk.getSize();
+ }
+ };
+
+ private volatile boolean mClosed = false;
+ private int mMinSampleSizeForSpeedCheck = MINIMUM_SAMPLE_SIZE_FOR_SPEED_CHECK;
+ private long mTotalWriteSize;
+ private long mTotalWriteTimeNs;
+ private float mWriteBandwidth = 0.0f;
+ private volatile int mSpeedCheckCount;
+ private boolean mDisabled = false;
+
+ public interface ChunkEvictedListener {
+ void onChunkEvicted(String id, long createdTimeMs);
+ }
+ /**
+ * Handles I/O
+ * between BufferManager and {@link SampleExtractor}.
+ */
+ public interface SampleBuffer {
+
+ /**
+ * Initializes SampleBuffer.
+ * @param Ids track identifiers for storage read/write.
+ * @param mediaFormats meta-data for each track.
+ * @throws IOException
+ */
+ void init(@NonNull List<String> Ids,
+ @NonNull List<com.google.android.exoplayer.MediaFormat> mediaFormats)
+ throws IOException;
+
+ /**
+ * Selects the track {@code index} for reading sample data.
+ */
+ void selectTrack(int index);
+
+ /**
+ * Deselects the track at {@code index},
+ * so that no more samples will be read from the track.
+ */
+ void deselectTrack(int index);
+
+ /**
+ * Writes sample to storage.
+ *
+ * @param index track index
+ * @param sample sample to write at storage
+ * @param conditionVariable notifies the completion of writing sample.
+ * @throws IOException
+ */
+ void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException;
+
+ /**
+ * Checks whether storage write speed is slow.
+ */
+ boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs);
+
+ /**
+ * Handles when write speed is slow.
+ * @throws IOException
+ */
+ void handleWriteSpeedSlow() throws IOException;
+
+ /**
+ * Sets the flag when EoS was reached.
+ */
+ void setEos();
+
+ /**
+ * Reads the next sample in the track at index {@code track} into {@code sampleHolder},
+ * returning {@link com.google.android.exoplayer.SampleSource#SAMPLE_READ}
+ * if it is available.
+ * If the next sample is not available,
+ * returns {@link com.google.android.exoplayer.SampleSource#NOTHING_READ}.
+ */
+ int readSample(int index, SampleHolder outSample);
+
+ /**
+ * Seeks to the specified time in microseconds.
+ */
+ void seekTo(long positionUs);
+
+ /**
+ * Returns an estimate of the position up to which data is buffered.
+ */
+ long getBufferedPositionUs();
+
+ /**
+ * Returns whether there is buffered data.
+ */
+ boolean continueBuffering(long positionUs);
+
+ /**
+ * Cleans up and releases everything.
+ * @throws IOException
+ */
+ void release() throws IOException;
+ }
+
+ /**
+ * Storage configuration and policy manager for {@link BufferManager}
+ */
+ public interface StorageManager {
+
+ /**
+ * Provides eligible storage directory for {@link BufferManager}.
+ *
+ * @return a directory to save buffer(chunks) and meta files
+ */
+ File getBufferDir();
+
+ /**
+ * Cleans up storage.
+ */
+ void clearStorage();
+
+ /**
+ * Informs whether the storage is used for persistent use. (eg. dvr recording/play)
+ *
+ * @return {@code true} if stored files are persistent
+ */
+ boolean isPersistent();
+
+ /**
+ * Informs whether the storage usage exceeds pre-determined size.
+ *
+ * @param bufferSize the current total usage of Storage in bytes.
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it reached pre-determined max size
+ */
+ boolean reachedStorageMax(long bufferSize, long pendingDelete);
+
+ /**
+ * Informs whether the storage has enough remained space.
+ *
+ * @param pendingDelete the current storage usage which will be deleted in near future by
+ * bytes
+ * @return {@code true} if it has enough space
+ */
+ boolean hasEnoughBuffer(long pendingDelete);
+
+ /**
+ * Reads track name & {@link MediaFormat} from storage.
+ *
+ * @param isAudio {@code true} if it is for audio track
+ * @return {@link Pair} of track name & {@link MediaFormat}
+ * @throws IOException
+ */
+ Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException;
+
+ /**
+ * Reads sample indexes for each written sample from storage.
+ *
+ * @param trackId track name
+ * @return indexes of the specified track
+ * @throws IOException
+ */
+ ArrayList<Long> readIndexFile(String trackId) throws IOException;
+
+ /**
+ * Writes track information to storage.
+ *
+ * @param trackId track name
+ * @param format {@link android.media.MediaFormat} of the track
+ * @param isAudio {@code true} if it is for audio track
+ * @throws IOException
+ */
+ void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio)
+ throws IOException;
+
+ /**
+ * Writes index file to storage.
+ *
+ * @param trackName track name
+ * @param index {@link SampleChunk} container
+ * @throws IOException
+ */
+ void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index)
+ throws IOException;
+ }
+
+ private static class EvictChunkQueueMap {
+ private final Map<String, LinkedList<SampleChunk>> mEvictMap = new ArrayMap<>();
+ private long mSize;
+
+ private void init(String key) {
+ mEvictMap.put(key, new LinkedList<>());
+ }
+
+ private void add(String key, SampleChunk chunk) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ mSize += chunk.getSize();
+ queue.add(chunk);
+ }
+ }
+
+ private SampleChunk poll(String key, long startPositionUs) {
+ LinkedList<SampleChunk> queue = mEvictMap.get(key);
+ if (queue != null) {
+ SampleChunk chunk = queue.peek();
+ if (chunk != null && chunk.getStartPositionUs() < startPositionUs) {
+ mSize -= chunk.getSize();
+ return queue.poll();
+ }
+ }
+ return null;
+ }
+
+ private long getSize() {
+ return mSize;
+ }
+
+ private void release() {
+ for (Map.Entry<String, LinkedList<SampleChunk>> entry : mEvictMap.entrySet()) {
+ for (SampleChunk chunk : entry.getValue()) {
+ SampleChunk.IoState.release(chunk, true);
+ }
+ }
+ mEvictMap.clear();
+ mSize = 0;
+ }
+ }
+
+ public BufferManager(StorageManager storageManager) {
+ this(storageManager, new SampleChunk.SampleChunkCreator());
+ }
+
+ public BufferManager(StorageManager storageManager,
+ SampleChunk.SampleChunkCreator sampleChunkCreator) {
+ mStorageManager = storageManager;
+ mSampleChunkCreator = sampleChunkCreator;
+ clearBuffer(true);
+ }
+
+ public void registerChunkEvictedListener(String id, ChunkEvictedListener listener) {
+ mEvictListeners.put(id, listener);
+ }
+
+ public void unregisterChunkEvictedListener(String id) {
+ mEvictListeners.remove(id);
+ }
+
+ private void clearBuffer(boolean deleteFiles) {
+ mChunkMap.clear();
+ if (deleteFiles) {
+ mStorageManager.clearStorage();
+ }
+ mBufferSize = 0;
+ }
+
+ private static String getFileName(String id, long positionUs) {
+ return String.format(Locale.ENGLISH, "%s_%016x.chunk", id, positionUs);
+ }
+
+ /**
+ * Creates a new {@link SampleChunk} for caching samples.
+ *
+ * @param id the name of the track
+ * @param positionUs starting position of the {@link SampleChunk} in micro seconds.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @return returns the created {@link SampleChunk}.
+ * @throws IOException
+ */
+ public SampleChunk createNewWriteFile(String id, long positionUs,
+ SamplePool samplePool) throws IOException {
+ if (!maybeEvictChunk()) {
+ throw new IOException("Not enough storage space");
+ }
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(id, map);
+ mStartPositionMap.put(id, positionUs);
+ mPendingDelete.init(id);
+ }
+ File file = new File(mStorageManager.getBufferDir(), getFileName(id, positionUs));
+ SampleChunk sampleChunk = mSampleChunkCreator.createSampleChunk(samplePool, file,
+ positionUs, mChunkCallback);
+ map.put(positionUs, sampleChunk);
+ return sampleChunk;
+ }
+
+ /**
+ * Loads a track using {@link BufferManager.StorageManager}.
+ *
+ * @param trackId the name of the track.
+ * @param samplePool {@link SamplePool} for the fast creation of samples.
+ * @throws IOException
+ */
+ public void loadTrackFromStorage(String trackId, SamplePool samplePool) throws IOException {
+ ArrayList<Long> keyPositions = mStorageManager.readIndexFile(trackId);
+ long startPositionUs = keyPositions.size() > 0 ? keyPositions.get(0) : 0;
+
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(trackId);
+ if (map == null) {
+ map = new TreeMap<>();
+ mChunkMap.put(trackId, map);
+ mStartPositionMap.put(trackId, startPositionUs);
+ mPendingDelete.init(trackId);
+ }
+ SampleChunk chunk = null;
+ for (long positionUs: keyPositions) {
+ chunk = mSampleChunkCreator.loadSampleChunkFromFile(samplePool,
+ mStorageManager.getBufferDir(), getFileName(trackId, positionUs), positionUs,
+ mChunkCallback, chunk);
+ map.put(positionUs, chunk);
+ }
+ }
+
+ /**
+ * Finds a {@link SampleChunk} for the specified track name and the position.
+ *
+ * @param id the name of the track.
+ * @param positionUs the position.
+ * @return returns the found {@link SampleChunk}.
+ */
+ public SampleChunk getReadFile(String id, long positionUs) {
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(id);
+ if (map == null) {
+ return null;
+ }
+ SampleChunk sampleChunk;
+ SortedMap<Long, SampleChunk> headMap = map.headMap(positionUs + 1);
+ if (!headMap.isEmpty()) {
+ sampleChunk = headMap.get(headMap.lastKey());
+ } else {
+ sampleChunk = map.get(map.firstKey());
+ }
+ return sampleChunk;
+ }
+
+ /**
+ * Evicts chunks which are ready to be evicted for the specified track
+ *
+ * @param id the specified track
+ * @param earlierThanPositionUs the start position of the {@link SampleChunk}
+ * should be earlier than
+ */
+ public void evictChunks(String id, long earlierThanPositionUs) {
+ SampleChunk chunk = null;
+ while ((chunk = mPendingDelete.poll(id, earlierThanPositionUs)) != null) {
+ SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent()) ;
+ }
+ }
+
+ /**
+ * Returns the start position of the specified track in micro seconds.
+ *
+ * @param id the specified track
+ */
+ public long getStartPositionUs(String id) {
+ Long ret = mStartPositionMap.get(id);
+ return ret == null ? 0 : ret;
+ }
+
+ private boolean maybeEvictChunk() {
+ long pendingDelete = mPendingDelete.getSize();
+ while (mStorageManager.reachedStorageMax(mBufferSize, pendingDelete)
+ || !mStorageManager.hasEnoughBuffer(pendingDelete)) {
+ if (mStorageManager.isPersistent()) {
+ // Since chunks are persistent, we cannot evict chunks.
+ return false;
+ }
+ SortedMap<Long, SampleChunk> earliestChunkMap = null;
+ SampleChunk earliestChunk = null;
+ String earliestChunkId = null;
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ SortedMap<Long, SampleChunk> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ SampleChunk chunk = map.get(map.firstKey());
+ if (earliestChunk == null
+ || chunk.getCreatedTimeMs() < earliestChunk.getCreatedTimeMs()) {
+ earliestChunkMap = map;
+ earliestChunk = chunk;
+ earliestChunkId = entry.getKey();
+ }
+ }
+ if (earliestChunk == null) {
+ break;
+ }
+ mPendingDelete.add(earliestChunkId, earliestChunk);
+ earliestChunkMap.remove(earliestChunk.getStartPositionUs());
+ if (DEBUG) {
+ Log.d(TAG, String.format("bufferSize = %d; pendingDelete = %b; "
+ + "earliestChunk size = %d; %s@%d (%s)",
+ mBufferSize, pendingDelete, earliestChunk.getSize(), earliestChunkId,
+ earliestChunk.getStartPositionUs(),
+ Utils.toIsoDateTimeString(earliestChunk.getCreatedTimeMs())));
+ }
+ ChunkEvictedListener listener = mEvictListeners.get(earliestChunkId);
+ if (listener != null) {
+ listener.onChunkEvicted(earliestChunkId, earliestChunk.getCreatedTimeMs());
+ }
+ pendingDelete = mPendingDelete.getSize();
+ }
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ SortedMap<Long, SampleChunk> map = entry.getValue();
+ if (map.isEmpty()) {
+ continue;
+ }
+ mStartPositionMap.put(entry.getKey(), map.firstKey());
+ }
+ return true;
+ }
+
+ /**
+ * Reads track information which includes {@link MediaFormat}.
+ *
+ * @return returns all track information which is found by {@link BufferManager.StorageManager}.
+ * @throws IOException
+ */
+ public ArrayList<Pair<String, MediaFormat>> readTrackInfoFiles() throws IOException {
+ ArrayList<Pair<String, MediaFormat>> trackInfos = new ArrayList<>();
+ try {
+ trackInfos.add(mStorageManager.readTrackInfoFile(false));
+ } catch (FileNotFoundException e) {
+ // There can be a single track only recording. (eg. audio-only, video-only)
+ // So the exception should not stop the read.
+ }
+ try {
+ trackInfos.add(mStorageManager.readTrackInfoFile(true));
+ } catch (FileNotFoundException e) {
+ // See above catch block.
+ }
+ return trackInfos;
+ }
+
+ /**
+ * Writes track information and index information for all tracks.
+ *
+ * @param audio audio information.
+ * @param video video information.
+ * @throws IOException
+ */
+ public void writeMetaFiles(Pair<String, MediaFormat> audio, Pair<String, MediaFormat> video)
+ throws IOException {
+ if (audio != null) {
+ mStorageManager.writeTrackInfoFile(audio.first, audio.second, true);
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(audio.first);
+ if (map == null) {
+ throw new IOException("Audio track index missing");
+ }
+ mStorageManager.writeIndexFile(audio.first, map);
+ }
+ if (video != null) {
+ mStorageManager.writeTrackInfoFile(video.first, video.second, false);
+ SortedMap<Long, SampleChunk> map = mChunkMap.get(video.first);
+ if (map == null) {
+ throw new IOException("Video track index missing");
+ }
+ mStorageManager.writeIndexFile(video.first, map);
+ }
+ }
+
+ /**
+ * Marks it is closed and it is not used anymore.
+ */
+ public void close() {
+ // Clean-up may happen after this is called.
+ mClosed = true;
+ }
+
+ /**
+ * Releases all the resources.
+ */
+ public void release() {
+ mPendingDelete.release();
+ for (Map.Entry<String, SortedMap<Long, SampleChunk>> entry : mChunkMap.entrySet()) {
+ for (SampleChunk chunk : entry.getValue().values()) {
+ SampleChunk.IoState.release(chunk, !mStorageManager.isPersistent());
+ }
+ }
+ mChunkMap.clear();
+ if (mClosed) {
+ clearBuffer(!mStorageManager.isPersistent());
+ }
+ }
+
+ private void resetWriteStat(float writeBandwidth) {
+ mWriteBandwidth = writeBandwidth;
+ mTotalWriteSize = 0;
+ mTotalWriteTimeNs = 0;
+ }
+
+ /**
+ * Adds a disk write sample size to calculate the average disk write bandwidth.
+ */
+ public void addWriteStat(long size, long timeNs) {
+ if (size >= mMinSampleSizeForSpeedCheck) {
+ mTotalWriteSize += size;
+ mTotalWriteTimeNs += timeNs;
+ }
+ }
+
+ /**
+ * Returns if the average disk write bandwidth is slower than
+ * threshold {@code MINIMUM_DISK_WRITE_SPEED_MBPS}.
+ */
+ public boolean isWriteSlow() {
+ if (mTotalWriteSize < MINIMUM_WRITE_SIZE_FOR_SPEED_CHECK) {
+ return false;
+ }
+
+ // Checks write speed for only MAXIMUM_SPEED_CHECK_COUNT times to ignore outliers
+ // by temporary system overloading during the playback.
+ if (mSpeedCheckCount > MAXIMUM_SPEED_CHECK_COUNT) {
+ return false;
+ }
+ mSpeedCheckCount++;
+ float megabytePerSecond = calculateWriteBandwidth();
+ resetWriteStat(megabytePerSecond);
+ if (DEBUG) {
+ Log.d(TAG, "Measured disk write performance: " + megabytePerSecond + "MBps");
+ }
+ return megabytePerSecond < MINIMUM_DISK_WRITE_SPEED_MBPS;
+ }
+
+ /**
+ * Returns recent write bandwidth in MBps. If recent bandwidth is not available,
+ * returns {float -1.0f}.
+ */
+ public float getWriteBandwidth() {
+ return mWriteBandwidth == 0.0f ? -1.0f : mWriteBandwidth;
+ }
+
+ private float calculateWriteBandwidth() {
+ if (mTotalWriteTimeNs == 0) {
+ return -1;
+ }
+ return ((float) mTotalWriteSize * 1000 / mTotalWriteTimeNs);
+ }
+
+ /**
+ * Marks {@link BufferManager} object disabled to prevent it from the future use.
+ */
+ public void disable() {
+ mDisabled = true;
+ }
+
+ /**
+ * Returns if {@link BufferManager} object is disabled.
+ */
+ public boolean isDisabled() {
+ return mDisabled;
+ }
+
+ /**
+ * Returns if {@link BufferManager} has checked the write speed,
+ * which is suitable for Trickplay.
+ */
+ @VisibleForTesting
+ public boolean hasSpeedCheckDone() {
+ return mSpeedCheckCount > 0;
+ }
+
+ /**
+ * Sets minimum sample size for write speed check.
+ * @param sampleSize minimum sample size for write speed check.
+ */
+ @VisibleForTesting
+ public void setMinimumSampleSizeForSpeedCheck(int sampleSize) {
+ mMinSampleSizeForSpeedCheck = sampleSize;
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
new file mode 100644
index 00000000..6a0502a7
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/DvrStorageManager.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.media.MediaFormat;
+import android.util.Pair;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.SortedMap;
+
+/**
+ * Manages DVR storage.
+ */
+public class DvrStorageManager implements BufferManager.StorageManager {
+
+ // TODO: make serializable classes and use protobuf after internal data structure is finalized.
+ private static final String KEY_PIXEL_WIDTH_HEIGHT_RATIO =
+ "com.google.android.videos.pixelWidthHeightRatio";
+ private static final String META_FILE_SUFFIX = ".meta";
+ private static final String IDX_FILE_SUFFIX = ".idx";
+
+ // Size of minimum reserved storage buffer which will be used to save meta files
+ // and index files after actual recording finished.
+ private static final long MIN_BUFFER_BYTES = 256L * 1024 * 1024;
+ private static final int NO_VALUE = -1;
+ private static final long NO_VALUE_LONG = -1L;
+
+ private final File mBufferDir;
+
+ // {@code true} when this is for recording, {@code false} when this is for replaying.
+ private final boolean mIsRecording;
+
+ public DvrStorageManager(File file, boolean isRecording) {
+ mBufferDir = file;
+ mBufferDir.mkdirs();
+ mIsRecording = isRecording;
+ }
+
+ @Override
+ public void clearStorage() {
+ if (mIsRecording) {
+ File[] files = mBufferDir.listFiles();
+ if (files != null && files.length > 0) {
+ for (File file : files) {
+ file.delete();
+ }
+ }
+ }
+ }
+
+ @Override
+ public File getBufferDir() {
+ return mBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return true;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return false;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return !mIsRecording || mBufferDir.getUsableSpace() >= MIN_BUFFER_BYTES;
+ }
+
+ private void readFormatInt(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ int val = in.readInt();
+ if (val != NO_VALUE) {
+ format.setInteger(key, val);
+ }
+ }
+
+ private void readFormatLong(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ long val = in.readLong();
+ if (val != NO_VALUE_LONG) {
+ format.setLong(key, val);
+ }
+ }
+
+ private void readFormatFloat(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ float val = in.readFloat();
+ if (val != NO_VALUE) {
+ format.setFloat(key, val);
+ }
+ }
+
+ private String readString(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte [] strBytes = new byte[len];
+ in.readFully(strBytes);
+ return new String(strBytes, StandardCharsets.UTF_8);
+ }
+
+ private void readFormatString(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ String str = readString(in);
+ if (str != null) {
+ format.setString(key, str);
+ }
+ }
+
+ private ByteBuffer readByteBuffer(DataInputStream in) throws IOException {
+ int len = in.readInt();
+ if (len <= 0) {
+ return null;
+ }
+ byte [] bytes = new byte[len];
+ in.readFully(bytes);
+ ByteBuffer buffer = ByteBuffer.allocate(len);
+ buffer.put(bytes);
+ buffer.flip();
+
+ return buffer;
+ }
+
+ private void readFormatByteBuffer(DataInputStream in, MediaFormat format, String key)
+ throws IOException {
+ ByteBuffer buffer = readByteBuffer(in);
+ if (buffer != null) {
+ format.setByteBuffer(key, buffer);
+ }
+ }
+
+ @Override
+ public Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) throws IOException {
+ File file = new File(getBufferDir(), (isAudio ? "audio" : "video") + META_FILE_SUFFIX);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ String name = readString(in);
+ MediaFormat format = new MediaFormat();
+ readFormatString(in, format, MediaFormat.KEY_MIME);
+ readFormatInt(in, format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ readFormatInt(in, format, MediaFormat.KEY_WIDTH);
+ readFormatInt(in, format, MediaFormat.KEY_HEIGHT);
+ readFormatInt(in, format, MediaFormat.KEY_CHANNEL_COUNT);
+ readFormatInt(in, format, MediaFormat.KEY_SAMPLE_RATE);
+ readFormatFloat(in, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int i = 0; i < 3; ++i) {
+ readFormatByteBuffer(in, format, "csd-" + i);
+ }
+ readFormatLong(in, format, MediaFormat.KEY_DURATION);
+ return new Pair<>(name, format);
+ }
+ }
+
+ @Override
+ public ArrayList<Long> readIndexFile(String trackId) throws IOException {
+ ArrayList<Long> indices = new ArrayList<>();
+ File file = new File(getBufferDir(), trackId + IDX_FILE_SUFFIX);
+ try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
+ long count = in.readLong();
+ for (long i = 0; i < count; ++i) {
+ indices.add(in.readLong());
+ }
+ return indices;
+ }
+ }
+
+ private void writeFormatInt(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeInt(format.getInteger(key));
+ } else {
+ out.writeInt(NO_VALUE);
+ }
+ }
+
+ private void writeFormatLong(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeLong(format.getLong(key));
+ } else {
+ out.writeLong(NO_VALUE_LONG);
+ }
+ }
+
+ private void writeFormatFloat(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ out.writeFloat(format.getFloat(key));
+ } else {
+ out.writeFloat(NO_VALUE);
+ }
+ }
+
+ private void writeString(DataOutputStream out, String str) throws IOException {
+ byte [] data = str.getBytes(StandardCharsets.UTF_8);
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ }
+ }
+
+ private void writeFormatString(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeString(out, format.getString(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeByteBuffer(DataOutputStream out, ByteBuffer buffer) throws IOException {
+ byte [] data = new byte[buffer.limit()];
+ buffer.get(data);
+ buffer.flip();
+ out.writeInt(data.length);
+ if (data.length > 0) {
+ out.write(data);
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ private void writeFormatByteBuffer(DataOutputStream out, MediaFormat format, String key)
+ throws IOException {
+ if (format.containsKey(key)) {
+ writeByteBuffer(out, format.getByteBuffer(key));
+ } else {
+ out.writeInt(0);
+ }
+ }
+
+ @Override
+ public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio)
+ throws IOException {
+ File file = new File(getBufferDir(), (isAudio ? "audio" : "video") + META_FILE_SUFFIX);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
+ writeString(out, trackId);
+ writeFormatString(out, format, MediaFormat.KEY_MIME);
+ writeFormatInt(out, format, MediaFormat.KEY_MAX_INPUT_SIZE);
+ writeFormatInt(out, format, MediaFormat.KEY_WIDTH);
+ writeFormatInt(out, format, MediaFormat.KEY_HEIGHT);
+ writeFormatInt(out, format, MediaFormat.KEY_CHANNEL_COUNT);
+ writeFormatInt(out, format, MediaFormat.KEY_SAMPLE_RATE);
+ writeFormatFloat(out, format, KEY_PIXEL_WIDTH_HEIGHT_RATIO);
+ for (int i = 0; i < 3; ++i) {
+ writeFormatByteBuffer(out, format, "csd-" + i);
+ }
+ writeFormatLong(out, format, MediaFormat.KEY_DURATION);
+ }
+ }
+
+ @Override
+ public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index)
+ throws IOException {
+ File indexFile = new File(getBufferDir(), trackName + IDX_FILE_SUFFIX);
+ try (DataOutputStream out = new DataOutputStream(new FileOutputStream(indexFile))) {
+ out.writeLong(index.size());
+ for (Long key : index.keySet()) {
+ out.writeLong(key);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
new file mode 100644
index 00000000..4869b49f
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/RecordingSampleBuffer.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.os.ConditionVariable;
+import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.google.android.exoplayer.util.Assertions;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handles I/O between {@link SampleExtractor} and
+ * {@link BufferManager}.Reads & writes samples from/to {@link SampleChunk} which is backed
+ * by physical storage.
+ */
+public class RecordingSampleBuffer implements BufferManager.SampleBuffer,
+ BufferManager.ChunkEvictedListener {
+ private static final String TAG = "RecordingSampleBuffer";
+
+ @IntDef({BUFFER_REASON_LIVE_PLAYBACK, BUFFER_REASON_RECORDED_PLAYBACK, BUFFER_REASON_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface BufferReason {}
+
+ /**
+ * A buffer reason for live-stream playback.
+ */
+ public static final int BUFFER_REASON_LIVE_PLAYBACK = 0;
+
+ /**
+ * A buffer reason for playback of a recorded program.
+ */
+ public static final int BUFFER_REASON_RECORDED_PLAYBACK = 1;
+
+ /**
+ * A buffer reason for recording a program.
+ */
+ public static final int BUFFER_REASON_RECORDING = 2;
+
+ /**
+ * The duration of a chunk of samples, {@link SampleChunk}.
+ */
+ static final long CHUNK_DURATION_US = TimeUnit.MILLISECONDS.toMicros(500);
+ private static final long BUFFER_WRITE_TIMEOUT_MS = 10 * 1000; // 10 seconds
+ private static final long BUFFER_NEEDED_US =
+ 1000L * Math.max(MpegTsPlayer.MIN_BUFFER_MS, MpegTsPlayer.MIN_REBUFFER_MS);
+
+ private final BufferManager mBufferManager;
+ private final PlaybackBufferListener mBufferListener;
+ private final @BufferReason int mBufferReason;
+
+ private int mTrackCount;
+ private boolean[] mTrackSelected;
+ private List<String> mIds;
+ private List<SampleQueue> mReadSampleQueues;
+ private final SamplePool mSamplePool = new SamplePool();
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+ private long mCurrentPlaybackPositionUs = 0;
+
+ // An error in I/O thread of {@link SampleChunkIoHelper} will be notified.
+ private volatile boolean mError;
+
+ // Eos was reached in I/O thread of {@link SampleChunkIoHelper}.
+ private volatile boolean mEos;
+ private SampleChunkIoHelper mSampleChunkIoHelper;
+ private final SampleChunkIoHelper.IoCallback mIoCallback =
+ new SampleChunkIoHelper.IoCallback() {
+ @Override
+ public void onIoReachedEos() {
+ mEos = true;
+ }
+
+ @Override
+ public void onIoError() {
+ mError = true;
+ }
+ };
+
+ /**
+ * Creates {@link BufferManager.SampleBuffer} with
+ * cached I/O backed by physical storage (e.g. trickplay,recording,recorded-playback).
+ *
+ * @param bufferManager the manager of {@link SampleChunk}
+ * @param bufferListener the listener for buffer I/O event
+ * @param enableTrickplay {@code true} when trickplay should be enabled
+ * @param bufferReason the reason for caching samples {@link RecordingSampleBuffer.BufferReason}
+ */
+ public RecordingSampleBuffer(BufferManager bufferManager, PlaybackBufferListener bufferListener,
+ boolean enableTrickplay, @BufferReason int bufferReason) {
+ mBufferManager = bufferManager;
+ mBufferListener = bufferListener;
+ if (bufferListener != null) {
+ bufferListener.onBufferStateChanged(enableTrickplay);
+ }
+ mBufferReason = bufferReason;
+ }
+
+ @Override
+ public void init(@NonNull List<String> ids, @NonNull List<MediaFormat> mediaFormats)
+ throws IOException {
+ mTrackCount = ids.size();
+ if (mTrackCount <= 0) {
+ throw new IOException("No tracks to initialize");
+ }
+ mIds = ids;
+ mTrackSelected = new boolean[mTrackCount];
+ mReadSampleQueues = new ArrayList<>();
+ mSampleChunkIoHelper = new SampleChunkIoHelper(ids, mediaFormats, mBufferReason,
+ mBufferManager, mSamplePool, mIoCallback);
+ for (int i = 0; i < mTrackCount; ++i) {
+ mReadSampleQueues.add(i, new SampleQueue(mSamplePool));
+ }
+ mSampleChunkIoHelper.init();
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ if (!mTrackSelected[index]) {
+ mTrackSelected[index] = true;
+ mReadSampleQueues.get(index).clear();
+ mBufferManager.registerChunkEvictedListener(mIds.get(index),
+ RecordingSampleBuffer.this);
+ mSampleChunkIoHelper.openRead(index, mCurrentPlaybackPositionUs);
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ if (mTrackSelected[index]) {
+ mTrackSelected[index] = false;
+ mReadSampleQueues.get(index).clear();
+ mBufferManager.unregisterChunkEvictedListener(mIds.get(index));
+ }
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample, ConditionVariable conditionVariable)
+ throws IOException {
+ mSampleChunkIoHelper.writeSample(index, sample, conditionVariable);
+
+ if (!conditionVariable.block(BUFFER_WRITE_TIMEOUT_MS)) {
+ Log.e(TAG, "Error: Serious delay on writing buffer");
+ conditionVariable.block();
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long writeDurationNs) {
+ if (mBufferReason == BUFFER_REASON_RECORDED_PLAYBACK) {
+ return false;
+ }
+ mBufferManager.addWriteStat(sampleSize, writeDurationNs);
+ return mBufferManager.isWriteSlow();
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() throws IOException{
+ if (mBufferReason == BUFFER_REASON_RECORDING) {
+ // Recording does not need to stop because I/O speed is slow temporarily.
+ // If fixed size buffer of TsStreamer overflows, TsDataSource will reach EoS.
+ // Reaching EoS will stop recording eventually.
+ Log.w(TAG, "Disk I/O speed is slow for recording temporarily: "
+ + mBufferManager.getWriteBandwidth() + "MBps");
+ return;
+ }
+ // Disables buffering samples afterwards, and notifies the disk speed is slow.
+ Log.w(TAG, "Disk is too slow for trickplay");
+ mBufferManager.disable();
+ mBufferListener.onDiskTooSlow();
+ }
+
+ @Override
+ public void setEos() {
+ mSampleChunkIoHelper.closeWrite();
+ }
+
+ private boolean maybeReadSample(SampleQueue queue, int index) {
+ if (queue.getLastQueuedPositionUs() != null
+ && queue.getLastQueuedPositionUs() > mCurrentPlaybackPositionUs + BUFFER_NEEDED_US
+ && queue.isDurationGreaterThan(CHUNK_DURATION_US)) {
+ // The speed of queuing samples can be higher than the playback speed.
+ // If the duration of the samples in the queue is not limited,
+ // samples can be accumulated and there can be out-of-memory issues.
+ // But, the throttling should provide enough samples for the player to
+ // finish the buffering state.
+ return false;
+ }
+ SampleHolder sample = mSampleChunkIoHelper.readSample(index);
+ if (sample != null) {
+ queue.queueSample(sample);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int readSample(int track, SampleHolder outSample) {
+ Assertions.checkState(mTrackSelected[track]);
+ maybeReadSample(mReadSampleQueues.get(track), track);
+ int result = mReadSampleQueues.get(track).dequeueSample(outSample);
+ if ((result != SampleSource.SAMPLE_READ && mEos) || mError) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (mTrackSelected[i]) {
+ mReadSampleQueues.get(i).clear();
+ mSampleChunkIoHelper.openRead(i, positionUs);
+ }
+ }
+ mLastBufferedPositionUs = positionUs;
+ }
+
+ @Override
+ public long getBufferedPositionUs() {
+ Long result = null;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs =
+ mReadSampleQueues.get(i).getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public boolean continueBuffering(long positionUs) {
+ mCurrentPlaybackPositionUs = positionUs;
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mTrackSelected[i]) {
+ continue;
+ }
+ SampleQueue queue = mReadSampleQueues.get(i);
+ maybeReadSample(queue, i);
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void release() throws IOException {
+ if (mTrackCount <= 0) {
+ return;
+ }
+ if (mSampleChunkIoHelper != null) {
+ mSampleChunkIoHelper.release();
+ }
+ }
+
+ // onChunkEvictedListener
+ @Override
+ public void onChunkEvicted(String id, long createdTimeMs) {
+ if (mBufferListener != null) {
+ mBufferListener.onBufferStartTimeChanged(
+ createdTimeMs + TimeUnit.MICROSECONDS.toMillis(CHUNK_DURATION_US));
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
new file mode 100644
index 00000000..552caaef
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunk.java
@@ -0,0 +1,419 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import com.google.android.exoplayer.SampleHolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+
+/**
+ * {@link SampleChunk} stores samples into file and makes them available for read.
+ * Stored file = { Header, Sample } * N
+ * Header = sample size : int, sample flag : int, sample PTS in micro second : long
+ */
+public class SampleChunk {
+ private static final String TAG = "SampleChunk";
+ private static final boolean DEBUG = false;
+
+ private final long mCreatedTimeMs;
+ private final long mStartPositionUs;
+ private SampleChunk mNextChunk;
+
+ // Header = sample size : int, sample flag : int, sample PTS in micro second : long
+ private static final int SAMPLE_HEADER_LENGTH = 16;
+
+ private final File mFile;
+ private final ChunkCallback mChunkCallback;
+ private final SamplePool mSamplePool;
+ private RandomAccessFile mAccessFile;
+ private long mWriteOffset;
+ private boolean mWriteFinished;
+ private boolean mIsReading;
+ private boolean mIsWriting;
+
+ /**
+ * A callback for chunks being committed to permanent storage.
+ */
+ public static abstract class ChunkCallback {
+
+ /**
+ * Notifies when writing a SampleChunk is completed.
+ *
+ * @param chunk SampleChunk which is written completely
+ */
+ public void onChunkWrite(SampleChunk chunk) {
+
+ }
+
+ /**
+ * Notifies when a SampleChunk is deleted.
+ *
+ * @param chunk SampleChunk which is deleted from storage
+ */
+ public void onChunkDelete(SampleChunk chunk) {
+ }
+ }
+
+ /**
+ * A class for SampleChunk creation.
+ */
+ @VisibleForTesting
+ public static class SampleChunkCreator {
+
+ /**
+ * Returns a newly created SampleChunk to read & write samples.
+ *
+ * @param samplePool sample allocator
+ * @param file filename which will be created newly
+ * @param startPositionUs the start position of the earliest sample to be stored
+ * @param chunkCallback for total storage usage change notification
+ */
+ SampleChunk createSampleChunk(SamplePool samplePool, File file,
+ long startPositionUs, ChunkCallback chunkCallback) {
+ return new SampleChunk(samplePool, file, startPositionUs, System.currentTimeMillis(),
+ chunkCallback);
+ }
+
+ /**
+ * Returns a newly created SampleChunk which is backed by an existing file.
+ * Created SampleChunk is read-only.
+ *
+ * @param samplePool sample allocator
+ * @param bufferDir the directory where the file to read is located
+ * @param filename the filename which will be read afterwards
+ * @param startPositionUs the start position of the earliest sample in the file
+ * @param chunkCallback for total storage usage change notification
+ * @param prev the previous SampleChunk just before the newly created SampleChunk
+ * @throws IOException
+ */
+ SampleChunk loadSampleChunkFromFile(SamplePool samplePool, File bufferDir,
+ String filename, long startPositionUs, ChunkCallback chunkCallback,
+ SampleChunk prev) throws IOException {
+ File file = new File(bufferDir, filename);
+ SampleChunk chunk =
+ new SampleChunk(samplePool, file, startPositionUs, chunkCallback);
+ if (prev != null) {
+ prev.mNextChunk = chunk;
+ }
+ return chunk;
+ }
+ }
+
+ /**
+ * Handles I/O for SampleChunk.
+ * Maintains current SampleChunk and the current offset for next I/O operation.
+ */
+ static class IoState {
+ private SampleChunk mChunk;
+ private long mCurrentOffset;
+
+ private boolean equals(SampleChunk chunk, long offset) {
+ return chunk == mChunk && mCurrentOffset == offset;
+ }
+
+ /**
+ * Returns whether read I/O operation is finished.
+ */
+ boolean isReadFinished() {
+ return mChunk == null;
+ }
+
+ /**
+ * Returns the start position of the current SampleChunk
+ */
+ long getStartPositionUs() {
+ return mChunk == null ? 0 : mChunk.getStartPositionUs();
+ }
+
+ private void reset(@Nullable SampleChunk chunk) {
+ mChunk = chunk;
+ mCurrentOffset = 0;
+ }
+
+ /**
+ * Prepares for read I/O operation from a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to read from
+ * @throws IOException
+ */
+ void openRead(SampleChunk chunk) throws IOException {
+ if (mChunk != null) {
+ mChunk.closeRead();
+ }
+ chunk.openRead();
+ reset(chunk);
+ }
+
+ /**
+ * Prepares for write I/O operation to a new SampleChunk.
+ *
+ * @param chunk the new SampleChunk to write samples afterwards
+ * @throws IOException
+ */
+ void openWrite(SampleChunk chunk) throws IOException{
+ if (mChunk != null) {
+ mChunk.closeWrite(chunk);
+ }
+ chunk.openWrite();
+ reset(chunk);
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @return Returns a sample if it is available, null otherwise.
+ * @throws IOException
+ */
+ SampleHolder read() throws IOException {
+ if (mChunk != null && mChunk.isReadFinished(this)) {
+ SampleChunk next = mChunk.mNextChunk;
+ mChunk.closeRead();
+ if (next != null) {
+ next.openRead();
+ }
+ reset(next);
+ }
+ if (mChunk != null) {
+ try {
+ return mChunk.read(this);
+ } catch (IllegalStateException e) {
+ // Write is finished and there is no additional buffer to read.
+ Log.w(TAG, "Tried to read sample over EOS.");
+ return null;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param sample to write
+ * @param nextChunk if this is {@code null} writes at the current SampleChunk,
+ * otherwise close current SampleChunk and writes at this
+ * @throws IOException
+ */
+ void write(SampleHolder sample, SampleChunk nextChunk)
+ throws IOException {
+ if (nextChunk != null) {
+ if (mChunk == null || mChunk.mNextChunk != null) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+ mChunk.closeWrite(nextChunk);
+ mChunk.mChunkCallback.onChunkWrite(mChunk);
+ nextChunk.openWrite();
+ reset(nextChunk);
+ }
+ mChunk.write(sample, this);
+ }
+
+ /**
+ * Finishes write I/O operation.
+ *
+ * @throws IOException
+ */
+ void closeWrite() throws IOException {
+ if (mChunk != null) {
+ mChunk.closeWrite(null);
+ }
+ }
+
+ /**
+ * Releases SampleChunk. the SampleChunk will not be used anymore.
+ *
+ * @param chunk to release
+ * @param delete {@code true} when the backed file needs to be deleted,
+ * {@code false} otherwise.
+ */
+ static void release(SampleChunk chunk, boolean delete) {
+ chunk.release(delete);
+ }
+ }
+
+ @VisibleForTesting
+ protected SampleChunk(SamplePool samplePool, File file, long startPositionUs,
+ long createdTimeMs, ChunkCallback chunkCallback) {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = createdTimeMs;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ }
+
+ // Constructor of SampleChunk which is backed by the given existing file.
+ private SampleChunk(SamplePool samplePool, File file, long startPositionUs,
+ ChunkCallback chunkCallback) throws IOException {
+ mStartPositionUs = startPositionUs;
+ mCreatedTimeMs = mStartPositionUs / 1000;
+ mSamplePool = samplePool;
+ mFile = file;
+ mChunkCallback = chunkCallback;
+ mWriteFinished = true;
+ }
+
+ private void openRead() throws IOException {
+ if (!mIsReading) {
+ if (mAccessFile == null) {
+ mAccessFile = new RandomAccessFile(mFile, "r");
+ }
+ if (mWriteFinished && mWriteOffset == 0) {
+ // Lazy loading of write offset, in order not to load
+ // all SampleChunk's write offset at start time of recorded playback.
+ mWriteOffset = mAccessFile.length();
+ }
+ mIsReading = true;
+ }
+ }
+
+ private void openWrite() throws IOException {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Opened for write though write is already finished");
+ }
+ if (!mIsWriting) {
+ if (mIsReading) {
+ throw new IllegalStateException("Write is requested for "
+ + "an already opened SampleChunk");
+ }
+ mAccessFile = new RandomAccessFile(mFile, "rw");
+ mIsWriting = true;
+ }
+ }
+
+ private void CloseAccessFileIfNeeded() throws IOException {
+ if (!mIsReading && !mIsWriting) {
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } finally {
+ mAccessFile = null;
+ }
+ }
+ }
+
+ private void closeRead() throws IOException{
+ if (mIsReading) {
+ mIsReading = false;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private void closeWrite(SampleChunk nextChunk)
+ throws IOException {
+ if (mIsWriting) {
+ mNextChunk = nextChunk;
+ mIsWriting = false;
+ mWriteFinished = true;
+ CloseAccessFileIfNeeded();
+ }
+ }
+
+ private boolean isReadFinished(IoState state) {
+ return mWriteFinished && state.equals(this, mWriteOffset);
+ }
+
+ private SampleHolder read(IoState state) throws IOException {
+ if (mAccessFile == null || state.mChunk != this) {
+ throw new IllegalStateException("Requested read for wrong SampleChunk");
+ }
+ long offset = state.mCurrentOffset;
+ if (offset >= mWriteOffset) {
+ if (mWriteFinished) {
+ throw new IllegalStateException("Requested read for wrong range");
+ } else {
+ if (offset != mWriteOffset) {
+ Log.e(TAG, "This should not happen!");
+ }
+ return null;
+ }
+ }
+ mAccessFile.seek(offset);
+ int size = mAccessFile.readInt();
+ SampleHolder sample = mSamplePool.acquireSample(size);
+ sample.size = size;
+ sample.flags = mAccessFile.readInt();
+ sample.timeUs = mAccessFile.readLong();
+ sample.clearData();
+ sample.data.put(mAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY,
+ offset + SAMPLE_HEADER_LENGTH, sample.size));
+ offset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = offset;
+ return sample;
+ }
+
+ @VisibleForTesting
+ protected void write(SampleHolder sample, IoState state)
+ throws IOException {
+ if (mAccessFile == null || mNextChunk != null || !state.equals(this, mWriteOffset)) {
+ throw new IllegalStateException("Requested write for wrong SampleChunk");
+ }
+
+ mAccessFile.seek(mWriteOffset);
+ mAccessFile.writeInt(sample.size);
+ mAccessFile.writeInt(sample.flags);
+ mAccessFile.writeLong(sample.timeUs);
+ sample.data.position(0).limit(sample.size);
+ mAccessFile.getChannel().position(mWriteOffset + SAMPLE_HEADER_LENGTH).write(sample.data);
+ mWriteOffset += sample.size + SAMPLE_HEADER_LENGTH;
+ state.mCurrentOffset = mWriteOffset;
+ }
+
+ private void release(boolean delete) {
+ mWriteFinished = true;
+ mIsReading = mIsWriting = false;
+ try {
+ if (mAccessFile != null) {
+ mAccessFile.close();
+ }
+ } catch (IOException e) {
+ // Since the SampleChunk will not be reused, ignore exception.
+ }
+ if (delete) {
+ mFile.delete();
+ mChunkCallback.onChunkDelete(this);
+ }
+ }
+
+ /**
+ * Returns the start position.
+ */
+ public long getStartPositionUs() {
+ return mStartPositionUs;
+ }
+
+ /**
+ * Returns the creation time.
+ */
+ public long getCreatedTimeMs() {
+ return mCreatedTimeMs;
+ }
+
+ /**
+ * Returns the current size.
+ */
+ public long getSize() {
+ return mWriteOffset;
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
new file mode 100644
index 00000000..37ae4022
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleChunkIoHelper.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.media.MediaCodec;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.util.Log;
+import android.util.Pair;
+
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.util.MimeTypes;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.exoplayer.buffer.RecordingSampleBuffer.BufferReason;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Handles all {@link SampleChunk} I/O operations.
+ * An I/O dedicated thread handles all I/O operations for synchronization.
+ */
+public class SampleChunkIoHelper implements Handler.Callback {
+ private static final String TAG = "SampleChunkIoHelper";
+
+ private static final int MAX_READ_BUFFER_SAMPLES = 3;
+ private static final int READ_RESCHEDULING_DELAY_MS = 10;
+
+ private static final int MSG_OPEN_READ = 1;
+ private static final int MSG_OPEN_WRITE = 2;
+ private static final int MSG_CLOSE_WRITE = 3;
+ private static final int MSG_READ = 4;
+ private static final int MSG_WRITE = 5;
+ private static final int MSG_RELEASE = 6;
+
+ private final int mTrackCount;
+ private final List<String> mIds;
+ private final List<MediaFormat> mMediaFormats;
+ private final @BufferReason int mBufferReason;
+ private final BufferManager mBufferManager;
+ private final SamplePool mSamplePool;
+ private final IoCallback mIoCallback;
+
+ private Handler mIoHandler;
+ private final ConcurrentLinkedQueue<SampleHolder> mReadSampleBuffers[];
+ private final ConcurrentLinkedQueue<SampleHolder> mHandlerReadSampleBuffers[];
+ private final long[] mWriteEndPositionUs;
+ private final SampleChunk.IoState[] mReadIoStates;
+ private final SampleChunk.IoState[] mWriteIoStates;
+ private long mBufferDurationUs = 0;
+ private boolean mWriteEnded;
+ private boolean mErrorNotified;
+ private boolean mFinished;
+
+ /**
+ * A Callback for I/O events.
+ */
+ public static abstract class IoCallback {
+
+ /**
+ * Called when there is no sample to read.
+ */
+ public void onIoReachedEos() {
+ }
+
+ /**
+ * Called when there is an irrecoverable error during I/O.
+ */
+ public void onIoError() {
+ }
+ }
+
+ private class IoParams {
+ private final int index;
+ private final long positionUs;
+ private final SampleHolder sample;
+ private final ConditionVariable conditionVariable;
+ private final ConcurrentLinkedQueue<SampleHolder> readSampleBuffer;
+
+ private IoParams(int index, long positionUs, SampleHolder sample,
+ ConditionVariable conditionVariable,
+ ConcurrentLinkedQueue<SampleHolder> readSampleBuffer) {
+ this.index = index;
+ this.positionUs = positionUs;
+ this.sample = sample;
+ this.conditionVariable = conditionVariable;
+ this.readSampleBuffer = readSampleBuffer;
+ }
+ }
+
+ /**
+ * Creates {@link SampleChunk} I/O handler.
+ *
+ * @param ids track names
+ * @param mediaFormats {@link android.media.MediaFormat} for each track
+ * @param bufferReason reason to be buffered
+ * @param bufferManager manager of {@link SampleChunk} collections
+ * @param samplePool allocator for a sample
+ * @param ioCallback listeners for I/O events
+ */
+ public SampleChunkIoHelper(List<String> ids, List<MediaFormat> mediaFormats,
+ @BufferReason int bufferReason, BufferManager bufferManager, SamplePool samplePool,
+ IoCallback ioCallback) {
+ mTrackCount = ids.size();
+ mIds = ids;
+ mMediaFormats = mediaFormats;
+ mBufferReason = bufferReason;
+ mBufferManager = bufferManager;
+ mSamplePool = samplePool;
+ mIoCallback = ioCallback;
+
+ mReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mHandlerReadSampleBuffers = new ConcurrentLinkedQueue[mTrackCount];
+ mWriteEndPositionUs = new long[mTrackCount];
+ mReadIoStates = new SampleChunk.IoState[mTrackCount];
+ mWriteIoStates = new SampleChunk.IoState[mTrackCount];
+ for (int i = 0; i < mTrackCount; ++i) {
+ mWriteEndPositionUs[i] = RecordingSampleBuffer.CHUNK_DURATION_US;
+ mReadIoStates[i] = new SampleChunk.IoState();
+ mWriteIoStates[i] = new SampleChunk.IoState();
+ }
+ }
+
+ /**
+ * Prepares and initializes for I/O operations.
+ *
+ * @throws IOException
+ */
+ public void init() throws IOException {
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mIoHandler = new Handler(handlerThread.getLooper(), this);
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDED_PLAYBACK) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.loadTrackFromStorage(mIds.get(i), mSamplePool);
+ }
+ mWriteEnded = true;
+ } else {
+ for (int i = 0; i < mTrackCount; ++i) {
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_WRITE, i));
+ }
+ }
+ }
+
+ /**
+ * Reads a sample if it is available.
+ *
+ * @param index track index
+ * @return {@code null} if a sample is not available, otherwise returns a sample
+ */
+ public SampleHolder readSample(int index) {
+ SampleHolder sample = mReadSampleBuffers[index].poll();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ return sample;
+ }
+
+ /**
+ * Writes a sample.
+ *
+ * @param index track index
+ * @param sample to write
+ * @param conditionVariable which will be wait until the write is finished
+ * @throws IOException
+ */
+ public void writeSample(int index, SampleHolder sample,
+ ConditionVariable conditionVariable) throws IOException {
+ if (mErrorNotified) {
+ throw new IOException("Storage I/O error happened");
+ }
+ conditionVariable.close();
+ IoParams params = new IoParams(index, 0, sample, conditionVariable, null);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_WRITE, params));
+ }
+
+ /**
+ * Starts read from the specified position.
+ *
+ * @param index track index
+ * @param positionUs the specified position
+ */
+ public void openRead(int index, long positionUs) {
+ // Old mReadSampleBuffers may have a pending read.
+ mReadSampleBuffers[index] = new ConcurrentLinkedQueue<>();
+ IoParams params = new IoParams(index, positionUs, null, null, mReadSampleBuffers[index]);
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_OPEN_READ, params));
+ }
+
+ /**
+ * Notifies writes are finished.
+ */
+ public void closeWrite() {
+ mIoHandler.sendEmptyMessage(MSG_CLOSE_WRITE);
+ }
+
+ /**
+ * Finishes I/O operations and releases all the resources.
+ * @throws IOException
+ */
+ public void release() throws IOException {
+ if (mIoHandler == null) {
+ return;
+ }
+ // Finishes all I/O operations.
+ ConditionVariable conditionVariable = new ConditionVariable();
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_RELEASE, conditionVariable));
+ conditionVariable.block();
+
+ for (int i = 0; i < mTrackCount; ++i) {
+ mBufferManager.unregisterChunkEvictedListener(mIds.get(i));
+ }
+ try {
+ if (mBufferReason == RecordingSampleBuffer.BUFFER_REASON_RECORDING && mTrackCount > 0) {
+ // Saves meta information for recording.
+ Pair<String, android.media.MediaFormat> audio = null, video = null;
+ for (int i = 0; i < mTrackCount; ++i) {
+ android.media.MediaFormat format =
+ mMediaFormats.get(i).getFrameworkMediaFormatV16();
+ format.setLong(android.media.MediaFormat.KEY_DURATION, mBufferDurationUs);
+ if (audio == null && MimeTypes.isAudio(mMediaFormats.get(i).mimeType)) {
+ audio = new Pair<>(mIds.get(i), format);
+ } else if (video == null && MimeTypes.isVideo(mMediaFormats.get(i).mimeType)) {
+ video = new Pair<>(mIds.get(i), format);
+ }
+ if (audio != null && video != null) {
+ break;
+ }
+ }
+ mBufferManager.writeMetaFiles(audio, video);
+ }
+ } finally {
+ mBufferManager.release();
+ mIoHandler.getLooper().quitSafely();
+ }
+ }
+
+ @Override
+ public boolean handleMessage(Message message) {
+ if (mFinished) {
+ return true;
+ }
+ releaseEvictedChunks();
+ try {
+ switch (message.what) {
+ case MSG_OPEN_READ:
+ doOpenRead((IoParams) message.obj);
+ return true;
+ case MSG_OPEN_WRITE:
+ doOpenWrite((int) message.obj);
+ return true;
+ case MSG_CLOSE_WRITE:
+ doCloseWrite();
+ return true;
+ case MSG_READ:
+ doRead((int) message.obj);
+ return true;
+ case MSG_WRITE:
+ doWrite((IoParams) message.obj);
+ // Since only write will increase storage, eviction will be handled here.
+ return true;
+ case MSG_RELEASE:
+ doRelease((ConditionVariable) message.obj);
+ return true;
+ }
+ } catch (IOException e) {
+ mIoCallback.onIoError();
+ mErrorNotified = true;
+ Log.e(TAG, "IoException happened", e);
+ return true;
+ }
+ return false;
+ }
+
+ private void doOpenRead(IoParams params) throws IOException {
+ int index = params.index;
+ mIoHandler.removeMessages(MSG_READ, index);
+ SampleChunk chunk = mBufferManager.getReadFile(mIds.get(index), params.positionUs);
+ if (chunk == null) {
+ String errorMessage = "Chunk ID:" + mIds.get(index) + " pos:" + params.positionUs
+ + "is not found";
+ SoftPreconditions.checkNotNull(chunk, TAG, errorMessage);
+ throw new IOException(errorMessage);
+ }
+ mReadIoStates[index].openRead(chunk);
+ if (mHandlerReadSampleBuffers[index] != null) {
+ SampleHolder sample;
+ while ((sample = mHandlerReadSampleBuffers[index].poll()) != null) {
+ mSamplePool.releaseSample(sample);
+ }
+ }
+ mHandlerReadSampleBuffers[index] = params.readSampleBuffer;
+ mIoHandler.sendMessage(mIoHandler.obtainMessage(MSG_READ, index));
+ }
+
+ private void doOpenWrite(int index) throws IOException {
+ SampleChunk chunk = mBufferManager.createNewWriteFile(mIds.get(index), 0, mSamplePool);
+ mWriteIoStates[index].openWrite(chunk);
+ }
+
+ private void doRead(int index) throws IOException {
+ mIoHandler.removeMessages(MSG_READ, index);
+ if (mHandlerReadSampleBuffers[index].size() >= MAX_READ_BUFFER_SAMPLES) {
+ // If enough samples are buffered, try again few moments later hoping that
+ // buffered samples are consumed.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index), READ_RESCHEDULING_DELAY_MS);
+ } else {
+ if (mReadIoStates[index].isReadFinished()) {
+ for (int i = 0; i < mTrackCount; ++i) {
+ if (!mReadIoStates[i].isReadFinished()) {
+ return;
+ }
+ }
+ mIoCallback.onIoReachedEos();
+ return;
+ }
+ SampleHolder sample = mReadIoStates[index].read();
+ if (sample != null) {
+ mHandlerReadSampleBuffers[index].offer(sample);
+ } else {
+ // Read reached write but write is not finished yet --- wait a few moments to
+ // see if another sample is written.
+ mIoHandler.sendMessageDelayed(
+ mIoHandler.obtainMessage(MSG_READ, index),
+ READ_RESCHEDULING_DELAY_MS);
+ }
+ }
+ }
+
+ private void doWrite(IoParams params) throws IOException {
+ try {
+ if (mWriteEnded) {
+ SoftPreconditions.checkState(false);
+ return;
+ }
+ int index = params.index;
+ SampleHolder sample = params.sample;
+ SampleChunk nextChunk = null;
+ if ((sample.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ if (sample.timeUs > mBufferDurationUs) {
+ mBufferDurationUs = sample.timeUs;
+ }
+
+ if (sample.timeUs >= mWriteEndPositionUs[index]) {
+ nextChunk = mBufferManager.createNewWriteFile(mIds.get(index),
+ mWriteEndPositionUs[index], mSamplePool);
+ mWriteEndPositionUs[index] =
+ ((sample.timeUs / RecordingSampleBuffer.CHUNK_DURATION_US) + 1) *
+ RecordingSampleBuffer.CHUNK_DURATION_US;
+ }
+ }
+ mWriteIoStates[params.index].write(params.sample, nextChunk);
+ } finally {
+ params.conditionVariable.open();
+ }
+ }
+
+ private void doCloseWrite() throws IOException {
+ if (mWriteEnded) {
+ return;
+ }
+ mWriteEnded = true;
+ boolean readFinished = true;
+ for (int i = 0; i < mTrackCount; ++i) {
+ readFinished = readFinished && mReadIoStates[i].isReadFinished();
+ mWriteIoStates[i].closeWrite();
+ }
+ if (readFinished) {
+ mIoCallback.onIoReachedEos();
+ }
+ }
+
+ private void doRelease(ConditionVariable conditionVariable) {
+ mIoHandler.removeCallbacksAndMessages(null);
+ mFinished = true;
+ conditionVariable.open();
+ }
+
+ private void releaseEvictedChunks() {
+ if (mBufferReason != RecordingSampleBuffer.BUFFER_REASON_LIVE_PLAYBACK) {
+ return;
+ }
+ for (int i = 0; i < mTrackCount; ++i) {
+ long evictEndPositionUs = Math.min(mBufferManager.getStartPositionUs(mIds.get(i)),
+ mReadIoStates[i].getStartPositionUs());
+ mBufferManager.evictChunks(mIds.get(i), evictEndPositionUs);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java b/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
new file mode 100644
index 00000000..bb048e85
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/SamplePool.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import com.google.android.exoplayer.SampleHolder;
+
+import java.util.LinkedList;
+
+/**
+ * Pool of samples to recycle ByteBuffers as much as possible.
+ */
+public class SamplePool {
+ private final LinkedList<SampleHolder> mSamplePool = new LinkedList<>();
+
+ /**
+ * Acquires a sample with a buffer larger than size from the pool. Allocate new one or resize
+ * an existing buffer if necessary.
+ */
+ public synchronized SampleHolder acquireSample(int size) {
+ if (mSamplePool.isEmpty()) {
+ SampleHolder sample = new SampleHolder(SampleHolder.BUFFER_REPLACEMENT_MODE_NORMAL);
+ sample.ensureSpaceForWrite(size);
+ return sample;
+ }
+ SampleHolder smallestSufficientSample = null;
+ SampleHolder maxSample = mSamplePool.getFirst();
+ for (SampleHolder sample : mSamplePool) {
+ // Grab the smallest sufficient sample.
+ if (sample.data.capacity() >= size && (smallestSufficientSample == null
+ || smallestSufficientSample.data.capacity() > sample.data.capacity())) {
+ smallestSufficientSample = sample;
+ }
+
+ // Grab the max size sample.
+ if (maxSample.data.capacity() < sample.data.capacity()) {
+ maxSample = sample;
+ }
+ }
+ SampleHolder sampleFromPool = smallestSufficientSample;
+
+ // If there's no sufficient sample, grab the maximum sample and resize it to size.
+ if (sampleFromPool == null) {
+ sampleFromPool = maxSample;
+ sampleFromPool.ensureSpaceForWrite(size);
+ }
+ mSamplePool.remove(sampleFromPool);
+ return sampleFromPool;
+ }
+
+ /**
+ * Releases the sample back to the pool.
+ */
+ public synchronized void releaseSample(SampleHolder sample) {
+ sample.clearData();
+ mSamplePool.offerLast(sample);
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
new file mode 100644
index 00000000..7b098f40
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/SampleQueue.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+
+import java.util.LinkedList;
+
+/**
+ * A sample queue which reads from the buffer and passes to player pipeline.
+ */
+public class SampleQueue {
+ private final LinkedList<SampleHolder> mQueue = new LinkedList<>();
+ private final SamplePool mSamplePool;
+ private Long mLastQueuedPositionUs = null;
+
+ public SampleQueue(SamplePool samplePool) {
+ mSamplePool = samplePool;
+ }
+
+ public void queueSample(SampleHolder sample) {
+ mQueue.offer(sample);
+ mLastQueuedPositionUs = sample.timeUs;
+ }
+
+ public int dequeueSample(SampleHolder sample) {
+ SampleHolder sampleFromQueue = mQueue.poll();
+ if (sampleFromQueue == null) {
+ return SampleSource.NOTHING_READ;
+ }
+ sample.size = sampleFromQueue.size;
+ sample.flags = sampleFromQueue.flags;
+ sample.timeUs = sampleFromQueue.timeUs;
+ sample.clearData();
+ sampleFromQueue.data.position(0).limit(sample.size);
+ sample.data.put(sampleFromQueue.data);
+ mSamplePool.releaseSample(sampleFromQueue);
+ return SampleSource.SAMPLE_READ;
+ }
+
+ public void clear() {
+ while (!mQueue.isEmpty()) {
+ mSamplePool.releaseSample(mQueue.poll());
+ }
+ mLastQueuedPositionUs = null;
+ }
+
+ public Long getLastQueuedPositionUs() {
+ return mLastQueuedPositionUs;
+ }
+
+ public boolean isDurationGreaterThan(long durationUs) {
+ return !mQueue.isEmpty() && mQueue.getLast().timeUs - mQueue.getFirst().timeUs > durationUs;
+ }
+
+ public boolean isEmpty() {
+ return mQueue.isEmpty();
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
new file mode 100644
index 00000000..40c4ef95
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/SimpleSampleBuffer.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.os.ConditionVariable;
+
+import android.support.annotation.NonNull;
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.MediaFormat;
+import com.google.android.exoplayer.SampleHolder;
+import com.google.android.exoplayer.SampleSource;
+import com.android.tv.tuner.tvinput.PlaybackBufferListener;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+
+import java.io.IOException;
+import java.util.List;
+
+import junit.framework.Assert;
+
+/**
+ * Handles I/O for {@link SampleExtractor} when
+ * physical storage based buffer is not used. Trickplay is disabled.
+ */
+public class SimpleSampleBuffer implements BufferManager.SampleBuffer {
+ private final SamplePool mSamplePool = new SamplePool();
+ private SampleQueue[] mPlayingSampleQueues;
+ private long mLastBufferedPositionUs = C.UNKNOWN_TIME_US;
+
+ private volatile boolean mEos;
+
+ public SimpleSampleBuffer(PlaybackBufferListener bufferListener) {
+ if (bufferListener != null) {
+ // Disables trickplay.
+ bufferListener.onBufferStateChanged(false);
+ }
+ }
+
+ @Override
+ public synchronized void init(@NonNull List<String> ids,
+ @NonNull List<MediaFormat> mediaFormats) {
+ int trackCount = ids.size();
+ mPlayingSampleQueues = new SampleQueue[trackCount];
+ for (int i = 0; i < trackCount; i++) {
+ mPlayingSampleQueues[i] = null;
+ }
+ }
+
+ @Override
+ public void setEos() {
+ mEos = true;
+ }
+
+ private boolean reachedEos() {
+ return mEos;
+ }
+
+ @Override
+ public void selectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] == null) {
+ mPlayingSampleQueues[index] = new SampleQueue(mSamplePool);
+ } else {
+ mPlayingSampleQueues[index].clear();
+ }
+ }
+ }
+
+ @Override
+ public void deselectTrack(int index) {
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].clear();
+ mPlayingSampleQueues[index] = null;
+ }
+ }
+ }
+
+ @Override
+ public synchronized long getBufferedPositionUs() {
+ Long result = null;
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ Long lastQueuedSamplePositionUs = queue.getLastQueuedPositionUs();
+ if (lastQueuedSamplePositionUs == null) {
+ // No sample has been queued.
+ result = mLastBufferedPositionUs;
+ continue;
+ }
+ if (result == null || result > lastQueuedSamplePositionUs) {
+ result = lastQueuedSamplePositionUs;
+ }
+ }
+ if (result == null) {
+ return mLastBufferedPositionUs;
+ }
+ return (mLastBufferedPositionUs = result);
+ }
+
+ @Override
+ public synchronized int readSample(int track, SampleHolder sampleHolder) {
+ SampleQueue queue = mPlayingSampleQueues[track];
+ Assert.assertNotNull(queue);
+ int result = queue.dequeueSample(sampleHolder);
+ if (result != SampleSource.SAMPLE_READ && reachedEos()) {
+ return SampleSource.END_OF_STREAM;
+ }
+ return result;
+ }
+
+ @Override
+ public void writeSample(int index, SampleHolder sample,
+ ConditionVariable conditionVariable) throws IOException {
+ sample.data.position(0).limit(sample.size);
+ SampleHolder sampleToQueue = mSamplePool.acquireSample(sample.size);
+ sampleToQueue.size = sample.size;
+ sampleToQueue.clearData();
+ sampleToQueue.data.put(sample.data);
+ sampleToQueue.timeUs = sample.timeUs;
+ sampleToQueue.flags = sample.flags;
+
+ synchronized (this) {
+ if (mPlayingSampleQueues[index] != null) {
+ mPlayingSampleQueues[index].queueSample(sampleToQueue);
+ }
+ }
+ }
+
+ @Override
+ public boolean isWriteSpeedSlow(int sampleSize, long durationNs) {
+ // Since SimpleSampleBuffer write samples only to memory (not to physical storage),
+ // write speed is always fine.
+ return false;
+ }
+
+ @Override
+ public void handleWriteSpeedSlow() {
+ // no-op
+ }
+
+ @Override
+ public synchronized boolean continueBuffering(long positionUs) {
+ for (SampleQueue queue : mPlayingSampleQueues) {
+ if (queue == null) {
+ continue;
+ }
+ if (queue.getLastQueuedPositionUs() == null
+ || positionUs > queue.getLastQueuedPositionUs()) {
+ // No more buffered data.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void seekTo(long positionUs) {
+ // Not used.
+ }
+
+ @Override
+ public void release() {
+ // Not used.
+ }
+}
diff --git a/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
new file mode 100644
index 00000000..258a5cd0
--- /dev/null
+++ b/src/com/android/tv/tuner/exoplayer/buffer/TrickplayStorageManager.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.exoplayer.buffer;
+
+import android.content.Context;
+import android.media.MediaFormat;
+import android.os.AsyncTask;
+import android.os.Looper;
+import android.provider.Settings;
+import android.util.Pair;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.SortedMap;
+
+/**
+ * Manages Trickplay storage.
+ */
+public class TrickplayStorageManager implements BufferManager.StorageManager {
+ private static final String BUFFER_DIR = "timeshift";
+
+ // Copied from android.provider.Settings.Global (hidden fields)
+ private static final String
+ SYS_STORAGE_THRESHOLD_PERCENTAGE = "sys_storage_threshold_percentage";
+ private static final String
+ SYS_STORAGE_THRESHOLD_MAX_BYTES = "sys_storage_threshold_max_bytes";
+
+ // Copied from android.os.StorageManager
+ private static final int DEFAULT_THRESHOLD_PERCENTAGE = 10;
+ private static final long DEFAULT_THRESHOLD_MAX_BYTES = 500L * 1024 * 1024;
+
+ private final File mBufferDir;
+ private final long mMaxBufferSize;
+ private final long mStorageBufferBytes;
+
+ private static long getStorageBufferBytes(Context context, File path) {
+ long lowPercentage = Settings.Global.getInt(context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_PERCENTAGE, DEFAULT_THRESHOLD_PERCENTAGE);
+ long lowBytes = path.getTotalSpace() * lowPercentage / 100;
+ long maxLowBytes = Settings.Global.getLong(context.getContentResolver(),
+ SYS_STORAGE_THRESHOLD_MAX_BYTES, DEFAULT_THRESHOLD_MAX_BYTES);
+ return Math.min(lowBytes, maxLowBytes);
+ }
+
+ public TrickplayStorageManager(Context context, File baseDir, long maxBufferSize) {
+ mBufferDir = new File(baseDir, BUFFER_DIR);
+ mBufferDir.mkdirs();
+ mMaxBufferSize = maxBufferSize;
+ clearStorage();
+ mStorageBufferBytes = getStorageBufferBytes(context, mBufferDir);
+ }
+
+ @Override
+ public void clearStorage() {
+ File files[] = mBufferDir.listFiles();
+ if (files == null || files.length == 0) {
+ return;
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected Void doInBackground(Void... params) {
+ for (File file : files) {
+ file.delete();
+ }
+ return null;
+ }
+ }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ for (File file : files) {
+ file.delete();
+ }
+ }
+ }
+
+ @Override
+ public File getBufferDir() {
+ return mBufferDir;
+ }
+
+ @Override
+ public boolean isPersistent() {
+ return false;
+ }
+
+ @Override
+ public boolean reachedStorageMax(long bufferSize, long pendingDelete) {
+ return bufferSize - pendingDelete > mMaxBufferSize;
+ }
+
+ @Override
+ public boolean hasEnoughBuffer(long pendingDelete) {
+ return mBufferDir.getUsableSpace() + pendingDelete >= mStorageBufferBytes;
+ }
+
+ @Override
+ public Pair<String, MediaFormat> readTrackInfoFile(boolean isAudio) {
+ return null;
+ }
+
+ @Override
+ public ArrayList<Long> readIndexFile(String trackId) {
+ return null;
+ }
+
+ @Override
+ public void writeTrackInfoFile(String trackId, MediaFormat format, boolean isAudio) {
+ }
+
+ @Override
+ public void writeIndexFile(String trackName, SortedMap<Long, SampleChunk> index) {
+ }
+
+}
diff --git a/src/com/android/tv/tuner/layout/ScaledLayout.java b/src/com/android/tv/tuner/layout/ScaledLayout.java
new file mode 100644
index 00000000..379ea70e
--- /dev/null
+++ b/src/com/android/tv/tuner/layout/ScaledLayout.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.layout;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.hardware.display.DisplayManager;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.Display;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.tuner.R;
+
+import java.util.Arrays;
+import java.util.Comparator;
+
+/**
+ * A layout that scales its children using the given percentage value.
+ */
+public class ScaledLayout extends ViewGroup {
+ private static final String TAG = "ScaledLayout";
+ private static final boolean DEBUG = false;
+ private static final Comparator<Rect> mRectTopLeftSorter = new Comparator<Rect>() {
+ @Override
+ public int compare(Rect lhs, Rect rhs) {
+ if (lhs.top != rhs.top) {
+ return lhs.top - rhs.top;
+ } else {
+ return lhs.left - rhs.left;
+ }
+ }
+ };
+
+ private Rect[] mRectArray;
+ private final int mMaxWidth;
+ private final int mMaxHeight;
+
+ public ScaledLayout(Context context) {
+ this(context, null);
+ }
+
+ public ScaledLayout(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public ScaledLayout(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ Point size = new Point();
+ DisplayManager displayManager = (DisplayManager) getContext()
+ .getSystemService(Context.DISPLAY_SERVICE);
+ Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+ display.getRealSize(size);
+ mMaxWidth = size.x;
+ mMaxHeight = size.y;
+ }
+
+ /**
+ * ScaledLayoutParams stores the four scale factors.
+ * <br>
+ * Vertical coordinate system: ({@code scaleStartRow} * 100) % ~ ({@code scaleEndRow} * 100) %
+ * Horizontal coordinate system: ({@code scaleStartCol} * 100) % ~ ({@code scaleEndCol} * 100) %
+ * <br>
+ * In XML, for example,
+ * <pre>
+ * {@code
+ * <View
+ * app:layout_scaleStartRow="0.1"
+ * app:layout_scaleEndRow="0.5"
+ * app:layout_scaleStartCol="0.4"
+ * app:layout_scaleEndCol="1" />
+ * }
+ * </pre>
+ */
+ public static class ScaledLayoutParams extends ViewGroup.LayoutParams {
+ public static final float SCALE_UNSPECIFIED = -1;
+ public final float scaleStartRow;
+ public final float scaleEndRow;
+ public final float scaleStartCol;
+ public final float scaleEndCol;
+
+ public ScaledLayoutParams(float scaleStartRow, float scaleEndRow,
+ float scaleStartCol, float scaleEndCol) {
+ super(MATCH_PARENT, MATCH_PARENT);
+ this.scaleStartRow = scaleStartRow;
+ this.scaleEndRow = scaleEndRow;
+ this.scaleStartCol = scaleStartCol;
+ this.scaleEndCol = scaleEndCol;
+ }
+
+ public ScaledLayoutParams(Context context, AttributeSet attrs) {
+ super(MATCH_PARENT, MATCH_PARENT);
+ TypedArray array =
+ context.obtainStyledAttributes(attrs, R.styleable.utScaledLayout);
+ scaleStartRow =
+ array.getFloat(R.styleable.utScaledLayout_layout_scaleStartRow, SCALE_UNSPECIFIED);
+ scaleEndRow =
+ array.getFloat(R.styleable.utScaledLayout_layout_scaleEndRow, SCALE_UNSPECIFIED);
+ scaleStartCol =
+ array.getFloat(R.styleable.utScaledLayout_layout_scaleStartCol, SCALE_UNSPECIFIED);
+ scaleEndCol =
+ array.getFloat(R.styleable.utScaledLayout_layout_scaleEndCol, SCALE_UNSPECIFIED);
+ array.recycle();
+ }
+ }
+
+ @Override
+ public LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new ScaledLayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected boolean checkLayoutParams(LayoutParams p) {
+ return (p instanceof ScaledLayoutParams);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
+ int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
+ int width = widthSpecSize - getPaddingLeft() - getPaddingRight();
+ int height = heightSpecSize - getPaddingTop() - getPaddingBottom();
+ if (DEBUG) {
+ Log.d(TAG, String.format("onMeasure width: %d, height: %d", width, height));
+ }
+ int count = getChildCount();
+ mRectArray = new Rect[count];
+ for (int i = 0; i < count; ++i) {
+ View child = getChildAt(i);
+ ViewGroup.LayoutParams params = child.getLayoutParams();
+ float scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol;
+ if (!(params instanceof ScaledLayoutParams)) {
+ throw new RuntimeException(
+ "A child of ScaledLayout cannot have the UNSPECIFIED scale factors");
+ }
+ scaleStartRow = ((ScaledLayoutParams) params).scaleStartRow;
+ scaleEndRow = ((ScaledLayoutParams) params).scaleEndRow;
+ scaleStartCol = ((ScaledLayoutParams) params).scaleStartCol;
+ scaleEndCol = ((ScaledLayoutParams) params).scaleEndCol;
+ if (scaleStartRow < 0 || scaleStartRow > 1) {
+ throw new RuntimeException("A child of ScaledLayout should have a range of "
+ + "scaleStartRow between 0 and 1");
+ }
+ if (scaleEndRow < scaleStartRow || scaleStartRow > 1) {
+ throw new RuntimeException("A child of ScaledLayout should have a range of "
+ + "scaleEndRow between scaleStartRow and 1");
+ }
+ if (scaleEndCol < 0 || scaleEndCol > 1) {
+ throw new RuntimeException("A child of ScaledLayout should have a range of "
+ + "scaleStartCol between 0 and 1");
+ }
+ if (scaleEndCol < scaleStartCol || scaleEndCol > 1) {
+ throw new RuntimeException("A child of ScaledLayout should have a range of "
+ + "scaleEndCol between scaleStartCol and 1");
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("onMeasure child scaleStartRow: %f scaleEndRow: %f "
+ + "scaleStartCol: %f scaleEndCol: %f",
+ scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
+ }
+ mRectArray[i] = new Rect((int) (scaleStartCol * width), (int) (scaleStartRow * height),
+ (int) (scaleEndCol * width), (int) (scaleEndRow * height));
+ int scaleWidth = (int) (width * (scaleEndCol - scaleStartCol));
+ int childWidthSpec = MeasureSpec.makeMeasureSpec(
+ scaleWidth > mMaxWidth ? mMaxWidth : scaleWidth, MeasureSpec.EXACTLY);
+ int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ child.measure(childWidthSpec, childHeightSpec);
+
+ // If the height of the measured child view is bigger than the height of the calculated
+ // region by the given ScaleLayoutParams, the height of the region should be increased
+ // to fit the size of the child view.
+ if (child.getMeasuredHeight() > mRectArray[i].height()) {
+ int overflowedHeight = child.getMeasuredHeight() - mRectArray[i].height();
+ overflowedHeight = (overflowedHeight + 1) / 2;
+ mRectArray[i].bottom += overflowedHeight;
+ mRectArray[i].top -= overflowedHeight;
+ if (mRectArray[i].top < 0) {
+ mRectArray[i].bottom -= mRectArray[i].top;
+ mRectArray[i].top = 0;
+ }
+ if (mRectArray[i].bottom > height) {
+ mRectArray[i].top -= mRectArray[i].bottom - height;
+ mRectArray[i].bottom = height;
+ }
+ }
+ int scaleHeight = (int) (height * (scaleEndRow - scaleStartRow));
+ childHeightSpec = MeasureSpec.makeMeasureSpec(
+ scaleHeight > mMaxHeight ? mMaxHeight : scaleHeight, MeasureSpec.EXACTLY);
+ child.measure(childWidthSpec, childHeightSpec);
+ }
+
+ // Avoid overlapping rectangles.
+ // Step 1. Sort rectangles by position (top-left).
+ int visibleRectCount = 0;
+ int[] visibleRectGroup = new int[count];
+ Rect[] visibleRectArray = new Rect[count];
+ for (int i = 0; i < count; ++i) {
+ if (getChildAt(i).getVisibility() == View.VISIBLE) {
+ visibleRectGroup[visibleRectCount] = visibleRectCount;
+ visibleRectArray[visibleRectCount] = mRectArray[i];
+ ++visibleRectCount;
+ }
+ }
+ Arrays.sort(visibleRectArray, 0, visibleRectCount, mRectTopLeftSorter);
+
+ // Step 2. Move down if there are overlapping rectangles.
+ for (int i = 0; i < visibleRectCount - 1; ++i) {
+ for (int j = i + 1; j < visibleRectCount; ++j) {
+ if (Rect.intersects(visibleRectArray[i], visibleRectArray[j])) {
+ visibleRectGroup[j] = visibleRectGroup[i];
+ visibleRectArray[j].set(visibleRectArray[j].left,
+ visibleRectArray[i].bottom,
+ visibleRectArray[j].right,
+ visibleRectArray[i].bottom + visibleRectArray[j].height());
+ }
+ }
+ }
+
+ // Step 3. Move up if there is any overflowed rectangle.
+ for (int i = visibleRectCount - 1; i >= 0; --i) {
+ if (visibleRectArray[i].bottom > height) {
+ int overflowedHeight = visibleRectArray[i].bottom - height;
+ for (int j = 0; j <= i; ++j) {
+ if (visibleRectGroup[i] == visibleRectGroup[j]) {
+ visibleRectArray[j].set(visibleRectArray[j].left,
+ visibleRectArray[j].top - overflowedHeight,
+ visibleRectArray[j].right,
+ visibleRectArray[j].bottom - overflowedHeight);
+ }
+ }
+ }
+ }
+ setMeasuredDimension(widthSpecSize, heightSpecSize);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int l, int t, int r, int b) {
+ int paddingLeft = getPaddingLeft();
+ int paddingTop = getPaddingTop();
+ int count = getChildCount();
+ for (int i = 0; i < count; ++i) {
+ View child = getChildAt(i);
+ if (child.getVisibility() != GONE) {
+ int childLeft = paddingLeft + mRectArray[i].left;
+ int childTop = paddingTop + mRectArray[i].top;
+ int childBottom = paddingLeft + mRectArray[i].bottom;
+ int childRight = paddingTop + mRectArray[i].right;
+ if (DEBUG) {
+ Log.d(TAG, String.format("layoutChild bottom: %d left: %d right: %d top: %d",
+ childBottom, childLeft,
+ childRight, childTop));
+ }
+ child.layout(childLeft, childTop, childRight, childBottom);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java
new file mode 100644
index 00000000..97d9ece3
--- /dev/null
+++ b/src/com/android/tv/tuner/setup/ConnectionTypeFragment.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.setup;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+
+import java.util.List;
+import java.util.TimeZone;
+
+/**
+ * A fragment for connection type selection.
+ */
+public class ConnectionTypeFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY =
+ "com.android.tv.tuner.setup.ConnectionTypeFragment";
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ return new ContentFragment();
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ public static class ContentFragment extends SetupGuidedStepFragment {
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ return new Guidance(getString(R.string.ut_connection_title),
+ getString(R.string.ut_connection_description),
+ getString(R.string.ut_setup_breadcrumb), null);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions,
+ Bundle savedInstanceState) {
+ String[] choices = getResources().getStringArray(R.array.ut_connection_choices);
+ int length = choices.length - 1;
+ int startOffset = 0;
+ for (int i = 0; i < length; ++i) {
+ actions.add(new GuidedAction.Builder(getActivity())
+ .id(startOffset + i)
+ .title(choices[i])
+ .build());
+ }
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/setup/ScanFragment.java b/src/com/android/tv/tuner/setup/ScanFragment.java
new file mode 100644
index 00000000..4b3ffe40
--- /dev/null
+++ b/src/com/android/tv/tuner/setup/ScanFragment.java
@@ -0,0 +1,505 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.setup;
+
+import android.animation.LayoutTransition;
+import android.app.Activity;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.Button;
+import android.widget.ListView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.android.tv.common.AutoCloseableUtils;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.ui.setup.SetupFragment;
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.source.FileTsStreamer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsStreamer;
+import com.android.tv.tuner.source.TunerTsStreamer;
+import com.android.tv.tuner.tvinput.ChannelDataManager;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+
+import junit.framework.Assert;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A fragment for scanning channels.
+ */
+public class ScanFragment extends SetupFragment {
+ private static final String TAG = "ScanFragment";
+ private static final boolean DEBUG = false;
+ // In the fake mode, the connection to antenna or cable is not necessary.
+ // Instead dummy channels are added.
+ private static final boolean FAKE_MODE = false;
+
+ private static final String VCTLESS_CHANNEL_NAME_FORMAT = "RF%d-%d";
+
+ public static final String ACTION_CATEGORY = "com.android.tv.tuner.setup.ScanFragment";
+ public static final int ACTION_CANCEL = 1;
+ public static final int ACTION_FINISH = 2;
+
+ public static final String EXTRA_FOR_CHANNEL_SCAN_FILE = "scan_file_choice";
+
+ private static final long CHANNEL_SCAN_SHOW_DELAY_MS = 10000;
+ private static final long CHANNEL_SCAN_PERIOD_MS = 4000;
+ private static final long SHOW_PROGRESS_DIALOG_DELAY_MS = 300;
+
+ // Build channels out of the locally stored TS streams.
+ private static final boolean SCAN_LOCAL_STREAMS = true;
+
+ private ChannelDataManager mChannelDataManager;
+ private ChannelScanTask mChannelScanTask;
+ private ProgressBar mProgressBar;
+ private TextView mScanningMessage;
+ private View mChannelHolder;
+ private ChannelAdapter mAdapter;
+ private volatile boolean mChannelListVisible;
+ private Button mCancelButton;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = super.onCreateView(inflater, container, savedInstanceState);
+ mChannelDataManager = new ChannelDataManager(getActivity());
+ mChannelDataManager.checkDataVersion(getActivity());
+ mAdapter = new ChannelAdapter();
+ mProgressBar = (ProgressBar) view.findViewById(R.id.tune_progress);
+ mScanningMessage = (TextView) view.findViewById(R.id.tune_description);
+ ListView channelList = (ListView) view.findViewById(R.id.channel_list);
+ channelList.setAdapter(mAdapter);
+ channelList.setOnItemClickListener(null);
+ ViewGroup progressHolder = (ViewGroup) view.findViewById(R.id.progress_holder);
+ LayoutTransition transition = new LayoutTransition();
+ transition.enableTransitionType(LayoutTransition.CHANGING);
+ progressHolder.setLayoutTransition(transition);
+ mChannelHolder = view.findViewById(R.id.channel_holder);
+ mCancelButton = (Button) view.findViewById(R.id.tune_cancel);
+ mCancelButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finishScan(false);
+ }
+ });
+ Bundle args = getArguments();
+ // TODO: Handle the case when the fragment is restored.
+ startScan(args == null ? 0 : args.getInt(EXTRA_FOR_CHANNEL_SCAN_FILE, 0));
+ TextView scanTitleView = (TextView) view.findViewById(R.id.tune_title);
+ if (TunerInputInfoUtils.isBuiltInTuner(getActivity())){
+ scanTitleView.setText(R.string.bt_channel_scan);
+ } else {
+ scanTitleView.setText(R.string.ut_channel_scan);
+ }
+ return view;
+ }
+
+ @Override
+ protected int getLayoutResourceId() {
+ return R.layout.ut_channel_scan;
+ }
+
+ @Override
+ protected int[] getParentIdsForDelay() {
+ return new int[] {R.id.progress_holder};
+ }
+
+ private void startScan(int channelMapId) {
+ mChannelScanTask = new ChannelScanTask(channelMapId);
+ mChannelScanTask.execute();
+ }
+
+ @Override
+ public void onDetach() {
+ if (mChannelScanTask != null) {
+ // Ensure scan task will stop.
+ mChannelScanTask.stopScan();
+ }
+ super.onDetach();
+ }
+
+ /**
+ * Finishes the current scan thread. This fragment will be popped after the scan thread ends.
+ *
+ * @param cancel a flag which indicates the scan is canceled or not.
+ */
+ public void finishScan(boolean cancel) {
+ if (mChannelScanTask != null) {
+ mChannelScanTask.cancelScan(cancel);
+
+ // Notifies a user of waiting to finish the scanning process.
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ mChannelScanTask.showFinishingProgressDialog();
+ }
+ }, SHOW_PROGRESS_DIALOG_DELAY_MS);
+
+ // Hides the cancel button.
+ mCancelButton.setEnabled(false);
+ }
+ }
+
+ private class ChannelAdapter extends BaseAdapter {
+ private final ArrayList<TunerChannel> mChannels;
+
+ public ChannelAdapter() {
+ mChannels = new ArrayList<>();
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean isEnabled(int pos) {
+ return false;
+ }
+
+ @Override
+ public int getCount() {
+ return mChannels.size();
+ }
+
+ @Override
+ public Object getItem(int pos) {
+ return pos;
+ }
+
+ @Override
+ public long getItemId(int pos) {
+ return pos;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final Context context = parent.getContext();
+
+ if (convertView == null) {
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.ut_channel_list, parent, false);
+ }
+
+ TextView channelNum = (TextView) convertView.findViewById(R.id.channel_num);
+ channelNum.setText(mChannels.get(position).getDisplayNumber());
+
+ TextView channelName = (TextView) convertView.findViewById(R.id.channel_name);
+ channelName.setText(mChannels.get(position).getName());
+ return convertView;
+ }
+
+ public void add(TunerChannel channel) {
+ mChannels.add(channel);
+ notifyDataSetChanged();
+ }
+ }
+
+ private class ChannelScanTask extends AsyncTask<Void, Integer, Void>
+ implements EventDetector.EventListener, ChannelDataManager.ChannelScanListener {
+ private static final int MAX_PROGRESS = 100;
+
+ private final Activity mActivity;
+ private final int mChannelMapId;
+ private final TsStreamer mScanTsStreamer;
+ private final TsStreamer mFileTsStreamer;
+ private final ConditionVariable mConditionStopped;
+
+ private final List<ChannelScanFileParser.ScanChannel> mScanChannelList = new ArrayList<>();
+ private boolean mIsCanceled;
+ private boolean mIsFinished;
+ private ProgressDialog mFinishingProgressDialog;
+ private CountDownLatch mLatch;
+
+ public ChannelScanTask(int channelMapId) {
+ mActivity = getActivity();
+ mChannelMapId = channelMapId;
+ if (FAKE_MODE) {
+ mScanTsStreamer = new FakeTsStreamer(this);
+ } else {
+ TunerHal hal = TunerHal.createInstance(mActivity.getApplicationContext());
+ if (hal == null) {
+ throw new RuntimeException("Failed to open a DVB device");
+ }
+ mScanTsStreamer = new TunerTsStreamer(hal, this);
+ }
+ mFileTsStreamer = SCAN_LOCAL_STREAMS ? new FileTsStreamer(this) : null;
+ mConditionStopped = new ConditionVariable();
+ mChannelDataManager.setChannelScanListener(this, new Handler());
+ }
+
+ private void maybeSetChannelListVisible() {
+ mActivity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ int channelsFound = mAdapter.getCount();
+ if (!mChannelListVisible && channelsFound > 0) {
+ String format = getResources().getQuantityString(
+ R.plurals.ut_channel_scan_message, channelsFound, channelsFound);
+ mScanningMessage.setText(String.format(format, channelsFound));
+ mChannelHolder.setVisibility(View.VISIBLE);
+ mChannelListVisible = true;
+ }
+ }
+ });
+ }
+
+ private void addChannel(final TunerChannel channel) {
+ mActivity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mAdapter.add(channel);
+ if (mChannelListVisible) {
+ int channelsFound = mAdapter.getCount();
+ String format = getResources().getQuantityString(
+ R.plurals.ut_channel_scan_message, channelsFound, channelsFound);
+ mScanningMessage.setText(String.format(format, channelsFound));
+ }
+ }
+ });
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ mScanChannelList.clear();
+ if (SCAN_LOCAL_STREAMS) {
+ FileTsStreamer.addLocalStreamFiles(mScanChannelList);
+ }
+ mScanChannelList.addAll(ChannelScanFileParser.parseScanFile(
+ getResources().openRawResource(mChannelMapId)));
+ scanChannels();
+ return null;
+ }
+
+ @Override
+ protected void onCancelled() {
+ SoftPreconditions.checkState(false, TAG, "call cancelScan instead of cancel");
+ }
+
+ @Override
+ protected void onProgressUpdate(Integer... values) {
+ mProgressBar.setProgress(values[0]);
+ }
+
+ private void stopScan() {
+ mConditionStopped.open();
+ }
+
+ private void cancelScan(boolean cancel) {
+ mIsCanceled = cancel;
+ stopScan();
+ }
+
+ private void scanChannels() {
+ if (DEBUG) Log.i(TAG, "Channel scan starting");
+ mChannelDataManager.notifyScanStarted();
+
+ long startMs = System.currentTimeMillis();
+ int i = 1;
+ for (ChannelScanFileParser.ScanChannel scanChannel : mScanChannelList) {
+ int frequency = scanChannel.frequency;
+ String modulation = scanChannel.modulation;
+ Log.i(TAG, "Tuning to " + frequency + " " + modulation);
+
+ TsStreamer streamer = getStreamer(scanChannel.type);
+ Assert.assertNotNull(streamer);
+ if (streamer.startStream(scanChannel)) {
+ mLatch = new CountDownLatch(1);
+ try {
+ mLatch.await(CHANNEL_SCAN_PERIOD_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Log.e(TAG, "The current thread is interrupted during scanChannels(). " +
+ "The TS stream is stopped earlier than expected.", e);
+ }
+ streamer.stopStream();
+
+ addChannelsWithoutVct(scanChannel);
+ if (System.currentTimeMillis() > startMs + CHANNEL_SCAN_SHOW_DELAY_MS
+ && !mChannelListVisible) {
+ maybeSetChannelListVisible();
+ }
+ }
+ if (mConditionStopped.block(-1)) {
+ break;
+ }
+ onProgressUpdate(MAX_PROGRESS * i++ / mScanChannelList.size());
+ }
+ if (mScanTsStreamer instanceof TunerTsStreamer) {
+ AutoCloseableUtils.closeQuietly(
+ ((TunerTsStreamer) mScanTsStreamer).getTunerHal());
+ }
+ mChannelDataManager.notifyScanCompleted();
+ if (!mConditionStopped.block(-1)) {
+ publishProgress(MAX_PROGRESS);
+ }
+ if (DEBUG) Log.i(TAG, "Channel scan ended");
+ }
+
+
+ private void addChannelsWithoutVct(ChannelScanFileParser.ScanChannel scanChannel) {
+ if (scanChannel.radioFrequencyNumber == null
+ || !(mScanTsStreamer instanceof TunerTsStreamer)) {
+ return;
+ }
+ for (TunerChannel tunerChannel
+ : ((TunerTsStreamer) mScanTsStreamer).getMalFormedChannels()) {
+ if ((tunerChannel.getVideoPid() != TunerChannel.INVALID_PID)
+ && (tunerChannel.getAudioPid() != TunerChannel.INVALID_PID)) {
+ tunerChannel.setFrequency(scanChannel.frequency);
+ tunerChannel.setModulation(scanChannel.modulation);
+ tunerChannel.setShortName(String.format(Locale.US, VCTLESS_CHANNEL_NAME_FORMAT,
+ scanChannel.radioFrequencyNumber,
+ tunerChannel.getProgramNumber()));
+ tunerChannel.setVirtualMajor(scanChannel.radioFrequencyNumber);
+ tunerChannel.setVirtualMinor(tunerChannel.getProgramNumber());
+ onChannelDetected(tunerChannel, true);
+ }
+ }
+ }
+
+ private TsStreamer getStreamer(int type) {
+ switch (type) {
+ case Channel.TYPE_TUNER:
+ return mScanTsStreamer;
+ case Channel.TYPE_FILE:
+ return mFileTsStreamer;
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) {
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ if (mLatch != null) {
+ mLatch.countDown();
+ }
+ }
+
+ @Override
+ public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ if (channelArrivedAtFirstTime) {
+ Log.i(TAG, "Found channel " + channel);
+ }
+ if (channelArrivedAtFirstTime && channel.hasAudio()) {
+ // Playbacks with video-only stream have not been tested yet.
+ // No video-only channel has been found.
+ addChannel(channel);
+ }
+ mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
+ }
+
+ public void showFinishingProgressDialog() {
+ // Show a progress dialog to wait for the scanning process if it's not done yet.
+ if (!mIsFinished && mFinishingProgressDialog == null) {
+ mFinishingProgressDialog = ProgressDialog.show(mActivity, "",
+ getString(R.string.ut_setup_cancel), true, false);
+ }
+ }
+
+ @Override
+ public void onChannelHandlingDone() {
+ mChannelDataManager.setCurrentVersion(mActivity);
+ mChannelDataManager.releaseSafely();
+ mIsFinished = true;
+ TunerPreferences.setScannedChannelCount(mActivity.getApplicationContext(),
+ mChannelDataManager.getScannedChannelCount());
+ // Cancel a previously shown recommendation card.
+ TunerSetupActivity.cancelRecommendationCard(mActivity.getApplicationContext());
+ // Mark scan as done
+ TunerPreferences.setScanDone(mActivity.getApplicationContext());
+ // finishing will be done manually.
+ if (mFinishingProgressDialog != null) {
+ mFinishingProgressDialog.dismiss();
+ }
+ onActionClick(ACTION_CATEGORY, mIsCanceled ? ACTION_CANCEL : ACTION_FINISH);
+ mChannelScanTask = null;
+ }
+ }
+
+ private static class FakeTsStreamer implements TsStreamer {
+ private final EventDetector.EventListener mEventListener;
+ private int mProgramNumber = 0;
+
+ FakeTsStreamer(EventDetector.EventListener eventListener) {
+ mEventListener = eventListener;
+ }
+
+ @Override
+ public boolean startStream(ChannelScanFileParser.ScanChannel channel) {
+ if (++mProgramNumber % 2 == 1) {
+ return true;
+ }
+ final String displayNumber = Integer.toString(mProgramNumber);
+ final String name = "Channel-" + mProgramNumber;
+ mEventListener.onChannelDetected(new TunerChannel(mProgramNumber, new ArrayList<>()) {
+ @Override
+ public String getDisplayNumber() {
+ return displayNumber;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+ }, true);
+ return true;
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ return false;
+ }
+
+ @Override
+ public void stopStream() {
+ }
+
+ @Override
+ public TsDataSource createDataSource() {
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/setup/ScanResultFragment.java b/src/com/android/tv/tuner/setup/ScanResultFragment.java
new file mode 100644
index 00000000..068543cd
--- /dev/null
+++ b/src/com/android/tv/tuner/setup/ScanResultFragment.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.setup;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+
+import java.util.List;
+
+/**
+ * A fragment for initial screen.
+ */
+public class ScanResultFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY =
+ "com.android.tv.tuner.setup.ScanResultFragment";
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ return new ContentFragment();
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ public static class ContentFragment extends SetupGuidedStepFragment {
+ private int mChannelCountOnPreference;
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mChannelCountOnPreference = TunerPreferences.getScannedChannelCount(context);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title;
+ String description;
+ String breadcrumb;
+ if (mChannelCountOnPreference > 0) {
+ Resources res = getResources();
+ title = res.getQuantityString(R.plurals.ut_result_found_title,
+ mChannelCountOnPreference, mChannelCountOnPreference);
+ description = res.getQuantityString(R.plurals.ut_result_found_description,
+ mChannelCountOnPreference, mChannelCountOnPreference);
+ breadcrumb = null;
+ } else {
+ title = getString(R.string.ut_result_not_found_title);
+ if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) {
+ description = getString(R.string.bt_result_not_found_description);
+ } else {
+ description = getString(R.string.ut_result_not_found_description);
+ }
+ breadcrumb = getString(R.string.ut_setup_breadcrumb);
+ }
+ return new Guidance(title, description, breadcrumb, null);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions,
+ Bundle savedInstanceState) {
+ String[] choices;
+ int doneActionIndex;
+ if (mChannelCountOnPreference > 0) {
+ choices = getResources().getStringArray(R.array.ut_result_found_choices);
+ doneActionIndex = 0;
+ } else {
+ choices = getResources().getStringArray(R.array.ut_result_not_found_choices);
+ doneActionIndex = 1;
+ }
+ for (int i = 0; i < choices.length; ++i) {
+ if (i == doneActionIndex) {
+ actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DONE)
+ .title(choices[i]).build());
+ } else {
+ actions.add(new GuidedAction.Builder(getActivity()).id(i).title(choices[i])
+ .build());
+ }
+ }
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/setup/TunerSetupActivity.java b/src/com/android/tv/tuner/setup/TunerSetupActivity.java
new file mode 100644
index 00000000..78121bc5
--- /dev/null
+++ b/src/com/android/tv/tuner/setup/TunerSetupActivity.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.setup;
+
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.tv.TvContract;
+import android.os.Bundle;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.widget.Toast;
+
+import com.android.tv.TvApplication;
+import com.android.tv.common.TvCommonConstants;
+import com.android.tv.common.TvCommonUtils;
+import com.android.tv.common.ui.setup.SetupActivity;
+import com.android.tv.common.ui.setup.SetupFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+
+/**
+ * An activity that serves tuner setup process.
+ */
+public class TunerSetupActivity extends SetupActivity {
+ private final String TAG = "TunerSetupActivity";
+ // For the recommendation card
+ private static final String TV_ACTIVITY_CLASS_NAME = "com.android.tv.TvActivity";
+ private static final String NOTIFY_TAG = "TunerSetup";
+ private static final int NOTIFY_ID = 1000;
+ private static final String TAG_DRAWABLE = "drawable";
+ private static final String TAG_ICON = "ic_launcher_s";
+
+ private static final int CHANNEL_MAP_SCAN_FILE[] = {
+ R.raw.ut_us_atsc_center_frequencies_8vsb,
+ R.raw.ut_us_cable_standard_center_frequencies_qam256,
+ R.raw.ut_us_all,
+ R.raw.ut_kr_atsc_center_frequencies_8vsb,
+ R.raw.ut_kr_cable_standard_center_frequencies_qam256,
+ R.raw.ut_kr_all,
+ R.raw.ut_kr_dev_cj_cable_center_frequencies_qam256};
+
+ private ScanFragment mLastScanFragment;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ TvApplication.setCurrentRunningProcess(this, false);
+ super.onCreate(savedInstanceState);
+ // TODO: check {@link shouldShowRequestPermissionRationale}.
+ if (checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
+ != PackageManager.PERMISSION_GRANTED) {
+ // No need to check the request result.
+ requestPermissions(new String[] {android.Manifest.permission.ACCESS_COARSE_LOCATION},
+ 0);
+ }
+ }
+
+ @Override
+ protected Fragment onCreateInitialFragment() {
+ SetupFragment fragment = new WelcomeFragment();
+ fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION
+ | SetupFragment.FRAGMENT_REENTER_TRANSITION);
+ return fragment;
+ }
+
+ @Override
+ protected boolean executeAction(String category, int actionId, Bundle params) {
+ switch (category) {
+ case WelcomeFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ // If the scan was performed, then the result should be OK.
+ setResult(mLastScanFragment == null ? RESULT_CANCELED : RESULT_OK);
+ finish();
+ break;
+ default: {
+ SetupFragment fragment = new ConnectionTypeFragment();
+ fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION
+ | SetupFragment.FRAGMENT_RETURN_TRANSITION);
+ showFragment(fragment, true);
+ break;
+ }
+ }
+ return true;
+ case ConnectionTypeFragment.ACTION_CATEGORY:
+ TunerHal hal = TunerHal.createInstance(getApplicationContext());
+ if (hal == null) {
+ finish();
+ Toast.makeText(getApplicationContext(),
+ R.string.ut_channel_scan_tuner_unavailable,Toast.LENGTH_LONG).show();
+ return true;
+ }
+ try {
+ hal.close();
+ } catch (Exception e) {
+ Log.e(TAG, "Tuner hal close failed", e);
+ return true;
+ }
+ mLastScanFragment = new ScanFragment();
+ Bundle args = new Bundle();
+ args.putInt(ScanFragment.EXTRA_FOR_CHANNEL_SCAN_FILE,
+ CHANNEL_MAP_SCAN_FILE[actionId]);
+ mLastScanFragment.setArguments(args);
+ showFragment(mLastScanFragment, true);
+ return true;
+ case ScanFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case ScanFragment.ACTION_CANCEL:
+ getFragmentManager().popBackStack();
+ return true;
+ case ScanFragment.ACTION_FINISH:
+ SetupFragment fragment = new ScanResultFragment();
+ fragment.setShortDistance(SetupFragment.FRAGMENT_EXIT_TRANSITION
+ | SetupFragment.FRAGMENT_REENTER_TRANSITION);
+ showFragment(fragment, true);
+ return true;
+ }
+ break;
+ case ScanResultFragment.ACTION_CATEGORY:
+ switch (actionId) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ setResult(RESULT_OK);
+ finish();
+ break;
+ default:
+ SetupFragment fragment = new ConnectionTypeFragment();
+ fragment.setShortDistance(SetupFragment.FRAGMENT_ENTER_TRANSITION
+ | SetupFragment.FRAGMENT_RETURN_TRANSITION);
+ showFragment(fragment, true);
+ break;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ FragmentManager manager = getFragmentManager();
+ int count = manager.getBackStackEntryCount();
+ if (count > 0) {
+ String lastTag = manager.getBackStackEntryAt(count - 1).getName();
+ if (ScanResultFragment.class.getCanonicalName().equals(lastTag) && count >= 2) {
+ // Pops fragment including ScanFragment.
+ manager.popBackStack(manager.getBackStackEntryAt(count - 2).getName(),
+ FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ return true;
+ } else if (ScanFragment.class.getCanonicalName().equals(lastTag)) {
+ mLastScanFragment.finishScan(true);
+ return true;
+ }
+ }
+ }
+ return super.onKeyUp(keyCode, event);
+ }
+
+ /**
+ * A callback to be invoked when the TvInputService is enabled or disabled.
+ *
+ * @param context a {@link Context} instance
+ * @param enabled {@code true} for the {@link TunerTvInputService} to be enabled;
+ * otherwise {@code false}
+ */
+ public static void onTvInputEnabled(Context context, boolean enabled) {
+ // Send a recommendation card for tuner setup if there's no channels and the tuner TV input
+ // setup has been not done.
+ boolean channelScanDoneOnPreference = TunerPreferences.isScanDone(context);
+ int channelCountOnPreference = TunerPreferences.getScannedChannelCount(context);
+ if (enabled && !channelScanDoneOnPreference && channelCountOnPreference == 0) {
+ TunerPreferences.setShouldShowSetupActivity(context, true);
+ sendRecommendationCard(context);
+ } else {
+ TunerPreferences.setShouldShowSetupActivity(context, false);
+ cancelRecommendationCard(context);
+ }
+ }
+
+ /**
+ * Returns a {@link Intent} to launch the tuner TV input service.
+ *
+ * @param context a {@link Context} instance
+ */
+ public static Intent createSetupActivity(Context context) {
+ String inputId = TvContract.buildInputId(new ComponentName(context.getPackageName(),
+ TunerTvInputService.class.getName()));
+
+ // Make an intent to launch the setup activity of USB tuner TV input.
+ Intent intent = TvCommonUtils.createSetupIntent(
+ new Intent(context, TunerSetupActivity.class), inputId);
+ intent.putExtra(TvCommonConstants.EXTRA_INPUT_ID, inputId);
+ Intent tvActivityIntent = new Intent();
+ tvActivityIntent.setComponent(new ComponentName(context, TV_ACTIVITY_CLASS_NAME));
+ intent.putExtra(TvCommonConstants.EXTRA_ACTIVITY_AFTER_COMPLETION, tvActivityIntent);
+ return intent;
+ }
+
+ /**
+ * Returns a {@link PendingIntent} to launch the tuner TV input service.
+ *
+ * @param context a {@link Context} instance
+ */
+ private static PendingIntent createPendingIntentForSetupActivity(Context context) {
+ return PendingIntent.getActivity(context, 0, createSetupActivity(context),
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ /**
+ * Sends the recommendation card to start the tuner TV input setup activity.
+ *
+ * @param context a {@link Context} instance
+ */
+ private static void sendRecommendationCard(Context context) {
+ Resources resources = context.getResources();
+ String focusedTitle = resources.getString(
+ R.string.ut_setup_recommendation_card_focused_title);
+ String title;
+ if (TunerInputInfoUtils.isBuiltInTuner(context)) {
+ title = resources.getString(R.string.bt_setup_recommendation_card_title);
+ } else {
+ title = resources.getString(R.string.ut_setup_recommendation_card_title);
+ }
+ Bitmap largeIcon = BitmapFactory.decodeResource(resources,
+ R.drawable.recommendation_antenna);
+
+ // Build and send the notification.
+ Notification notification = new NotificationCompat.BigPictureStyle(
+ new NotificationCompat.Builder(context)
+ .setAutoCancel(false)
+ .setContentTitle(focusedTitle)
+ .setContentText(title)
+ .setContentInfo(title)
+ .setCategory(Notification.CATEGORY_RECOMMENDATION)
+ .setLargeIcon(largeIcon)
+ .setSmallIcon(resources.getIdentifier(
+ TAG_ICON, TAG_DRAWABLE, context.getPackageName()))
+ .setContentIntent(createPendingIntentForSetupActivity(context)))
+ .build();
+ NotificationManager notificationManager = (NotificationManager) context
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.notify(NOTIFY_TAG, NOTIFY_ID, notification);
+ }
+
+ /**
+ * Cancels the previously shown recommendation card.
+ *
+ * @param context a {@link Context} instance
+ */
+ public static void cancelRecommendationCard(Context context) {
+ NotificationManager notificationManager = (NotificationManager) context
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ notificationManager.cancel(NOTIFY_TAG, NOTIFY_ID);
+ }
+}
diff --git a/src/com/android/tv/tuner/setup/WelcomeFragment.java b/src/com/android/tv/tuner/setup/WelcomeFragment.java
new file mode 100644
index 00000000..7e809411
--- /dev/null
+++ b/src/com/android/tv/tuner/setup/WelcomeFragment.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.setup;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.v17.leanback.widget.GuidanceStylist.Guidance;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.tv.common.ui.setup.SetupGuidedStepFragment;
+import com.android.tv.common.ui.setup.SetupMultiPaneFragment;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.util.TunerInputInfoUtils;
+
+import java.util.List;
+
+/**
+ * A fragment for initial screen.
+ */
+public class WelcomeFragment extends SetupMultiPaneFragment {
+ public static final String ACTION_CATEGORY =
+ "com.android.tv.tuner.setup.WelcomeFragment";
+
+ @Override
+ protected SetupGuidedStepFragment onCreateContentFragment() {
+ return new ContentFragment();
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+
+ @Override
+ protected boolean needsDoneButton() {
+ return false;
+ }
+
+ public static class ContentFragment extends SetupGuidedStepFragment {
+ private int mChannelCountOnPreference;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ mChannelCountOnPreference = TunerPreferences
+ .getScannedChannelCount(getActivity().getApplicationContext());
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @NonNull
+ @Override
+ public Guidance onCreateGuidance(Bundle savedInstanceState) {
+ String title;
+ String description;
+ if (mChannelCountOnPreference == 0) {
+ if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) {
+ title = getString(R.string.bt_setup_new_title);
+ description = getString(R.string.bt_setup_new_description);
+ } else {
+ title = getString(R.string.ut_setup_new_title);
+ description = getString(R.string.ut_setup_new_description);
+ }
+ } else {
+ title = getString(R.string.bt_setup_again_title);
+ if (TunerInputInfoUtils.isBuiltInTuner(getActivity())) {
+ description = getString(R.string.bt_setup_again_description);
+ } else {
+ description = getString(R.string.ut_setup_again_description);
+ }
+ }
+ return new Guidance(title, description, null, null);
+ }
+
+ @Override
+ public void onCreateActions(@NonNull List<GuidedAction> actions,
+ Bundle savedInstanceState) {
+ String[] choices = getResources().getStringArray(mChannelCountOnPreference == 0
+ ? R.array.ut_setup_new_choices : R.array.ut_setup_again_choices);
+ for (int i = 0; i < choices.length - 1; ++i) {
+ actions.add(new GuidedAction.Builder(getActivity()).id(i).title(choices[i])
+ .build());
+ }
+ actions.add(new GuidedAction.Builder(getActivity()).id(ACTION_DONE)
+ .title(choices[choices.length - 1]).build());
+ }
+
+ @Override
+ protected String getActionCategory() {
+ return ACTION_CATEGORY;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/source/FileTsStreamer.java b/src/com/android/tv/tuner/source/FileTsStreamer.java
new file mode 100644
index 00000000..14997ee4
--- /dev/null
+++ b/src/com/android/tv/tuner/source/FileTsStreamer.java
@@ -0,0 +1,470 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import android.os.Environment;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.upstream.DataSpec;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.ChannelScanFileParser.ScanChannel;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.FileSourceEventDetector;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Provides MPEG-2 TS stream sources for both channel scanning and channel playing from a local file
+ * generated by capturing TV signal.
+ */
+public class FileTsStreamer implements TsStreamer {
+ private static final String TAG = "FileTsStreamer";
+
+ private static final int TS_PACKET_SIZE = 188;
+ private static final int TS_SYNC_BYTE = 0x47;
+ private static final int MIN_READ_UNIT = TS_PACKET_SIZE * 10;
+ private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~20KB
+ private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 4000; // ~ 8MB
+ private static final int PADDING_SIZE = MIN_READ_UNIT * 1000; // ~2MB
+ private static final int READ_TIMEOUT_MS = 10000; // 10 secs.
+ private static final int BUFFER_UNDERRUN_SLEEP_MS = 10;
+ private static final String FILE_DIR =
+ new File(Environment.getExternalStorageDirectory(), "Streams").getAbsolutePath();
+
+ // Virtual frequency base used for file-based source
+ public static final int FREQ_BASE = 100;
+
+ private final Object mCircularBufferMonitor = new Object();
+ private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE];
+ private final FileSourceEventDetector mEventDetector;
+
+ private long mBytesFetched;
+ private long mLastReadPosition;
+ private boolean mStreaming;
+
+ private Thread mStreamingThread;
+ private StreamProvider mSource;
+
+ public static class FileDataSource extends TsDataSource {
+ private final FileTsStreamer mTsStreamer;
+ private final AtomicLong mLastReadPosition = new AtomicLong(0);
+ private long mStartBufferedPosition;
+
+ private FileDataSource(FileTsStreamer tsStreamer) {
+ mTsStreamer = tsStreamer;
+ mStartBufferedPosition = tsStreamer.getBufferedPosition();
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return mTsStreamer.getBufferedPosition() - mStartBufferedPosition;
+ }
+
+ @Override
+ public long getLastReadPosition() {
+ return mLastReadPosition.get();
+ }
+
+ @Override
+ public void shiftStartPosition(long offset) {
+ SoftPreconditions.checkState(mLastReadPosition.get() == 0);
+ SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition());
+ mStartBufferedPosition += offset;
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ mLastReadPosition.set(0);
+ return C.LENGTH_UNBOUNDED;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public int read(byte[] buffer, int offset, int readLength) throws IOException {
+ int ret = mTsStreamer.readAt(mStartBufferedPosition + mLastReadPosition.get(), buffer,
+ offset, readLength);
+ if (ret > 0) {
+ mLastReadPosition.addAndGet(ret);
+ }
+ return ret;
+ }
+ }
+
+ /**
+ * Creates {@link TsStreamer} for scanning & playing MPEG-2 TS file.
+ * @param eventListener the listener for channel & program information
+ */
+ public FileTsStreamer(EventDetector.EventListener eventListener) {
+ mEventDetector = new FileSourceEventDetector(eventListener);
+ }
+
+ @Override
+ public boolean startStream(ScanChannel channel) {
+ String filepath = new File(FILE_DIR, channel.filename).getAbsolutePath();
+ mSource = new StreamProvider(filepath);
+ if (!mSource.isReady()) {
+ return false;
+ }
+ mEventDetector.start(mSource, FileSourceEventDetector.ALL_PROGRAM_NUMBERS);
+ mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID);
+ mSource.addPidFilter(TsParser.PAT_PID);
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ return true;
+ }
+ mStreaming = true;
+ }
+
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ Log.i(TAG, "tuneToChannel with: " + channel.getFilepath());
+ mSource = new StreamProvider(channel.getFilepath());
+ if (!mSource.isReady()) {
+ return false;
+ }
+ mEventDetector.start(mSource, channel.getProgramNumber());
+ mSource.addPidFilter(channel.getVideoPid());
+ for (Integer i : channel.getAudioPids()) {
+ mSource.addPidFilter(i);
+ }
+ mSource.addPidFilter(channel.getPcrPid());
+ mSource.addPidFilter(TsParser.ATSC_SI_BASE_PID);
+ mSource.addPidFilter(TsParser.PAT_PID);
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ return true;
+ }
+ mStreaming = true;
+ }
+
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+
+ /**
+ * Blocks the current thread until the streaming thread stops. In rare cases when the tuner
+ * device is overloaded this can take a while, but usually it returns pretty quickly.
+ */
+ @Override
+ public void stopStream() {
+ synchronized (mCircularBufferMonitor) {
+ mStreaming = false;
+ mCircularBufferMonitor.notify();
+ }
+
+ try {
+ if (mStreamingThread != null) {
+ mStreamingThread.join();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public TsDataSource createDataSource() {
+ return new FileDataSource(this);
+ }
+
+ /**
+ * Returns the current buffered position from the file.
+ * @return the current buffered position
+ */
+ public long getBufferedPosition() {
+ synchronized (mCircularBufferMonitor) {
+ return mBytesFetched;
+ }
+ }
+
+ /**
+ * Provides MPEG-2 transport stream from a local file. Stream can be filtered by PID.
+ */
+ public static class StreamProvider {
+ private final String mFilepath;
+ private final SparseBooleanArray mPids = new SparseBooleanArray();
+ private final byte[] mPreBuffer = new byte[READ_BUFFER_SIZE];
+
+ private BufferedInputStream mInputStream;
+
+ private StreamProvider(String filepath) {
+ mFilepath = filepath;
+ open(filepath);
+ }
+
+ private void open(String filepath) {
+ try {
+ mInputStream = new BufferedInputStream(new FileInputStream(filepath));
+ } catch (IOException e) {
+ Log.e(TAG, "Error opening input stream", e);
+ mInputStream = null;
+ }
+ }
+
+ private boolean isReady() {
+ return mInputStream != null;
+ }
+
+ /**
+ * Returns the file path of the MPEG-2 TS file.
+ */
+ public String getFilepath() {
+ return mFilepath;
+ }
+
+ /**
+ * Adds a pid for filtering from the MPEG-2 TS file.
+ */
+ public void addPidFilter(int pid) {
+ mPids.put(pid, true);
+ }
+
+ /**
+ * Returns whether the current pid filter is empty or not.
+ */
+ public boolean isFilterEmpty() {
+ return mPids.size() > 0;
+ }
+
+ /**
+ * Clears the current pid filter.
+ */
+ public void clearPidFilter() {
+ mPids.clear();
+ }
+
+ /**
+ * Returns whether a pid is in the pid filter or not.
+ * @param pid the pid to check
+ */
+ public boolean isInFilter(int pid) {
+ return mPids.get(pid);
+ }
+
+ /**
+ * Reads from the MPEG-2 TS file to buffer.
+ *
+ * @param inputBuffer to read
+ * @return the number of read bytes
+ */
+ private int read(byte[] inputBuffer) {
+ int readSize = readInternal();
+ if (readSize <= 0) {
+ // Reached the end of stream. Restart from the beginning.
+ close();
+ open(mFilepath);
+ if (mInputStream == null) {
+ return -1;
+ }
+ readSize = readInternal();
+ }
+
+ if (mPreBuffer[0] != TS_SYNC_BYTE) {
+ Log.e(TAG, "Error reading input stream - no TS sync found");
+ return -1;
+ }
+ int filteredSize = 0;
+ for (int i = 0, destPos = 0; i < readSize; i += TS_PACKET_SIZE) {
+ if (mPreBuffer[i] == TS_SYNC_BYTE) {
+ int pid = ((mPreBuffer[i + 1] & 0x1f) << 8) + (mPreBuffer[i + 2] & 0xff);
+ if (mPids.get(pid)) {
+ System.arraycopy(mPreBuffer, i, inputBuffer, destPos, TS_PACKET_SIZE);
+ destPos += TS_PACKET_SIZE;
+ filteredSize += TS_PACKET_SIZE;
+ }
+ }
+ }
+ return filteredSize;
+ }
+
+ private int readInternal() {
+ int readSize;
+ try {
+ readSize = mInputStream.read(mPreBuffer, 0, mPreBuffer.length);
+ } catch (IOException e) {
+ Log.e(TAG, "Error reading input stream", e);
+ return -1;
+ }
+ return readSize;
+ }
+
+ private void close() {
+ try {
+ mInputStream.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Error closing input stream:", e);
+ }
+ mInputStream = null;
+ }
+ }
+
+ /**
+ * Reads data from internal buffer.
+ * @param pos the position to read from
+ * @param buffer to read
+ * @param offset start position of the read buffer
+ * @param amount number of bytes to read
+ * @return number of read bytes when successful, {@code -1} otherwise
+ * @throws IOException
+ */
+ public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException {
+ synchronized (mCircularBufferMonitor) {
+ long initialBytesFetched = mBytesFetched;
+ while (mBytesFetched < pos + amount && mStreaming) {
+ try {
+ mCircularBufferMonitor.wait(READ_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ // Wait again.
+ Thread.currentThread().interrupt();
+ }
+ if (initialBytesFetched == mBytesFetched) {
+ Log.w(TAG, "No data update for " + READ_TIMEOUT_MS + "ms. returning -1.");
+
+ // Returning -1 will make demux report EOS so that the input service can retry
+ // the playback.
+ return -1;
+ }
+ }
+ if (!mStreaming) {
+ Log.w(TAG, "Stream is already stopped.");
+ return -1;
+ }
+ if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) {
+ Log.e(TAG, "Demux is requesting the data which is already overwritten.");
+ return -1;
+ }
+ int posInBuffer = (int) (pos % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = amount;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(mCircularBuffer, posInBuffer, buffer, offset, bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < amount) {
+ System.arraycopy(mCircularBuffer, 0, buffer, offset + bytesToCopyInFirstPass,
+ amount - bytesToCopyInFirstPass);
+ }
+ mLastReadPosition = pos + amount;
+ mCircularBufferMonitor.notify();
+ return amount;
+ }
+ }
+
+ /**
+ * Adds {@link ScanChannel} instance for local files.
+ *
+ * @param output a list of channels where the results will be placed in
+ */
+ public static void addLocalStreamFiles(List<ScanChannel> output) {
+ File dir = new File(FILE_DIR);
+ if (!dir.exists()) return;
+
+ File[] tsFiles = dir.listFiles();
+ if (tsFiles == null) return;
+ int freq = FileTsStreamer.FREQ_BASE;
+ for (File file : tsFiles) {
+ if (!file.isFile()) continue;
+ output.add(ScanChannel.forFile(freq, file.getName()));
+ freq += 100;
+ }
+ }
+
+ /**
+ * A thread managing a circular buffer that holds stream data to be consumed by player.
+ * Keeps reading data in from a {@link StreamProvider} to hold enough amount for buffering.
+ * Started and stopped by {@link #startStream()} and {@link #stopStream()}, respectively.
+ */
+ private class StreamingThread extends Thread {
+ @Override
+ public void run() {
+ byte[] dataBuffer = new byte[READ_BUFFER_SIZE];
+
+ synchronized (mCircularBufferMonitor) {
+ mBytesFetched = 0;
+ mLastReadPosition = 0;
+ }
+
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ while ((mBytesFetched - mLastReadPosition + PADDING_SIZE) > CIRCULAR_BUFFER_SIZE
+ && mStreaming) {
+ try {
+ mCircularBufferMonitor.wait();
+ } catch (InterruptedException e) {
+ // Wait again.
+ Thread.currentThread().interrupt();
+ }
+ }
+ if (!mStreaming) {
+ break;
+ }
+ }
+
+ int bytesWritten = mSource.read(dataBuffer);
+ if (bytesWritten <= 0) {
+ try {
+ // When buffer is underrun, we sleep for short time to prevent
+ // unnecessary CPU draining.
+ sleep(BUFFER_UNDERRUN_SLEEP_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ continue;
+ }
+
+ mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten);
+
+ synchronized (mCircularBufferMonitor) {
+ int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = bytesWritten;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(dataBuffer, 0, mCircularBuffer, posInBuffer,
+ bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < bytesWritten) {
+ System.arraycopy(dataBuffer, bytesToCopyInFirstPass, mCircularBuffer, 0,
+ bytesWritten - bytesToCopyInFirstPass);
+ }
+ mBytesFetched += bytesWritten;
+ mCircularBufferMonitor.notify();
+ }
+ }
+
+ Log.i(TAG, "Streaming stopped");
+ mSource.close();
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/source/TsDataSource.java b/src/com/android/tv/tuner/source/TsDataSource.java
new file mode 100644
index 00000000..2ce3e670
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TsDataSource.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import com.google.android.exoplayer.upstream.DataSource;
+
+/**
+ * {@link DataSource} for MPEG-TS stream, which will be used by {@link TsExtractor}.
+ */
+public abstract class TsDataSource implements DataSource {
+
+ /**
+ * Returns the number of bytes being buffered by {@link TsStreamer} so far.
+ *
+ * @return the buffered position
+ */
+ public long getBufferedPosition() {
+ return 0;
+ }
+
+ /**
+ * Returns the offset position where the last {@link DataSource#read} read.
+ *
+ * @return the last read position
+ */
+ public long getLastReadPosition() {
+ return 0;
+ }
+
+ /**
+ * Shifts start position by the specified offset.
+ * Do not call this method when the class already provided MPEG-TS stream to the extractor.
+ * @param offset 0 <= offset <= buffered position
+ */
+ public void shiftStartPosition(long offset) { }
+} \ No newline at end of file
diff --git a/src/com/android/tv/tuner/source/TsDataSourceManager.java b/src/com/android/tv/tuner/source/TsDataSourceManager.java
new file mode 100644
index 00000000..7286cd8c
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TsDataSourceManager.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import android.content.Context;
+
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.tvinput.EventDetector;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages {@link DataSource} for playback and recording.
+ * The class hides handling of {@link TunerHal} and {@link TsStreamer} from other classes.
+ * One TsDataSourceManager should be created for per session.
+ */
+public class TsDataSourceManager {
+ private static String TAG = "TsDataSourceManager";
+
+ private static final Object sLock = new Object();
+ private static final Map<TsDataSource, TsStreamer> sTsStreamers =
+ new ConcurrentHashMap<>();
+
+ private static int sSequenceId;
+
+ private final int mId;
+ private final boolean mIsRecording;
+ private final TunerTsStreamerManager mTunerStreamerManager =
+ TunerTsStreamerManager.getInstance();
+
+ private boolean mKeepTuneStatus;
+
+ /**
+ * Creates TsDataSourceManager to create and release {@link DataSource} which will be
+ * used for playing and recording.
+ * @param isRecording {@code true} when for recording, {@code false} otherwise
+ * @return {@link TsDataSourceManager}
+ */
+ public static TsDataSourceManager createSourceManager(boolean isRecording) {
+ int id;
+ synchronized (sLock) {
+ id = ++sSequenceId;
+ }
+ return new TsDataSourceManager(id, isRecording);
+ }
+
+ private TsDataSourceManager(int id, boolean isRecording) {
+ mId = id;
+ mIsRecording = isRecording;
+ mKeepTuneStatus = true;
+ }
+
+ /**
+ * Creates or retrieves {@link TsDataSource} for playing or recording
+ * @param context a {@link Context} instance
+ * @param channel to play or record
+ * @param eventListener for program information which will be scanned from MPEG2-TS stream
+ * @return {@link TsDataSource} which will provide the specified channel stream
+ */
+ public TsDataSource createDataSource(Context context, TunerChannel channel,
+ EventDetector.EventListener eventListener) {
+ if (channel.getType() == Channel.TYPE_FILE) {
+ // MPEG2 TS captured stream file recording is not supported.
+ if (mIsRecording) {
+ return null;
+ }
+ FileTsStreamer streamer = new FileTsStreamer(eventListener);
+ if (streamer.startStream(channel)) {
+ TsDataSource source = streamer.createDataSource();
+ sTsStreamers.put(source, streamer);
+ return source;
+ }
+ return null;
+ }
+ return mTunerStreamerManager.createDataSource(context, channel, eventListener,
+ mId, !mIsRecording && mKeepTuneStatus);
+ }
+
+ /**
+ * Releases the specified {@link TsDataSource} and underlying {@link TunerHal}.
+ * @param source to release
+ */
+ public void releaseDataSource(TsDataSource source) {
+ if (source instanceof TunerTsStreamer.TunerDataSource) {
+ mTunerStreamerManager.releaseDataSource(
+ source, mId, !mIsRecording && mKeepTuneStatus);
+ } else if (source instanceof FileTsStreamer.FileDataSource) {
+ FileTsStreamer streamer = (FileTsStreamer) sTsStreamers.get(source);
+ if (streamer != null) {
+ sTsStreamers.remove(source);
+ streamer.stopStream();
+ }
+ }
+ }
+
+ /**
+ * Indicates that the current session has pending tunes.
+ */
+ public void setHasPendingTune() {
+ mTunerStreamerManager.setHasPendingTune(mId);
+ }
+
+ /**
+ * Indicates whether the underlying {@link TunerHal} should be kept or not when data source
+ * is being released.
+ * TODO: If b/30750953 is fixed, we can remove this function.
+ * @param keepTuneStatus underlying {@link TunerHal} will be reused when data source releasing.
+ */
+ public void setKeepTuneStatus(boolean keepTuneStatus) {
+ mKeepTuneStatus = keepTuneStatus;
+ }
+
+ /**
+ * Releases persistent resources.
+ */
+ public void release() {
+ mTunerStreamerManager.release(mId);
+ }
+}
diff --git a/src/com/android/tv/tuner/source/TsStreamWriter.java b/src/com/android/tv/tuner/source/TsStreamWriter.java
new file mode 100644
index 00000000..30650555
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TsStreamWriter.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import android.content.Context;
+import android.util.Log;
+import com.android.tv.tuner.data.TunerChannel;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Stores TS files to the disk for debugging.
+ */
+public class TsStreamWriter {
+ private static final String TAG = "TsStreamWriter";
+ private static final boolean DEBUG = false;
+
+ private static final long TIME_LIMIT_MS = 10000; // 10s
+ private static final int NO_INSTANCE_ID = 0;
+ private static final int MAX_GET_ID_RETRY_COUNT = 5;
+ private static final int MAX_INSTANCE_ID = 10000;
+ private static final String SEPARATOR = "_";
+
+ private FileOutputStream mFileOutputStream;
+ private long mFileStartTimeMs;
+ private String mFileName = null;
+ private final String mDirectoryPath;
+ private final File mDirectory;
+ private final int mInstanceId;
+ private TunerChannel mChannel;
+
+ public TsStreamWriter(Context context) {
+ File externalFilesDir = context.getExternalFilesDir(null);
+ if (externalFilesDir == null || !externalFilesDir.isDirectory()) {
+ mDirectoryPath = null;
+ mDirectory = null;
+ mInstanceId = NO_INSTANCE_ID;
+ if (DEBUG) {
+ Log.w(TAG, "Fail to get external files dir!");
+ }
+ } else {
+ mDirectoryPath = externalFilesDir.getPath() + "/EngTsStream";
+ mDirectory = new File(mDirectoryPath);
+ if (!mDirectory.exists()) {
+ boolean madeDir = mDirectory.mkdir();
+ if (!madeDir) {
+ Log.w(TAG, "Error. Fail to create folder!");
+ }
+ }
+ mInstanceId = generateInstanceId();
+ }
+ }
+
+ /**
+ * Sets the current channel.
+ *
+ * @param channel curren channel of the stream
+ */
+ public void setChannel(TunerChannel channel) {
+ mChannel = channel;
+ }
+
+ /**
+ * Opens a file to store TS data.
+ */
+ public void openFile() {
+ if (mChannel == null || mDirectoryPath == null) {
+ return;
+ }
+ mFileStartTimeMs = System.currentTimeMillis();
+ mFileName = mChannel.getDisplayNumber() + SEPARATOR + mFileStartTimeMs + SEPARATOR
+ + mInstanceId + ".ts";
+ String filePath = mDirectoryPath + "/" + mFileName;
+ try {
+ mFileOutputStream = new FileOutputStream(filePath, false);
+ } catch (FileNotFoundException e) {
+ Log.w(TAG, "Cannot open file: " + filePath, e);
+ }
+ }
+
+ /**
+ * Closes the file and stops storing TS data.
+ *
+ * @param calledWhenStopStream {@code true} if this method is called when the stream is stopped
+ * {@code false} otherwise
+ */
+ public void closeFile(boolean calledWhenStopStream) {
+ if (mFileOutputStream == null) {
+ return;
+ }
+ try {
+ mFileOutputStream.close();
+ deleteOutdatedFiles(calledWhenStopStream);
+ mFileName = null;
+ mFileOutputStream = null;
+ } catch (IOException e) {
+ Log.w(TAG, "Error on closing file.", e);
+ }
+ }
+
+ /**
+ * Writes the data to the file.
+ *
+ * @param buffer the data to be written
+ * @param bytesWritten number of bytes written
+ */
+ public void writeToFile(byte[] buffer, int bytesWritten) {
+ if (mFileOutputStream == null) {
+ return;
+ }
+ if (System.currentTimeMillis() - mFileStartTimeMs > TIME_LIMIT_MS) {
+ closeFile(false);
+ openFile();
+ }
+ try {
+ mFileOutputStream.write(buffer, 0, bytesWritten);
+ } catch (IOException e) {
+ Log.w(TAG, "Error on writing TS stream.", e);
+ }
+ }
+
+ /**
+ * Deletes outdated files to save storage.
+ *
+ * @param deleteAll {@code true} if all the files with the relative ID should be deleted
+ * {@code false} if the most recent file should not be deleted
+ */
+ private void deleteOutdatedFiles(boolean deleteAll) {
+ if (mFileName == null) {
+ return;
+ }
+ if (mDirectory == null || !mDirectory.isDirectory()) {
+ Log.e(TAG, "Error. The folder doesn't exist!");
+ return;
+ }
+ if (mFileName == null) {
+ Log.e(TAG, "Error. The current file name is null!");
+ return;
+ }
+ for (File file : mDirectory.listFiles()) {
+ if (file.isFile() && getFileId(file) == mInstanceId
+ && (deleteAll || !mFileName.equals(file.getName()))) {
+ boolean deleted = file.delete();
+ if (DEBUG && !deleted) {
+ Log.w(TAG, "Failed to delete " + file.getName());
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates a unique instance ID.
+ *
+ * @return a unique instance ID
+ */
+ private int generateInstanceId() {
+ if (mDirectory == null) {
+ return NO_INSTANCE_ID;
+ }
+ Set<Integer> idSet = getExistingIds();
+ if (idSet == null) {
+ return NO_INSTANCE_ID;
+ }
+ for (int i = 0; i < MAX_GET_ID_RETRY_COUNT; i++) {
+ // Range [1, MAX_INSTANCE_ID]
+ int id = (int)Math.floor(Math.random() * MAX_INSTANCE_ID) + 1;
+ if (!idSet.contains(id)) {
+ return id;
+ }
+ }
+ return NO_INSTANCE_ID;
+ }
+
+ /**
+ * Gets all existing instance IDs.
+ *
+ * @return a set of all existing instance IDs
+ */
+ private Set<Integer> getExistingIds() {
+ if (mDirectory == null || !mDirectory.isDirectory()) {
+ return null;
+ }
+
+ Set<Integer> idSet = new HashSet<>();
+ for (File file : mDirectory.listFiles()) {
+ int id = getFileId(file);
+ if(id != NO_INSTANCE_ID) {
+ idSet.add(id);
+ }
+ }
+ return idSet;
+ }
+
+ /**
+ * Gets the instance ID of a given file.
+ *
+ * @param file the file whose TsStreamWriter ID is returned
+ * @return the TsStreamWriter ID of the file or NO_INSTANCE_ID if not available
+ */
+ private static int getFileId(File file) {
+ if (file == null || !file.isFile()) {
+ return NO_INSTANCE_ID;
+ }
+ String fileName = file.getName();
+ int lastSeparator = fileName.lastIndexOf(SEPARATOR);
+ if (!fileName.endsWith(".ts") || lastSeparator == -1) {
+ return NO_INSTANCE_ID;
+ }
+ try {
+ return Integer.parseInt(fileName.substring(lastSeparator + 1, fileName.length() - 3));
+ } catch (NumberFormatException e) {
+ if (DEBUG) {
+ Log.e(TAG, fileName + " is not a valid file name.");
+ }
+ }
+ return NO_INSTANCE_ID;
+ }
+}
diff --git a/src/com/android/tv/tuner/source/TsStreamer.java b/src/com/android/tv/tuner/source/TsStreamer.java
new file mode 100644
index 00000000..1ac950bb
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TsStreamer.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.data.TunerChannel;
+
+/**
+ * Interface definition for a stream generator. The interface will provide streams
+ * for scanning channels and/or playback.
+ */
+public interface TsStreamer {
+ /**
+ * Starts streaming the data for channel scanning process.
+ *
+ * @param channel {@link ChannelScanFileParser.ScanChannel} to be scanned
+ * @return {@code true} if ready to stream, otherwise {@code false}
+ */
+ boolean startStream(ChannelScanFileParser.ScanChannel channel);
+
+ /**
+ * Starts streaming the data for channel playing or recording.
+ *
+ * @param channel {@link TunerChannel} to tune
+ * @return {@code true} if ready to stream, otherwise {@code false}
+ */
+ boolean startStream(TunerChannel channel);
+
+ /**
+ * Stops streaming the data.
+ */
+ void stopStream();
+
+ /**
+ * Creates {@link TsDataSource} which will provide MPEG-2 TS stream for
+ * {@link android.media.MediaExtractor}. The source will start from the position
+ * where it is created.
+ *
+ * @return {@link TsDataSource}
+ */
+ TsDataSource createDataSource();
+}
diff --git a/src/com/android/tv/tuner/source/TunerTsStreamer.java b/src/com/android/tv/tuner/source/TunerTsStreamer.java
new file mode 100644
index 00000000..b24048e6
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TunerTsStreamer.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.android.exoplayer.C;
+import com.google.android.exoplayer.upstream.DataSpec;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.ChannelScanFileParser;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.tvinput.EventDetector;
+import com.android.tv.tuner.tvinput.EventDetector.EventListener;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Provides MPEG-2 TS stream sources for channel playing from an underlying tuner device.
+ */
+public class TunerTsStreamer implements TsStreamer {
+ private static final String TAG = "TunerTsStreamer";
+
+ private static final int MIN_READ_UNIT = 1500;
+ private static final int READ_BUFFER_SIZE = MIN_READ_UNIT * 10; // ~15KB
+ private static final int CIRCULAR_BUFFER_SIZE = MIN_READ_UNIT * 20000; // ~ 30MB
+
+ private static final int READ_TIMEOUT_MS = 5000; // 5 secs.
+ private static final int BUFFER_UNDERRUN_SLEEP_MS = 10;
+
+ private final Object mCircularBufferMonitor = new Object();
+ private final byte[] mCircularBuffer = new byte[CIRCULAR_BUFFER_SIZE];
+ private long mBytesFetched;
+ private final AtomicLong mLastReadPosition = new AtomicLong();
+ private boolean mEndOfStreamSent;
+ private boolean mStreaming;
+
+ private final TunerHal mTunerHal;
+ private TunerChannel mChannel;
+ private Thread mStreamingThread;
+ private final EventDetector mEventDetector;
+
+ private final TsStreamWriter mTsStreamWriter;
+
+ public static class TunerDataSource extends TsDataSource {
+ private final TunerTsStreamer mTsStreamer;
+ private final AtomicLong mLastReadPosition = new AtomicLong(0);
+ private long mStartBufferedPosition;
+
+ private TunerDataSource(TunerTsStreamer tsStreamer) {
+ mTsStreamer = tsStreamer;
+ mStartBufferedPosition = tsStreamer.getBufferedPosition();
+ }
+
+ @Override
+ public long getBufferedPosition() {
+ return mTsStreamer.getBufferedPosition() - mStartBufferedPosition;
+ }
+
+ @Override
+ public long getLastReadPosition() {
+ return mLastReadPosition.get();
+ }
+
+ @Override
+ public void shiftStartPosition(long offset) {
+ SoftPreconditions.checkState(mLastReadPosition.get() == 0);
+ SoftPreconditions.checkArgument(0 <= offset && offset <= getBufferedPosition());
+ mStartBufferedPosition += offset;
+ }
+
+ @Override
+ public long open(DataSpec dataSpec) throws IOException {
+ mLastReadPosition.set(0);
+ return C.LENGTH_UNBOUNDED;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public int read(byte[] buffer, int offset, int readLength) throws IOException {
+ int ret = mTsStreamer.readAt(mStartBufferedPosition + mLastReadPosition.get(), buffer,
+ offset, readLength);
+ if (ret > 0) {
+ mLastReadPosition.addAndGet(ret);
+ }
+ return ret;
+ }
+ }
+ /**
+ * Creates {@link TsStreamer} for playing or recording the specified channel.
+ * @param tunerHal the HAL for tuner device
+ * @param eventListener the listener for channel & program information
+ */
+ public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener, Context context) {
+ mTunerHal = tunerHal;
+ mEventDetector = new EventDetector(mTunerHal, eventListener);
+ mTsStreamWriter = context != null && TunerPreferences.getStoreTsStream(context) ?
+ new TsStreamWriter(context) : null;
+ }
+
+ public TunerTsStreamer(TunerHal tunerHal, EventListener eventListener) {
+ this(tunerHal, eventListener, null);
+ }
+
+ @Override
+ public boolean startStream(TunerChannel channel) {
+ if (mTunerHal.tune(channel.getFrequency(), channel.getModulation())) {
+ if (channel.hasVideo()) {
+ mTunerHal.addPidFilter(channel.getVideoPid(),
+ TunerHal.FILTER_TYPE_VIDEO);
+ }
+ boolean audioFilterSet = false;
+ for (Integer audioPid : channel.getAudioPids()) {
+ if (!audioFilterSet) {
+ mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_AUDIO);
+ audioFilterSet = true;
+ } else {
+ // FILTER_TYPE_AUDIO overrides the previous filter for audio. We use
+ // FILTER_TYPE_OTHER from the secondary one to get the all audio tracks.
+ mTunerHal.addPidFilter(audioPid, TunerHal.FILTER_TYPE_OTHER);
+ }
+ }
+ mTunerHal.addPidFilter(channel.getPcrPid(),
+ TunerHal.FILTER_TYPE_PCR);
+ if (mEventDetector != null) {
+ mEventDetector.startDetecting(channel.getFrequency(), channel.getModulation(),
+ channel.getProgramNumber());
+ }
+ mChannel = channel;
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ Log.w(TAG, "Streaming should be stopped before start streaming");
+ return true;
+ }
+ mStreaming = true;
+ mBytesFetched = 0;
+ mLastReadPosition.set(0L);
+ mEndOfStreamSent = false;
+ }
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.setChannel(mChannel);
+ mTsStreamWriter.openFile();
+ }
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean startStream(ChannelScanFileParser.ScanChannel channel) {
+ if (mTunerHal.tune(channel.frequency, channel.modulation)) {
+ mEventDetector.startDetecting(
+ channel.frequency, channel.modulation, EventDetector.ALL_PROGRAM_NUMBERS);
+ synchronized (mCircularBufferMonitor) {
+ if (mStreaming) {
+ Log.w(TAG, "Streaming should be stopped before start streaming");
+ return true;
+ }
+ mStreaming = true;
+ mBytesFetched = 0;
+ mLastReadPosition.set(0L);
+ mEndOfStreamSent = false;
+ }
+ mStreamingThread = new StreamingThread();
+ mStreamingThread.start();
+ Log.i(TAG, "Streaming started");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Blocks the current thread until the streaming thread stops. In rare cases when the tuner
+ * device is overloaded this can take a while, but usually it returns pretty quickly.
+ */
+ @Override
+ public void stopStream() {
+ mChannel = null;
+ synchronized (mCircularBufferMonitor) {
+ mStreaming = false;
+ mCircularBufferMonitor.notifyAll();
+ }
+
+ try {
+ if (mStreamingThread != null) {
+ mStreamingThread.join();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.closeFile(true);
+ mTsStreamWriter.setChannel(null);
+ }
+ }
+
+ @Override
+ public TsDataSource createDataSource() {
+ return new TunerDataSource(this);
+ }
+
+ /**
+ * Returns incomplete channel lists which was scanned so far. Incomplete channel means
+ * the channel whose channel information is not complete or is not well-formed.
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ return mEventDetector.getMalFormedChannels();
+ }
+
+ /**
+ * Returns the current {@link TunerHal} which provides MPEG-TS stream for TunerTsStreamer.
+ * @return {@link TunerHal}
+ */
+ public TunerHal getTunerHal() {
+ return mTunerHal;
+ }
+
+ /**
+ * Returns the current tuned channel for TunerTsStreamer.
+ * @return {@link TunerChannel}
+ */
+ public TunerChannel getChannel() {
+ return mChannel;
+ }
+
+ /**
+ * Returns the current buffered position from tuner.
+ * @return the current buffered position
+ */
+ public long getBufferedPosition() {
+ synchronized (mCircularBufferMonitor) {
+ return mBytesFetched;
+ }
+ }
+
+ private class StreamingThread extends Thread {
+ @Override
+ public void run() {
+ // Buffers for streaming data from the tuner and the internal buffer.
+ byte[] dataBuffer = new byte[READ_BUFFER_SIZE];
+
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ if (!mStreaming) {
+ break;
+ }
+ }
+
+ int bytesWritten = mTunerHal.readTsStream(dataBuffer, dataBuffer.length);
+ if (bytesWritten <= 0) {
+ try {
+ // When buffer is underrun, we sleep for short time to prevent
+ // unnecessary CPU draining.
+ sleep(BUFFER_UNDERRUN_SLEEP_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ continue;
+ }
+
+ if (mTsStreamWriter != null) {
+ mTsStreamWriter.writeToFile(dataBuffer, bytesWritten);
+ }
+
+ if (mEventDetector != null) {
+ mEventDetector.feedTSStream(dataBuffer, 0, bytesWritten);
+ }
+ synchronized (mCircularBufferMonitor) {
+ int posInBuffer = (int) (mBytesFetched % CIRCULAR_BUFFER_SIZE);
+ int bytesToCopyInFirstPass = bytesWritten;
+ if (posInBuffer + bytesToCopyInFirstPass > mCircularBuffer.length) {
+ bytesToCopyInFirstPass = mCircularBuffer.length - posInBuffer;
+ }
+ System.arraycopy(dataBuffer, 0, mCircularBuffer, posInBuffer,
+ bytesToCopyInFirstPass);
+ if (bytesToCopyInFirstPass < bytesWritten) {
+ System.arraycopy(dataBuffer, bytesToCopyInFirstPass, mCircularBuffer, 0,
+ bytesWritten - bytesToCopyInFirstPass);
+ }
+ mBytesFetched += bytesWritten;
+ mCircularBufferMonitor.notifyAll();
+ }
+ }
+
+ Log.i(TAG, "Streaming stopped");
+ }
+ }
+
+ /**
+ * Reads data from internal buffer.
+ * @param pos the position to read from
+ * @param buffer to read
+ * @param offset start position of the read buffer
+ * @param amount number of bytes to read
+ * @return number of read bytes when successful, {@code -1} otherwise
+ * @throws IOException
+ */
+ public int readAt(long pos, byte[] buffer, int offset, int amount) throws IOException {
+ long readStartTime = System.currentTimeMillis();
+ while (true) {
+ synchronized (mCircularBufferMonitor) {
+ if (mEndOfStreamSent || !mStreaming) {
+ return -1;
+ }
+ if (mBytesFetched - CIRCULAR_BUFFER_SIZE > pos) {
+ Log.e(TAG, "Demux is requesting the data which is already overwritten.");
+ return -1;
+ }
+ if (System.currentTimeMillis() - readStartTime > READ_TIMEOUT_MS) {
+ // Nothing was received during READ_TIMEOUT_MS before.
+ mEndOfStreamSent = true;
+ mCircularBufferMonitor.notifyAll();
+ return -1;
+ }
+ if (mBytesFetched < pos + amount) {
+ try {
+ mCircularBufferMonitor.wait(READ_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ // Try again to prevent starvation.
+ // Give chances to read from other threads.
+ continue;
+ }
+ int startPos = (int) (pos % CIRCULAR_BUFFER_SIZE);
+ int endPos = (int) ((pos + amount) % CIRCULAR_BUFFER_SIZE);
+ int firstLength = (startPos > endPos ? CIRCULAR_BUFFER_SIZE : endPos) - startPos;
+ System.arraycopy(mCircularBuffer, startPos, buffer, offset, firstLength);
+ if (firstLength < amount) {
+ System.arraycopy(mCircularBuffer, 0, buffer, offset + firstLength,
+ amount - firstLength);
+ }
+ mCircularBufferMonitor.notifyAll();
+ return amount;
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/source/TunerTsStreamerManager.java b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java
new file mode 100644
index 00000000..cf1f6dcf
--- /dev/null
+++ b/src/com/android/tv/tuner/source/TunerTsStreamerManager.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.source;
+
+import android.content.Context;
+
+import com.android.tv.common.AutoCloseableUtils;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.tvinput.EventDetector;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Manages {@link TunerTsStreamer} for playback and recording.
+ * The class hides handling of {@link TunerHal} from other classes.
+ * This class is used by {@link TsDataSourceManager}. Don't use this class directly.
+ */
+class TunerTsStreamerManager {
+ // The lock will protect mStreamerFinder, mSourceToStreamerMap and some part of TsStreamCreator
+ // to support timely {@link TunerTsStreamer} cancellation due to a new tune request from
+ // the same session.
+ private final Object mCancelLock = new Object();
+ private final StreamerFinder mStreamerFinder = new StreamerFinder();
+ private final Map<Integer, TsStreamerCreator> mCreators = new HashMap<>();
+ private final Map<TsDataSource, TunerTsStreamer> mSourceToStreamerMap = new HashMap<>();
+ private final TunerHalManager mTunerHalManager = new TunerHalManager();
+ private static TunerTsStreamerManager sInstance;
+
+ /**
+ * Returns the singleton instance for the class
+ * @return TunerTsStreamerManager
+ */
+ static synchronized TunerTsStreamerManager getInstance() {
+ if (sInstance == null) {
+ sInstance = new TunerTsStreamerManager();
+ }
+ return sInstance;
+ }
+
+ private TunerTsStreamerManager() { }
+
+ synchronized TsDataSource createDataSource(
+ Context context, TunerChannel channel, EventDetector.EventListener listener,
+ int sessionId, boolean reuse) {
+ TsStreamerCreator creator;
+ synchronized (mCancelLock) {
+ if (mStreamerFinder.containsLocked(channel)) {
+ mStreamerFinder.appendSessionLocked(channel, sessionId);
+ TunerTsStreamer streamer = mStreamerFinder.getStreamerLocked(channel);
+ TsDataSource source = streamer.createDataSource();
+ mSourceToStreamerMap.put(source, streamer);
+ return source;
+ }
+ creator = new TsStreamerCreator(context, channel, listener);
+ mCreators.put(sessionId, creator);
+ }
+ TunerTsStreamer streamer = creator.create(sessionId, reuse);
+ synchronized (mCancelLock) {
+ mCreators.remove(sessionId);
+ if (streamer == null) {
+ return null;
+ }
+ if (!creator.isCancelledLocked()) {
+ mStreamerFinder.putLocked(channel, sessionId, streamer);
+ TsDataSource source = streamer.createDataSource();
+ mSourceToStreamerMap.put(source, streamer);
+ return source;
+ }
+ }
+ // Created streamer was cancelled by a new tune request.
+ streamer.stopStream();
+ TunerHal hal = streamer.getTunerHal();
+ hal.setHasPendingTune(false);
+ mTunerHalManager.releaseTunerHal(hal, sessionId, reuse);
+ return null;
+ }
+
+ synchronized void releaseDataSource(TsDataSource source, int sessionId,
+ boolean reuse) {
+ TunerTsStreamer streamer;
+ synchronized (mCancelLock) {
+ streamer = mSourceToStreamerMap.get(source);
+ mSourceToStreamerMap.remove(source);
+ if (streamer == null) {
+ return;
+ }
+ TunerChannel channel = streamer.getChannel();
+ SoftPreconditions.checkState(channel != null);
+ mStreamerFinder.removeSessionLocked(channel, sessionId);
+ if (mStreamerFinder.containsLocked(channel)) {
+ return;
+ }
+ }
+ streamer.stopStream();
+ TunerHal hal = streamer.getTunerHal();
+ hal.setHasPendingTune(false);
+ mTunerHalManager.releaseTunerHal(hal, sessionId, reuse);
+ }
+
+ void setHasPendingTune(int sessionId) {
+ synchronized (mCancelLock) {
+ if (mCreators.containsKey(sessionId)) {
+ mCreators.get(sessionId).cancelLocked();
+ }
+ }
+ }
+
+ synchronized void release(int sessionId) {
+ mTunerHalManager.releaseCachedHal(sessionId);
+ }
+
+ private class StreamerFinder {
+ private final Map<TunerChannel, Set<Integer>> mSessions = new HashMap<>();
+ private final Map<TunerChannel, TunerTsStreamer> mStreamers = new HashMap<>();
+
+ // @GuardedBy("mCancelLock")
+ private void putLocked(TunerChannel channel, int sessionId, TunerTsStreamer streamer) {
+ Set<Integer> sessions = new HashSet<>();
+ sessions.add(sessionId);
+ mSessions.put(channel, sessions);
+ mStreamers.put(channel, streamer);
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void appendSessionLocked(TunerChannel channel, int sessionId) {
+ if (mSessions.containsKey(channel)) {
+ mSessions.get(channel).add(sessionId);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void removeSessionLocked(TunerChannel channel, int sessionId) {
+ Set<Integer> sessions = mSessions.get(channel);
+ sessions.remove(sessionId);
+ if (sessions.size() == 0) {
+ mSessions.remove(channel);
+ mStreamers.remove(channel);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private boolean containsLocked(TunerChannel channel) {
+ return mSessions.containsKey(channel);
+ }
+
+ // @GuardedBy("mCancelLock")
+ private TunerTsStreamer getStreamerLocked(TunerChannel channel) {
+ return mStreamers.containsKey(channel) ? mStreamers.get(channel) : null;
+ }
+ }
+
+ /**
+ * {@link TunerTsStreamer} creation can be cancelled by a new tune request for the same
+ * session. The class supports the cancellation in creating new {@link TunerTsStreamer}.
+ */
+ private class TsStreamerCreator {
+ private final Context mContext;
+ private final TunerChannel mChannel;
+ private final EventDetector.EventListener mEventListener;
+ // mCancelled will be {@code true} if a new tune request for the same session
+ // cancels create().
+ private boolean mCancelled;
+ private TunerHal mTunerHal;
+
+ private TsStreamerCreator(Context context, TunerChannel channel,
+ EventDetector.EventListener listener) {
+ mContext = context;
+ mChannel = channel;
+ mEventListener = listener;
+ }
+
+ private TunerTsStreamer create(int sessionId, boolean reuse) {
+ TunerHal hal = mTunerHalManager.getOrCreateTunerHal(mContext, sessionId);
+ if (hal == null) {
+ return null;
+ }
+ boolean canceled = false;
+ synchronized (mCancelLock) {
+ if (!mCancelled) {
+ mTunerHal = hal;
+ } else {
+ canceled = true;
+ }
+ }
+ if (!canceled) {
+ TunerTsStreamer tsStreamer = new TunerTsStreamer(hal, mEventListener, mContext);
+ if (tsStreamer.startStream(mChannel)) {
+ return tsStreamer;
+ }
+ synchronized (mCancelLock) {
+ mTunerHal = null;
+ }
+ }
+ hal.setHasPendingTune(false);
+ // Since TunerTsStreamer is not properly created, closes TunerHal.
+ // And do not re-use TunerHal when it is not cancelled.
+ mTunerHalManager.releaseTunerHal(hal, sessionId, mCancelled && reuse);
+ return null;
+ }
+
+ // @GuardedBy("mCancelLock")
+ private void cancelLocked() {
+ if (mCancelled) {
+ return;
+ }
+ mCancelled = true;
+ if (mTunerHal != null) {
+ mTunerHal.setHasPendingTune(true);
+ }
+ }
+
+ // @GuardedBy("mCancelLock")
+ private boolean isCancelledLocked() {
+ return mCancelled;
+ }
+ }
+
+ /**
+ * Supports sharing {@link TunerHal} among multiple sessions.
+ * The class also supports session affinity for {@link TunerHal} allocation.
+ */
+ private class TunerHalManager {
+ private final Map<Integer, TunerHal> mTunerHals = new HashMap<>();
+
+ private TunerHal getOrCreateTunerHal(Context context, int sessionId) {
+ // Handles session affinity.
+ TunerHal hal = mTunerHals.get(sessionId);
+ if (hal != null) {
+ mTunerHals.remove(sessionId);
+ return hal;
+ }
+ // Finds a TunerHal which is cached for other sessions.
+ Iterator it = mTunerHals.keySet().iterator();
+ if (it.hasNext()) {
+ Integer key = (Integer) it.next();
+ hal = mTunerHals.get(key);
+ mTunerHals.remove(key);
+ return hal;
+ }
+ return TunerHal.createInstance(context);
+ }
+
+ private void releaseTunerHal(TunerHal hal, int sessionId, boolean reuse) {
+ if (!reuse) {
+ AutoCloseableUtils.closeQuietly(hal);
+ return;
+ }
+ TunerHal cachedHal = mTunerHals.get(sessionId);
+ if (cachedHal != hal) {
+ mTunerHals.put(sessionId, hal);
+ }
+ if (cachedHal != null && cachedHal != hal) {
+ AutoCloseableUtils.closeQuietly(cachedHal);
+ }
+ }
+
+ private void releaseCachedHal(int sessionId) {
+ TunerHal hal = mTunerHals.get(sessionId);
+ if (hal != null) {
+ mTunerHals.remove(sessionId);
+ }
+ if (hal != null) {
+ AutoCloseableUtils.closeQuietly(hal);
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/tuner/ts/SectionParser.java b/src/com/android/tv/tuner/ts/SectionParser.java
new file mode 100644
index 00000000..5d3e728a
--- /dev/null
+++ b/src/com/android/tv/tuner/ts/SectionParser.java
@@ -0,0 +1,1264 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.ts;
+
+import android.media.tv.TvContentRating;
+import android.media.tv.TvContract.Programs.Genres;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.PsiData.PatItem;
+import com.android.tv.tuner.data.PsiData.PmtItem;
+import com.android.tv.tuner.data.PsipData.Ac3AudioDescriptor;
+import com.android.tv.tuner.data.PsipData.CaptionServiceDescriptor;
+import com.android.tv.tuner.data.PsipData.ContentAdvisoryDescriptor;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.EttItem;
+import com.android.tv.tuner.data.PsipData.ExtendedChannelNameDescriptor;
+import com.android.tv.tuner.data.PsipData.GenreDescriptor;
+import com.android.tv.tuner.data.PsipData.Iso639LanguageDescriptor;
+import com.android.tv.tuner.data.PsipData.MgtItem;
+import com.android.tv.tuner.data.PsipData.PsipSection;
+import com.android.tv.tuner.data.PsipData.RatingRegion;
+import com.android.tv.tuner.data.PsipData.RegionalRating;
+import com.android.tv.tuner.data.PsipData.TsDescriptor;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+
+import com.ibm.icu.text.UnicodeDecompressor;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Parses ATSC PSIP sections.
+ */
+public class SectionParser {
+ private static final String TAG = "SectionParser";
+ private static final boolean DEBUG = false;
+
+ private static final byte TABLE_ID_PAT = (byte) 0x00;
+ private static final byte TABLE_ID_PMT = (byte) 0x02;
+ private static final byte TABLE_ID_MGT = (byte) 0xc7;
+ private static final byte TABLE_ID_TVCT = (byte) 0xc8;
+ private static final byte TABLE_ID_CVCT = (byte) 0xc9;
+ private static final byte TABLE_ID_EIT = (byte) 0xcb;
+ private static final byte TABLE_ID_ETT = (byte) 0xcc;
+
+ // For details of the structure for the tags of descriptors, see ATSC A/65 Table 6.25.
+ public static final int DESCRIPTOR_TAG_ISO639LANGUAGE = 0x0a;
+ public static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86;
+ public static final int DESCRIPTOR_TAG_CONTENT_ADVISORY = 0x87;
+ public static final int DESCRIPTOR_TAG_AC3_AUDIO_STREAM = 0x81;
+ public static final int DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME = 0xa0;
+ public static final int DESCRIPTOR_TAG_GENRE = 0xab;
+
+ private static final byte COMPRESSION_TYPE_NO_COMPRESSION = (byte) 0x00;
+ private static final byte MODE_SELECTED_UNICODE_RANGE_1 = (byte) 0x00; // 0x0000 - 0x00ff
+ private static final byte MODE_UTF16 = (byte) 0x3f;
+ private static final byte MODE_SCSU = (byte) 0x3e;
+ private static final int MAX_SHORT_NAME_BYTES = 14;
+
+ // See ANSI/CEA-766-C.
+ private static final int RATING_REGION_US_TV = 1;
+ private static final int RATING_REGION_KR_TV = 4;
+
+ // The following values are defined in the live channels app.
+ // See https://developer.android.com/reference/android/media/tv/TvContentRating.html.
+ private static final String RATING_REGION_RATING_SYSTEM_US_TV = "US_TV";
+ private static final String RATING_REGION_RATING_SYSTEM_KR_TV = "KR_TV";
+
+ private static final String[] RATING_REGION_TABLE_US_TV = {
+ "US_TV_Y", "US_TV_Y7", "US_TV_G", "US_TV_PG", "US_TV_14", "US_TV_MA"
+ };
+
+ private static final String[] RATING_REGION_TABLE_KR_TV = {
+ "KR_TV_ALL", "KR_TV_7", "KR_TV_12", "KR_TV_15", "KR_TV_19"
+ };
+
+ /*
+ * The following CRC table is from the code generated by the following command.
+ * $ python pycrc.py --model crc-32-mpeg --algorithm table-driven --generate c
+ * To see the details of pycrc, visit http://www.tty1.net/pycrc/index_en.html
+ */
+ public static final int[] CRC_TABLE = {
+ 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9,
+ 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005,
+ 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61,
+ 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd,
+ 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9,
+ 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75,
+ 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011,
+ 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd,
+ 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039,
+ 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5,
+ 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81,
+ 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d,
+ 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49,
+ 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95,
+ 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1,
+ 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d,
+ 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae,
+ 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072,
+ 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16,
+ 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca,
+ 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde,
+ 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02,
+ 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066,
+ 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba,
+ 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e,
+ 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692,
+ 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6,
+ 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a,
+ 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e,
+ 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2,
+ 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686,
+ 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a,
+ 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637,
+ 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb,
+ 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f,
+ 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53,
+ 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47,
+ 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b,
+ 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff,
+ 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623,
+ 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7,
+ 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b,
+ 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f,
+ 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3,
+ 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7,
+ 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b,
+ 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f,
+ 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3,
+ 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640,
+ 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c,
+ 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8,
+ 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24,
+ 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30,
+ 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec,
+ 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088,
+ 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654,
+ 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0,
+ 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c,
+ 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18,
+ 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4,
+ 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0,
+ 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c,
+ 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668,
+ 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4
+ };
+
+ // A table which maps ATSC genres to TIF genres.
+ // See ATSC/65 Table 6.20.
+ private static final String[] CANONICAL_GENRES_TABLE = {
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ Genres.EDUCATION, Genres.ENTERTAINMENT, Genres.MOVIES, Genres.NEWS,
+ Genres.LIFE_STYLE, Genres.SPORTS, null, Genres.MOVIES,
+ null,
+ Genres.FAMILY_KIDS, Genres.DRAMA, null, Genres.ENTERTAINMENT, Genres.SPORTS,
+ Genres.SPORTS,
+ null, null,
+ Genres.MUSIC, Genres.EDUCATION,
+ null,
+ Genres.COMEDY,
+ null,
+ Genres.MUSIC,
+ null, null,
+ Genres.MOVIES, Genres.ENTERTAINMENT, Genres.NEWS, Genres.DRAMA,
+ Genres.EDUCATION, Genres.MOVIES, Genres.SPORTS, Genres.MOVIES,
+ null,
+ Genres.LIFE_STYLE, Genres.ARTS, Genres.LIFE_STYLE, Genres.SPORTS,
+ null, null,
+ Genres.GAMING, Genres.LIFE_STYLE, Genres.SPORTS,
+ null,
+ Genres.LIFE_STYLE, Genres.EDUCATION, Genres.EDUCATION, Genres.LIFE_STYLE,
+ Genres.SPORTS, Genres.LIFE_STYLE, Genres.MOVIES, Genres.NEWS,
+ null, null, null,
+ Genres.EDUCATION,
+ null, null, null,
+ Genres.EDUCATION,
+ null, null, null,
+ Genres.DRAMA, Genres.MUSIC, Genres.MOVIES,
+ null,
+ Genres.ANIMAL_WILDLIFE,
+ null, null,
+ Genres.PREMIER,
+ null, null, null, null,
+ Genres.SPORTS, Genres.ARTS,
+ null, null, null,
+ Genres.MOVIES, Genres.TECH_SCIENCE, Genres.DRAMA,
+ null,
+ Genres.SHOPPING, Genres.DRAMA,
+ null,
+ Genres.MOVIES, Genres.ENTERTAINMENT, Genres.TECH_SCIENCE, Genres.SPORTS,
+ Genres.TRAVEL, Genres.ENTERTAINMENT, Genres.ARTS, Genres.NEWS,
+ null,
+ Genres.ARTS, Genres.SPORTS, Genres.SPORTS, Genres.NEWS,
+ Genres.SPORTS, Genres.SPORTS, Genres.SPORTS, Genres.FAMILY_KIDS,
+ Genres.FAMILY_KIDS, Genres.MOVIES,
+ null,
+ Genres.TECH_SCIENCE, Genres.MUSIC,
+ null,
+ Genres.SPORTS, Genres.FAMILY_KIDS, Genres.NEWS, Genres.SPORTS,
+ Genres.NEWS, Genres.SPORTS, Genres.ANIMAL_WILDLIFE,
+ null,
+ Genres.MUSIC, Genres.NEWS, Genres.SPORTS,
+ null,
+ Genres.NEWS, Genres.NEWS, Genres.NEWS, Genres.NEWS,
+ Genres.SPORTS, Genres.MOVIES, Genres.ARTS, Genres.ANIMAL_WILDLIFE,
+ Genres.MUSIC, Genres.MUSIC, Genres.MOVIES, Genres.EDUCATION,
+ Genres.DRAMA, Genres.SPORTS, Genres.SPORTS, Genres.SPORTS,
+ Genres.SPORTS,
+ null,
+ Genres.SPORTS, Genres.SPORTS,
+ };
+
+ // A table which contains ATSC categorical genre code assignments.
+ // See ATSC/65 Table 6.20.
+ private static final String[] BROADCAST_GENRES_TABLE = new String[] {
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ null, null, null, null,
+ "Education", "Entertainment", "Movie", "News",
+ "Religious", "Sports", "Other", "Action",
+ "Advertisement", "Animated", "Anthology", "Automobile",
+ "Awards", "Baseball", "Basketball", "Bulletin",
+ "Business", "Classical", "College", "Combat",
+ "Comedy", "Commentary", "Concert", "Consumer",
+ "Contemporary", "Crime", "Dance", "Documentary",
+ "Drama", "Elementary", "Erotica", "Exercise",
+ "Fantasy", "Farm", "Fashion", "Fiction",
+ "Food", "Football", "Foreign", "Fund Raiser",
+ "Game/Quiz", "Garden", "Golf", "Government",
+ "Health", "High School", "History", "Hobby",
+ "Hockey", "Home", "Horror", "Information",
+ "Instruction", "International", "Interview", "Language",
+ "Legal", "Live", "Local", "Math",
+ "Medical", "Meeting", "Military", "Miniseries",
+ "Music", "Mystery", "National", "Nature",
+ "Police", "Politics", "Premier", "Prerecorded",
+ "Product", "Professional", "Public", "Racing",
+ "Reading", "Repair", "Repeat", "Review",
+ "Romance", "Science", "Series", "Service",
+ "Shopping", "Soap Opera", "Special", "Suspense",
+ "Talk", "Technical", "Tennis", "Travel",
+ "Variety", "Video", "Weather", "Western",
+ "Art", "Auto Racing", "Aviation", "Biography",
+ "Boating", "Bowling", "Boxing", "Cartoon",
+ "Children", "Classic Film", "Community", "Computers",
+ "Country Music", "Court", "Extreme Sports", "Family",
+ "Financial", "Gymnastics", "Headlines", "Horse Racing",
+ "Hunting/Fishing/Outdoors", "Independent", "Jazz", "Magazine",
+ "Motorcycle Racing", "Music/Film/Books", "News-International", "News-Local",
+ "News-National", "News-Regional", "Olympics", "Original",
+ "Performing Arts", "Pets/Animals", "Pop", "Rock & Roll",
+ "Sci-Fi", "Self Improvement", "Sitcom", "Skating",
+ "Skiing", "Soccer", "Track/Field", "True",
+ "Volleyball", "Wrestling",
+ };
+
+ // Audio language code map from ISO 639-2/B to 639-2/T, in order to show correct audio language.
+ private static final HashMap<String, String> ISO_LANGUAGE_CODE_MAP;
+ static {
+ ISO_LANGUAGE_CODE_MAP = new HashMap<>();
+ ISO_LANGUAGE_CODE_MAP.put("alb", "sqi");
+ ISO_LANGUAGE_CODE_MAP.put("arm", "hye");
+ ISO_LANGUAGE_CODE_MAP.put("baq", "eus");
+ ISO_LANGUAGE_CODE_MAP.put("bur", "mya");
+ ISO_LANGUAGE_CODE_MAP.put("chi", "zho");
+ ISO_LANGUAGE_CODE_MAP.put("cze", "ces");
+ ISO_LANGUAGE_CODE_MAP.put("dut", "nld");
+ ISO_LANGUAGE_CODE_MAP.put("fre", "fra");
+ ISO_LANGUAGE_CODE_MAP.put("geo", "kat");
+ ISO_LANGUAGE_CODE_MAP.put("ger", "deu");
+ ISO_LANGUAGE_CODE_MAP.put("gre", "ell");
+ ISO_LANGUAGE_CODE_MAP.put("ice", "isl");
+ ISO_LANGUAGE_CODE_MAP.put("mac", "mkd");
+ ISO_LANGUAGE_CODE_MAP.put("mao", "mri");
+ ISO_LANGUAGE_CODE_MAP.put("may", "msa");
+ ISO_LANGUAGE_CODE_MAP.put("per", "fas");
+ ISO_LANGUAGE_CODE_MAP.put("rum", "ron");
+ ISO_LANGUAGE_CODE_MAP.put("slo", "slk");
+ ISO_LANGUAGE_CODE_MAP.put("tib", "bod");
+ ISO_LANGUAGE_CODE_MAP.put("wel", "cym");
+ ISO_LANGUAGE_CODE_MAP.put("esl", "spa"); // Special entry for channel 9-1 KQED in bay area.
+ }
+
+ // Containers to store the last version numbers of the PSIP sections.
+ private final HashMap<PsipSection, Integer> mSectionVersionMap = new HashMap<>();
+ private final SparseArray<List<EttItem>> mParsedEttItems = new SparseArray<>();
+
+ public interface OutputListener {
+ void onPatParsed(List<PatItem> items);
+ void onPmtParsed(int programNumber, List<PmtItem> items);
+ void onMgtParsed(List<MgtItem> items);
+ void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber);
+ void onEitParsed(int sourceId, List<EitItem> items);
+ void onEttParsed(int sourceId, List<EttItem> descriptions);
+ }
+
+ private final OutputListener mListener;
+
+ public SectionParser(OutputListener listener) {
+ mListener = listener;
+ }
+
+ public void parseSections(ByteArrayBuffer data) {
+ int pos = 0;
+ while (pos + 3 <= data.length()) {
+ if ((data.byteAt(pos) & 0xff) == 0xff) {
+ // Clear stuffing bytes according to H222.0 section 2.4.4.
+ data.setLength(0);
+ break;
+ }
+ int sectionLength =
+ (((data.byteAt(pos + 1) & 0x0f) << 8) | (data.byteAt(pos + 2) & 0xff)) + 3;
+ if (pos + sectionLength > data.length()) {
+ break;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "parseSections 0x" + Integer.toHexString(data.byteAt(pos) & 0xff));
+ }
+ parseSection(Arrays.copyOfRange(data.buffer(), pos, pos + sectionLength));
+ pos += sectionLength;
+ }
+ if (mListener != null) {
+ for (int i = 0; i < mParsedEttItems.size(); ++i) {
+ int sourceId = mParsedEttItems.keyAt(i);
+ List<EttItem> descriptions = mParsedEttItems.valueAt(i);
+ mListener.onEttParsed(sourceId, descriptions);
+ }
+ }
+ mParsedEttItems.clear();
+ }
+
+ private void parseSection(byte[] data) {
+ if (!checkSanity(data)) {
+ Log.d(TAG, "Bad CRC!");
+ return;
+ }
+ PsipSection section = PsipSection.create(data);
+ if (section == null) {
+ return;
+ }
+
+ // The currentNextIndicator indicates that the section sent is currently applicable.
+ if (!section.getCurrentNextIndicator()) {
+ return;
+ }
+ int versionNumber = (data[5] & 0x3e) >> 1;
+ Integer oldVersionNumber = mSectionVersionMap.get(section);
+
+ // The versionNumber shall be incremented when a change in the information carried within
+ // the section occurs.
+ if (oldVersionNumber != null && versionNumber == oldVersionNumber) {
+ return;
+ }
+ boolean result = false;
+ switch (data[0]) {
+ case TABLE_ID_PAT:
+ result = parsePAT(data);
+ break;
+ case TABLE_ID_PMT:
+ result = parsePMT(data);
+ break;
+ case TABLE_ID_MGT:
+ result = parseMGT(data);
+ break;
+ case TABLE_ID_TVCT:
+ case TABLE_ID_CVCT:
+ result = parseVCT(data);
+ break;
+ case TABLE_ID_EIT:
+ result = parseEIT(data);
+ break;
+ case TABLE_ID_ETT:
+ result = parseETT(data);
+ break;
+ default:
+ break;
+ }
+ if (result) {
+ mSectionVersionMap.put(section, versionNumber);
+ }
+ }
+
+ private boolean parsePAT(byte[] data) {
+ if (DEBUG) {
+ Log.d(TAG, "PAT is discovered.");
+ }
+ int pos = 8;
+
+ List<PatItem> results = new ArrayList<>();
+ for (; pos < data.length - 4; pos = pos + 4) {
+ if (pos > data.length - 4 - 4) {
+ Log.e(TAG, "Broken PAT.");
+ return false;
+ }
+ int programNo = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+ int pmtPid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff);
+ results.add(new PatItem(programNo, pmtPid));
+ }
+ if (mListener != null) {
+ mListener.onPatParsed(results);
+ }
+ return true;
+ }
+
+ private boolean parsePMT(byte[] data) {
+ int table_id_ext = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ if (DEBUG) {
+ Log.d(TAG, "PMT is discovered. programNo = " + table_id_ext);
+ }
+ if (data.length <= 11) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ int pcrPid = (data[8] & 0x1f) << 8 | data[9];
+ int programInfoLen = (data[10] & 0x0f) << 8 | data[11];
+ int pos = 12;
+ List<TsDescriptor> descriptors = parseDescriptors(data, pos, pos + programInfoLen);
+ pos += programInfoLen;
+ if (DEBUG) {
+ Log.d(TAG, "PMT descriptors size: " + descriptors.size());
+ }
+ List<PmtItem> results = new ArrayList<>();
+ for (; pos < data.length - 4;) {
+ if (pos < 0) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ int streamType = data[pos] & 0xff;
+ int esPid = (data[pos + 1] & 0x1f) << 8 | (data[pos + 2] & 0xff);
+ int esInfoLen = (data[pos + 3] & 0xf) << 8 | (data[pos + 4] & 0xff);
+ if (data.length < pos + esInfoLen + 5) {
+ Log.e(TAG, "Broken PMT.");
+ return false;
+ }
+ descriptors = parseDescriptors(data, pos + 5, pos + 5 + esInfoLen);
+ List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors);
+ List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors);
+ PmtItem pmtItem = new PmtItem(streamType, esPid, audioTracks, captionTracks);
+ if (DEBUG) {
+ Log.d(TAG, "PMT " + pmtItem + " descriptors size: " + descriptors.size());
+ }
+ results.add(pmtItem);
+ pos = pos + esInfoLen + 5;
+ }
+ results.add(new PmtItem(PmtItem.ES_PID_PCR, pcrPid, null, null));
+ if (mListener != null) {
+ mListener.onPmtParsed(table_id_ext, results);
+ }
+ return true;
+ }
+
+ private boolean parseMGT(byte[] data) {
+ // For details of the structure for MGT, see ATSC A/65 Table 6.2.
+ if (DEBUG) {
+ Log.d(TAG, "MGT is discovered.");
+ }
+ if (data.length <= 10) {
+ Log.e(TAG, "Broken MGT.");
+ return false;
+ }
+ int tablesDefined = ((data[9] & 0xff) << 8) | (data[10] & 0xff);
+ int pos = 11;
+ List<MgtItem> results = new ArrayList<>();
+ for (int i = 0; i < tablesDefined; ++i) {
+ if (data.length <= pos + 10) {
+ Log.e(TAG, "Broken MGT.");
+ return false;
+ }
+ int tableType = ((data[pos] & 0xff) << 8) | (data[pos + 1] & 0xff);
+ int tableTypePid = ((data[pos + 2] & 0x1f) << 8) | (data[pos + 3] & 0xff);
+ int descriptorsLength = ((data[pos + 9] & 0x0f) << 8) | (data[pos + 10] & 0xff);
+ pos += 11 + descriptorsLength;
+ results.add(new MgtItem(tableType, tableTypePid));
+ }
+ if ((data[pos] & 0xf0) != 0xf0) {
+ Log.e(TAG, "Broken MGT.");
+ return false;
+ }
+ if (mListener != null) {
+ mListener.onMgtParsed(results);
+ }
+ return true;
+ }
+
+ private boolean parseVCT(byte[] data) {
+ // For details of the structure for VCT, see ATSC A/65 Table 6.4 and 6.8.
+ if (DEBUG) {
+ Log.d(TAG, "VCT is discovered.");
+ }
+ if (data.length <= 9) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ int numChannelsInSection = (data[9] & 0xff);
+ int sectionNumber = (data[6] & 0xff);
+ int lastSectionNumber = (data[7] & 0xff);
+ if (sectionNumber > lastSectionNumber) {
+ // According to section 6.3.1 of the spec ATSC A/65,
+ // last section number is the largest section number.
+ Log.w(TAG, "Invalid VCT. Section Number " + sectionNumber + " > Last Section Number "
+ + lastSectionNumber);
+ return false;
+ }
+ int pos = 10;
+ List<VctItem> results = new ArrayList<>();
+ for (int i = 0; i < numChannelsInSection; ++i) {
+ if (data.length <= pos + 31) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ String shortName = "";
+ int shortNameSize = getShortNameSize(data, pos);
+ try {
+ shortName = new String(
+ Arrays.copyOfRange(data, pos, pos + shortNameSize), "UTF-16");
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "Broken VCT.", e);
+ return false;
+ }
+ if ((data[pos + 14] & 0xf0) != 0xf0) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ int majorNumber = ((data[pos + 14] & 0x0f) << 6) | ((data[pos + 15] & 0xff) >> 2);
+ int minorNumber = ((data[pos + 15] & 0x03) << 8) | (data[pos + 16] & 0xff);
+ if ((majorNumber & 0x3f0) == 0x3f0) {
+ // If the six MSBs are 111111, these indicate that there is only one-part channel
+ // number. To see details, refer A/65 Section 6.3.2.
+ majorNumber = ((majorNumber & 0xf) << 10) + minorNumber;
+ minorNumber = 0;
+ }
+ int channelTsid = ((data[pos + 22] & 0xff) << 8) | (data[pos + 23] & 0xff);
+ int programNumber = ((data[pos + 24] & 0xff) << 8) | (data[pos + 25] & 0xff);
+ boolean accessControlled = (data[pos + 26] & 0x20) != 0;
+ boolean hidden = (data[pos + 26] & 0x10) != 0;
+ int serviceType = (data[pos + 27] & 0x3f);
+ int sourceId = ((data[pos + 28] & 0xff) << 8) | (data[pos + 29] & 0xff);
+ int descriptorsPos = pos + 32;
+ int descriptorsLength = ((data[pos + 30] & 0x03) << 8) | (data[pos + 31] & 0xff);
+ pos += 32 + descriptorsLength;
+ if (data.length < pos) {
+ Log.e(TAG, "Broken VCT.");
+ return false;
+ }
+ List<TsDescriptor> descriptors = parseDescriptors(
+ data, descriptorsPos, descriptorsPos + descriptorsLength);
+ String longName = null;
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof ExtendedChannelNameDescriptor) {
+ ExtendedChannelNameDescriptor extendedChannelNameDescriptor =
+ (ExtendedChannelNameDescriptor) descriptor;
+ longName = extendedChannelNameDescriptor.getLongChannelName();
+ break;
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format(
+ "Found channel [%s] %s - serviceType: %d tsid: 0x%x program: %d "
+ + "channel: %d-%d encrypted: %b hidden: %b, descriptors: %d",
+ shortName, longName, serviceType, channelTsid, programNumber, majorNumber,
+ minorNumber, accessControlled, hidden, descriptors.size()));
+ }
+ if (!accessControlled && !hidden && (serviceType == Channel.SERVICE_TYPE_ATSC_AUDIO ||
+ serviceType == Channel.SERVICE_TYPE_ATSC_DIGITAL_TELEVISION ||
+ serviceType == Channel.SERVICE_TYPE_UNASSOCIATED_SMALL_SCREEN_SERVICE)) {
+ // Hide hidden, encrypted, or unsupported ATSC service type channels
+ results.add(new VctItem(shortName, longName, serviceType, channelTsid,
+ programNumber, majorNumber, minorNumber, sourceId));
+ }
+ }
+ // Skip the remaining descriptor part which we don't use.
+
+ if (mListener != null) {
+ mListener.onVctParsed(results, sectionNumber, lastSectionNumber);
+ }
+ return true;
+ }
+
+ private boolean parseEIT(byte[] data) {
+ // For details of the structure for EIT, see ATSC A/65 Table 6.11.
+ if (DEBUG) {
+ Log.d(TAG, "EIT is discovered.");
+ }
+ if (data.length <= 9) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int sourceId = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
+ int numEventsInSection = (data[9] & 0xff);
+
+ int pos = 10;
+ List<EitItem> results = new ArrayList<>();
+ for (int i = 0; i < numEventsInSection; ++i) {
+ if (data.length <= pos + 9) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ if ((data[pos] & 0xc0) != 0xc0) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int eventId = ((data[pos] & 0x3f) << 8) + (data[pos + 1] & 0xff);
+ long startTime = ((data[pos + 2] & (long) 0xff) << 24) | ((data[pos + 3] & 0xff) << 16)
+ | ((data[pos + 4] & 0xff) << 8) | (data[pos + 5] & 0xff);
+ int lengthInSecond = ((data[pos + 6] & 0x0f) << 16)
+ | ((data[pos + 7] & 0xff) << 8) | (data[pos + 8] & 0xff);
+ int titleLength = (data[pos + 9] & 0xff);
+ if (data.length <= pos + 10 + titleLength + 1) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ String titleText = "";
+ if (titleLength > 0) {
+ titleText = extractText(data, pos + 10);
+ }
+ if ((data[pos + 10 + titleLength] & 0xf0) != 0xf0) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ int descriptorsLength = ((data[pos + 10 + titleLength] & 0x0f) << 8)
+ | (data[pos + 10 + titleLength + 1] & 0xff);
+ int descriptorsPos = pos + 10 + titleLength + 2;
+ if (data.length < descriptorsPos + descriptorsLength) {
+ Log.e(TAG, "Broken EIT.");
+ return false;
+ }
+ List<TsDescriptor> descriptors = parseDescriptors(
+ data, descriptorsPos, descriptorsPos + descriptorsLength);
+ if (DEBUG) {
+ Log.d(TAG, String.format("EIT descriptors size: %d", descriptors.size()));
+ }
+ String contentRating = generateContentRating(descriptors);
+ String broadcastGenre = generateBroadcastGenre(descriptors);
+ String canonicalGenre = generateCanonicalGenre(descriptors);
+ List<AtscAudioTrack> audioTracks = generateAudioTracks(descriptors);
+ List<AtscCaptionTrack> captionTracks = generateCaptionTracks(descriptors);
+ pos += 10 + titleLength + 2 + descriptorsLength;
+ results.add(new EitItem(EitItem.INVALID_PROGRAM_ID, eventId, titleText,
+ startTime, lengthInSecond, contentRating, audioTracks, captionTracks,
+ broadcastGenre, canonicalGenre, null));
+ }
+ if (mListener != null) {
+ mListener.onEitParsed(sourceId, results);
+ }
+ return true;
+ }
+
+ private boolean parseETT(byte[] data) {
+ // For details of the structure for ETT, see ATSC A/65 Table 6.13.
+ if (DEBUG) {
+ Log.d(TAG, "ETT is discovered.");
+ }
+ if (data.length <= 12) {
+ Log.e(TAG, "Broken ETT.");
+ return false;
+ }
+ int sourceId = ((data[9] & 0xff) << 8) | (data[10] & 0xff);
+ int eventId = (((data[11] & 0xff) << 8) | (data[12] & 0xff)) >> 2;
+ String text = extractText(data, 13);
+ List<EttItem> ettItems = mParsedEttItems.get(sourceId);
+ if (ettItems == null) {
+ ettItems = new ArrayList<>();
+ mParsedEttItems.put(sourceId, ettItems);
+ }
+ ettItems.add(new EttItem(eventId, text));
+ return true;
+ }
+
+ private static List<AtscAudioTrack> generateAudioTracks(List<TsDescriptor> descriptors) {
+ // The list of audio tracks sent is located at both AC3 Audio descriptor and ISO 639
+ // Language descriptor.
+ List<AtscAudioTrack> ac3Tracks = new ArrayList<>();
+ List<AtscAudioTrack> iso639LanguageTracks = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof Ac3AudioDescriptor) {
+ Ac3AudioDescriptor audioDescriptor =
+ (Ac3AudioDescriptor) descriptor;
+ AtscAudioTrack audioTrack = new AtscAudioTrack();
+ if (audioDescriptor.getLanguage() != null) {
+ audioTrack.language = audioDescriptor.getLanguage();
+ }
+ audioTrack.audioType = AtscAudioTrack.AUDIOTYPE_UNDEFINED;
+ audioTrack.channelCount = audioDescriptor.getNumChannels();
+ audioTrack.sampleRate = audioDescriptor.getSampleRate();
+ ac3Tracks.add(audioTrack);
+ }
+ }
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof Iso639LanguageDescriptor) {
+ Iso639LanguageDescriptor iso639LanguageDescriptor =
+ (Iso639LanguageDescriptor) descriptor;
+ iso639LanguageTracks.addAll(iso639LanguageDescriptor.getAudioTracks());
+ }
+ }
+
+ // An AC3 audio stream descriptor only has a audio channel count and a audio sample rate
+ // while a ISO 639 Language descriptor only has a audio type, which describes a main use
+ // case of its audio track.
+ // Some channels contain only AC3 audio stream descriptors with valid language values.
+ // Other channels contain both an AC3 audio stream descriptor and a ISO 639 Language
+ // descriptor per audio track, and those AC3 audio stream descriptors often have a null
+ // value of language field.
+ // Combines two descriptors into one in order to gather more audio track specific
+ // information as much as possible.
+ List<AtscAudioTrack> tracks = new ArrayList<>();
+ if (!ac3Tracks.isEmpty() && !iso639LanguageTracks.isEmpty()
+ && ac3Tracks.size() != iso639LanguageTracks.size()) {
+ // This shouldn't be happen. In here, it handles two cases. The first case is that the
+ // only one type of descriptors arrives. The second case is that the two types of
+ // descriptors have the same number of tracks.
+ Log.e(TAG, "AC3 audio stream descriptors size != ISO 639 Language descriptors size");
+ return tracks;
+ }
+ int size = Math.max(ac3Tracks.size(), iso639LanguageTracks.size());
+ for (int i = 0; i < size; ++i) {
+ AtscAudioTrack audioTrack = null;
+ if (i < ac3Tracks.size()) {
+ audioTrack = ac3Tracks.get(i);
+ }
+ if (i < iso639LanguageTracks.size()) {
+ if (audioTrack == null) {
+ audioTrack = iso639LanguageTracks.get(i);
+ } else {
+ AtscAudioTrack iso639LanguageTrack = iso639LanguageTracks.get(i);
+ if (audioTrack.language == null || TextUtils.equals(audioTrack.language, "")) {
+ audioTrack.language = iso639LanguageTrack.language;
+ }
+ audioTrack.audioType = iso639LanguageTrack.audioType;
+ }
+ }
+ String language = ISO_LANGUAGE_CODE_MAP.get(audioTrack.language);
+ if (language != null) {
+ audioTrack.language = language;
+ }
+ tracks.add(audioTrack);
+ }
+ return tracks;
+ }
+
+ private static List<AtscCaptionTrack> generateCaptionTracks(List<TsDescriptor> descriptors) {
+ List<AtscCaptionTrack> services = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof CaptionServiceDescriptor) {
+ CaptionServiceDescriptor captionServiceDescriptor =
+ (CaptionServiceDescriptor) descriptor;
+ services.addAll(captionServiceDescriptor.getCaptionTracks());
+ }
+ }
+ return services;
+ }
+
+ private static String generateContentRating(List<TsDescriptor> descriptors) {
+ List<String> contentRatings = new ArrayList<>();
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof ContentAdvisoryDescriptor) {
+ ContentAdvisoryDescriptor contentAdvisoryDescriptor =
+ (ContentAdvisoryDescriptor) descriptor;
+ for (RatingRegion ratingRegion : contentAdvisoryDescriptor.getRatingRegions()) {
+ for (RegionalRating index : ratingRegion.getRegionalRatings()) {
+ String ratingSystem = null;
+ String rating = null;
+ switch (ratingRegion.getName()) {
+ case RATING_REGION_US_TV:
+ ratingSystem = RATING_REGION_RATING_SYSTEM_US_TV;
+ if (index.getDimension() == 0 && index.getRating() >= 0
+ && index.getRating() < RATING_REGION_TABLE_US_TV.length) {
+ rating = RATING_REGION_TABLE_US_TV[index.getRating()];
+ }
+ break;
+ case RATING_REGION_KR_TV:
+ ratingSystem = RATING_REGION_RATING_SYSTEM_KR_TV;
+ if (index.getDimension() == 0 && index.getRating() >= 0
+ && index.getRating() < RATING_REGION_TABLE_KR_TV.length) {
+ rating = RATING_REGION_TABLE_KR_TV[index.getRating()];
+ }
+ break;
+ default:
+ break;
+ }
+ if (ratingSystem != null && rating != null) {
+ contentRatings.add(TvContentRating
+ .createRating("com.android.tv", ratingSystem, rating)
+ .flattenToString());
+ }
+ }
+ }
+ }
+ }
+ return TextUtils.join(",", contentRatings);
+ }
+
+ private static String generateBroadcastGenre(List<TsDescriptor> descriptors) {
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof GenreDescriptor) {
+ GenreDescriptor genreDescriptor =
+ (GenreDescriptor) descriptor;
+ return TextUtils.join(",", genreDescriptor.getBroadcastGenres());
+ }
+ }
+ return null;
+ }
+
+ private static String generateCanonicalGenre(List<TsDescriptor> descriptors) {
+ for (TsDescriptor descriptor : descriptors) {
+ if (descriptor instanceof GenreDescriptor) {
+ GenreDescriptor genreDescriptor =
+ (GenreDescriptor) descriptor;
+ return Genres.encode(genreDescriptor.getCanonicalGenres());
+ }
+ }
+ return null;
+ }
+
+ private static List<TsDescriptor> parseDescriptors(byte[] data, int offset, int limit) {
+ // For details of the structure for descriptors, see ATSC A/65 Section 6.9.
+ List<TsDescriptor> descriptors = new ArrayList<>();
+ if (data.length < limit) {
+ return descriptors;
+ }
+ int pos = offset;
+ while (pos + 1 < limit) {
+ int tag = data[pos] & 0xff;
+ int length = data[pos + 1] & 0xff;
+ if (length <= 0) {
+ break;
+ }
+ if (limit < pos + length + 2) {
+ break;
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("Descriptor tag: %02x", tag));
+ }
+ TsDescriptor descriptor = null;
+ switch (tag) {
+ case DESCRIPTOR_TAG_CONTENT_ADVISORY:
+ descriptor = parseContentAdvisory(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_CAPTION_SERVICE:
+ descriptor = parseCaptionService(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_EXTENDED_CHANNEL_NAME:
+ descriptor = parseLongChannelName(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_GENRE:
+ descriptor = parseGenre(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_AC3_AUDIO_STREAM:
+ descriptor = parseAc3AudioStream(data, pos, pos + length + 2);
+ break;
+
+ case DESCRIPTOR_TAG_ISO639LANGUAGE:
+ descriptor = parseIso639Language(data, pos, pos + length + 2);
+ break;
+
+ default:
+ }
+ if (descriptor != null) {
+ if (DEBUG) {
+ Log.d(TAG, "Descriptor parsed: " + descriptor);
+ }
+ descriptors.add(descriptor);
+ }
+ pos += length + 2;
+ }
+ return descriptors;
+ }
+
+ private static Iso639LanguageDescriptor parseIso639Language(byte[] data, int pos, int limit) {
+ // For the details of the structure of ISO 639 language descriptor,
+ // see ISO13818-1 second edition Section 2.6.18.
+ pos += 2;
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ while (pos + 4 <= limit) {
+ if (limit <= pos + 3) {
+ Log.e(TAG, "Broken Iso639Language.");
+ return null;
+ }
+ String language = new String(data, pos, 3);
+ int audioType = data[pos + 3] & 0xff;
+ AtscAudioTrack audioTrack = new AtscAudioTrack();
+ audioTrack.language = language;
+ audioTrack.audioType = audioType;
+ audioTracks.add(audioTrack);
+ pos += 4;
+ }
+ return new Iso639LanguageDescriptor(audioTracks);
+ }
+
+ private static CaptionServiceDescriptor parseCaptionService(byte[] data, int pos, int limit) {
+ // For the details of the structure of caption service descriptor,
+ // see ATSC A/65 Section 6.9.2.
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken CaptionServiceDescriptor.");
+ return null;
+ }
+ List<AtscCaptionTrack> services = new ArrayList<>();
+ pos += 2;
+ int numberServices = data[pos] & 0x1f;
+ ++pos;
+ if (limit < pos + numberServices * 6) {
+ Log.e(TAG, "Broken CaptionServiceDescriptor.");
+ return null;
+ }
+ for (int i = 0; i < numberServices; ++i) {
+ String language = new String(Arrays.copyOfRange(data, pos, pos + 3));
+ pos += 3;
+ boolean ccType = (data[pos] & 0x80) != 0;
+ if (!ccType) {
+ continue;
+ }
+ int captionServiceNumber = data[pos] & 0x3f;
+ ++pos;
+ boolean easyReader = (data[pos] & 0x80) != 0;
+ boolean wideAspectRatio = (data[pos] & 0x40) != 0;
+ byte[] reserved = new byte[2];
+ reserved[0] = (byte) (data[pos] << 2);
+ reserved[0] |= (byte) ((data[pos + 1] & 0xc0) >>> 6);
+ reserved[1] = (byte) ((data[pos + 1] & 0x3f) << 2);
+ pos += 2;
+ AtscCaptionTrack captionTrack = new AtscCaptionTrack();
+ captionTrack.language = language;
+ captionTrack.serviceNumber = captionServiceNumber;
+ captionTrack.easyReader = easyReader;
+ captionTrack.wideAspectRatio = wideAspectRatio;
+ services.add(captionTrack);
+ }
+ return new CaptionServiceDescriptor(services);
+ }
+
+ private static ContentAdvisoryDescriptor parseContentAdvisory(byte[] data, int pos, int limit) {
+ // For details of the structure for content advisory descriptor, see A/65 Table 6.27.
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int count = data[pos + 2] & 0x3f;
+ pos += 3;
+ List<RatingRegion> ratingRegions = new ArrayList<>();
+ for (int i = 0; i < count; ++i) {
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ List<RegionalRating> indices = new ArrayList<>();
+ int ratingRegion = data[pos] & 0xff;
+ int dimensionCount = data[pos + 1] & 0xff;
+ pos += 2;
+ for (int j = 0; j < dimensionCount; ++j) {
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int dimensionIndex = data[pos] & 0xff;
+ int ratingValue = data[pos + 1] & 0x0f;
+ pos += 2;
+ indices.add(new RegionalRating(dimensionIndex, ratingValue));
+ }
+ if (limit <= pos) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ int ratingDescriptionLength = data[pos] & 0xff;
+ ++pos;
+ if (limit < pos + ratingDescriptionLength) {
+ Log.e(TAG, "Broken ContentAdvisory");
+ return null;
+ }
+ String ratingDescription = extractText(data, pos);
+ pos += ratingDescriptionLength;
+ ratingRegions.add(new RatingRegion(ratingRegion, ratingDescription, indices));
+ }
+ return new ContentAdvisoryDescriptor(ratingRegions);
+ }
+
+ private static ExtendedChannelNameDescriptor parseLongChannelName(byte[] data, int pos,
+ int limit) {
+ if (limit <= pos + 2) {
+ Log.e(TAG, "Broken ExtendedChannelName.");
+ return null;
+ }
+ pos += 2;
+ String text = extractText(data, pos);
+ if (text == null) {
+ Log.e(TAG, "Broken ExtendedChannelName.");
+ return null;
+ }
+ return new ExtendedChannelNameDescriptor(text);
+ }
+
+ private static GenreDescriptor parseGenre(byte[] data, int pos, int limit) {
+ pos += 2;
+ int attributeCount = data[pos] & 0x1f;
+ if (limit <= pos + attributeCount) {
+ Log.e(TAG, "Broken Genre.");
+ return null;
+ }
+ HashSet<String> broadcastGenreSet = new HashSet<>();
+ HashSet<String> canonicalGenreSet = new HashSet<>();
+ for (int i = 0; i < attributeCount; ++i) {
+ ++pos;
+ int genreCode = data[pos] & 0xff;
+ if (genreCode < BROADCAST_GENRES_TABLE.length) {
+ String broadcastGenre = BROADCAST_GENRES_TABLE[genreCode];
+ if (broadcastGenre != null && !broadcastGenreSet.contains(broadcastGenre)) {
+ broadcastGenreSet.add(broadcastGenre);
+ }
+ }
+ if (genreCode < CANONICAL_GENRES_TABLE.length) {
+ String canonicalGenre = CANONICAL_GENRES_TABLE[genreCode];
+ if (canonicalGenre != null && !canonicalGenreSet.contains(canonicalGenre)) {
+ canonicalGenreSet.add(canonicalGenre);
+ }
+ }
+ }
+ return new GenreDescriptor(broadcastGenreSet.toArray(new String[broadcastGenreSet.size()]),
+ canonicalGenreSet.toArray(new String[canonicalGenreSet.size()]));
+ }
+
+ private static TsDescriptor parseAc3AudioStream(byte[] data, int pos, int limit) {
+ // For details of the AC3 audio stream descriptor, see A/52 Table A4.1.
+ if (limit <= pos + 5) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ pos += 2;
+ byte sampleRateCode = (byte) ((data[pos] & 0xe0) >> 5);
+ byte bsid = (byte) (data[pos] & 0x1f);
+ ++pos;
+ byte bitRateCode = (byte) ((data[pos] & 0xfc) >> 2);
+ byte surroundMode = (byte) (data[pos] & 0x03);
+ ++pos;
+ byte bsmod = (byte) ((data[pos] & 0xe0) >> 5);
+ int numChannels = (data[pos] & 0x1e) >> 1;
+ boolean fullSvc = (data[pos] & 0x01) != 0;
+ ++pos;
+ byte langCod = data[pos];
+ byte langCod2 = 0;
+ if (numChannels == 0) {
+ if (limit <= pos) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ ++pos;
+ langCod2 = data[pos];
+ }
+ if (limit <= pos + 1) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor.");
+ return null;
+ }
+ byte mainId = 0;
+ byte priority = 0;
+ byte asvcflags = 0;
+ ++pos;
+ if (bsmod < 2) {
+ mainId = (byte) ((data[pos] & 0xe0) >> 5);
+ priority = (byte) ((data[pos] & 0x18) >> 3);
+ if ((data[pos] & 0x07) != 0x07) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor reserved failed");
+ return null;
+ }
+ } else {
+ asvcflags = data[pos];
+ }
+
+ // See A/52B Table A3.6 num_channels.
+ int numEncodedChannels;
+ switch (numChannels) {
+ case 1:
+ case 8:
+ numEncodedChannels = 1;
+ break;
+ case 2:
+ case 9:
+ numEncodedChannels = 2;
+ break;
+ case 3:
+ case 4:
+ case 10:
+ numEncodedChannels = 3;
+ break;
+ case 5:
+ case 6:
+ case 11:
+ numEncodedChannels = 4;
+ break;
+ case 7:
+ case 12:
+ numEncodedChannels = 5;
+ break;
+ case 13:
+ numEncodedChannels = 6;
+ break;
+ default:
+ numEncodedChannels = 0;
+ break;
+ }
+
+ if (limit <= pos + 1) {
+ Log.w(TAG, "Missing text and language fields on AC3 audio stream descriptor.");
+ return new Ac3AudioDescriptor(sampleRateCode, bsid, bitRateCode, surroundMode, bsmod,
+ numEncodedChannels, fullSvc, langCod, langCod2, mainId, priority, asvcflags,
+ null, null, null);
+ }
+ ++pos;
+ int textLen = (data[pos] & 0xfe) >> 1;
+ boolean textCode = (data[pos] & 0x01) != 0;
+ ++pos;
+ String text = "";
+ if (textLen > 0) {
+ if (limit < pos + textLen) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ if (textCode) {
+ text = new String(data, pos, textLen);
+ } else {
+ text = new String(data, pos, textLen, Charset.forName("UTF-16"));
+ }
+ pos += textLen;
+ }
+ String language = null;
+ String language2 = null;
+ if (pos < limit) {
+ // Many AC3 audio stream descriptors skip the language fields.
+ boolean languageFlag1 = (data[pos] & 0x80) != 0;
+ boolean languageFlag2 = (data[pos] & 0x40) != 0;
+ if ((data[pos] & 0x3f) != 0x3f) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ if (pos + (languageFlag1 ? 3 : 0) + (languageFlag2 ? 3 : 0) > limit) {
+ Log.e(TAG, "Broken AC3 audio stream descriptor");
+ return null;
+ }
+ ++pos;
+ if (languageFlag1) {
+ language = new String(data, pos, 3);
+ pos += 3;
+ }
+ if (languageFlag2) {
+ language2 = new String(data, pos, 3);
+ }
+ }
+
+ return new Ac3AudioDescriptor(sampleRateCode, bsid, bitRateCode, surroundMode, bsmod,
+ numEncodedChannels, fullSvc, langCod, langCod2, mainId, priority, asvcflags, text,
+ language, language2);
+ }
+
+ private static int getShortNameSize(byte[] data, int offset) {
+ for (int i = 0; i < MAX_SHORT_NAME_BYTES; i += 2) {
+ if (data[offset + i] == 0 && data[offset + i + 1] == 0) {
+ return i;
+ }
+ }
+ return MAX_SHORT_NAME_BYTES;
+ }
+
+ private static String extractText(byte[] data, int pos) {
+ if (data.length < pos) {
+ return null;
+ }
+ int numStrings = data[pos] & 0xff;
+ pos++;
+ for (int i = 0; i < numStrings; ++i) {
+ if (data.length <= pos + 3) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ int numSegments = data[pos + 3] & 0xff;
+ pos += 4;
+ for (int j = 0; j < numSegments; ++j) {
+ if (data.length <= pos + 2) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ int compressionType = data[pos] & 0xff;
+ int mode = data[pos + 1] & 0xff;
+ int numBytes = data[pos + 2] & 0xff;
+ if (data.length < pos + 3 + numBytes) {
+ Log.e(TAG, "Broken text.");
+ return null;
+ }
+ byte[] bytes = Arrays.copyOfRange(data, pos + 3, pos + 3 + numBytes);
+ if (compressionType == COMPRESSION_TYPE_NO_COMPRESSION) {
+ try {
+ switch (mode) {
+ case MODE_SELECTED_UNICODE_RANGE_1:
+ return new String(bytes, "ISO-8859-1");
+ case MODE_SCSU:
+ return UnicodeDecompressor.decompress(bytes);
+ case MODE_UTF16:
+ return new String(bytes, "UTF-16");
+ }
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, "Unsupported text format.", e);
+ }
+ }
+ pos += 3 + numBytes;
+ }
+ }
+ return null;
+ }
+
+ private static boolean checkSanity(byte[] data) {
+ if (data.length <= 1) {
+ return false;
+ }
+ boolean hasCRC = (data[1] & 0x80) != 0; // section_syntax_indicator
+ if (hasCRC) {
+ int crc = 0xffffffff;
+ for(byte b : data) {
+ int index = ((crc >> 24) ^ (b & 0xff)) & 0xff;
+ crc = CRC_TABLE[index] ^ (crc << 8);
+ }
+ if(crc != 0){
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/tv/tuner/ts/TsParser.java b/src/com/android/tv/tuner/ts/TsParser.java
new file mode 100644
index 00000000..c24c2a21
--- /dev/null
+++ b/src/com/android/tv/tuner/ts/TsParser.java
@@ -0,0 +1,454 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.ts;
+
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.tv.tuner.data.PsiData.PatItem;
+import com.android.tv.tuner.data.PsiData.PmtItem;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.EttItem;
+import com.android.tv.tuner.data.PsipData.MgtItem;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.SectionParser.OutputListener;
+import com.android.tv.tuner.util.ByteArrayBuffer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeSet;
+
+/**
+ * Parses MPEG-2 TS packets.
+ */
+public class TsParser {
+ private static final String TAG = "TsParser";
+ private static final boolean DEBUG = false;
+
+ public static final int ATSC_SI_BASE_PID = 0x1ffb;
+ public static final int PAT_PID = 0x0000;
+ private static final int TS_PACKET_START_CODE = 0x47;
+ private static final int TS_PACKET_TEI_MASK = 0x80;
+ private static final int TS_PACKET_SIZE = 188;
+
+ /*
+ * Using a SparseArray removes the need to auto box the int key for mStreamMap
+ * in feedTdPacket which is called 100 times a second. This greatly reduces the
+ * number of objects created and the frequency of garbage collection.
+ * Other maps might be suitable for a SparseArray, but the performance
+ * trade offs must be considered carefully.
+ * mStreamMap is the only one called at such a high rate.
+ */
+ private final SparseArray<Stream> mStreamMap = new SparseArray<>();
+ private final Map<Integer, VctItem> mSourceIdToVctItemMap = new HashMap<>();
+ private final Map<Integer, String> mSourceIdToVctItemDescriptionMap = new HashMap<>();
+ private final Map<Integer, VctItem> mProgramNumberToVctItemMap = new HashMap<>();
+ private final Map<Integer, List<PmtItem>> mProgramNumberToPMTMap = new HashMap<>();
+ private final Map<Integer, List<EitItem>> mSourceIdToEitMap = new HashMap<>();
+ private final Map<EventSourceEntry, List<EitItem>> mEitMap = new HashMap<>();
+ private final Map<EventSourceEntry, List<EttItem>> mETTMap = new HashMap<>();
+ private final TreeSet<Integer> mEITPids = new TreeSet<>();
+ private final TreeSet<Integer> mETTPids = new TreeSet<>();
+ private final SparseBooleanArray mProgramNumberHandledStatus = new SparseBooleanArray();
+ private final SparseBooleanArray mVctItemHandledStatus = new SparseBooleanArray();
+ private final TsOutputListener mListener;
+
+ private int mVctItemCount;
+ private int mHandledVctItemCount;
+ private int mVctSectionParsedCount;
+ private boolean[] mVctSectionParsed;
+
+ public interface TsOutputListener {
+ void onPatDetected(List<PatItem> items);
+ void onEitPidDetected(int pid);
+ void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems);
+ void onEitItemParsed(VctItem channel, List<EitItem> items);
+ void onEttPidDetected(int pid);
+ void onAllVctItemsParsed();
+ }
+
+ private abstract class Stream {
+ private static final int INVALID_CONTINUITY_COUNTER = -1;
+ private static final int NUM_CONTINUITY_COUNTER = 16;
+
+ protected int mContinuityCounter = INVALID_CONTINUITY_COUNTER;
+ protected final ByteArrayBuffer mPacket = new ByteArrayBuffer(TS_PACKET_SIZE);
+
+ public void feedData(byte[] data, int continuityCounter, boolean startIndicator) {
+ if ((mContinuityCounter + 1) % NUM_CONTINUITY_COUNTER != continuityCounter) {
+ mPacket.setLength(0);
+ }
+ mContinuityCounter = continuityCounter;
+ handleData(data, startIndicator);
+ }
+
+ protected abstract void handleData(byte[] data, boolean startIndicator);
+ }
+
+ private class SectionStream extends Stream {
+ private final SectionParser mSectionParser;
+ private final int mPid;
+
+ public SectionStream(int pid) {
+ mPid = pid;
+ mSectionParser = new SectionParser(mSectionListener);
+ }
+
+ @Override
+ protected void handleData(byte[] data, boolean startIndicator) {
+ int startPos = 0;
+ if (mPacket.length() == 0) {
+ if (startIndicator) {
+ startPos = (data[0] & 0xff) + 1;
+ } else {
+ // Don't know where the section starts yet. Wait until start indicator is on.
+ return;
+ }
+ } else {
+ if (startIndicator) {
+ startPos = 1;
+ }
+ }
+
+ // When a broken packet is encountered, parsing will stop and return right away.
+ if (startPos >= data.length) {
+ mPacket.setLength(0);
+ return;
+ }
+ mPacket.append(data, startPos, data.length - startPos);
+ mSectionParser.parseSections(mPacket);
+ }
+
+ private final OutputListener mSectionListener = new OutputListener() {
+ @Override
+ public void onPatParsed(List<PatItem> items) {
+ for (PatItem i : items) {
+ startListening(i.getPmtPid());
+ }
+ if (mListener != null) {
+ mListener.onPatDetected(items);
+ }
+ }
+
+ @Override
+ public void onPmtParsed(int programNumber, List<PmtItem> items) {
+ mProgramNumberToPMTMap.put(programNumber, items);
+ if (DEBUG) {
+ Log.d(TAG, "onPMTParsed, programNo " + programNumber + " handledStatus is "
+ + mProgramNumberHandledStatus.get(programNumber, false));
+ }
+ int statusIndex = mProgramNumberHandledStatus.indexOfKey(programNumber);
+ if (statusIndex < 0) {
+ mProgramNumberHandledStatus.put(programNumber, false);
+ }
+ if (!mProgramNumberHandledStatus.get(programNumber)) {
+ VctItem vctItem = mProgramNumberToVctItemMap.get(programNumber);
+ if (vctItem != null) {
+ // When PMT is parsed later than VCT.
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleVctItem(vctItem, items);
+ mHandledVctItemCount++;
+ if (mHandledVctItemCount >= mVctItemCount
+ && mVctSectionParsedCount >= mVctSectionParsed.length
+ && mListener != null) {
+ mListener.onAllVctItemsParsed();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onMgtParsed(List<MgtItem> items) {
+ for (MgtItem i : items) {
+ if (mStreamMap.get(i.getTableTypePid()) != null) {
+ continue;
+ }
+ if (i.getTableType() >= MgtItem.TABLE_TYPE_EIT_RANGE_START
+ && i.getTableType() <= MgtItem.TABLE_TYPE_EIT_RANGE_END) {
+ startListening(i.getTableTypePid());
+ mEITPids.add(i.getTableTypePid());
+ if (mListener != null) {
+ mListener.onEitPidDetected(i.getTableTypePid());
+ }
+ } else if (i.getTableType() == MgtItem.TABLE_TYPE_CHANNEL_ETT ||
+ (i.getTableType() >= MgtItem.TABLE_TYPE_ETT_RANGE_START
+ && i.getTableType() <= MgtItem.TABLE_TYPE_ETT_RANGE_END)) {
+ startListening(i.getTableTypePid());
+ mETTPids.add(i.getTableTypePid());
+ if (mListener != null) {
+ mListener.onEttPidDetected(i.getTableTypePid());
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onVctParsed(List<VctItem> items, int sectionNumber, int lastSectionNumber) {
+ if (mVctSectionParsed == null) {
+ mVctSectionParsed = new boolean[lastSectionNumber + 1];
+ } else if (mVctSectionParsed[sectionNumber]) {
+ // The current section was handled before.
+ if (DEBUG) {
+ Log.d(TAG, "Duplicate VCT section found.");
+ }
+ return;
+ }
+ mVctSectionParsed[sectionNumber] = true;
+ mVctSectionParsedCount++;
+ mVctItemCount += items.size();
+ for (VctItem i : items) {
+ if (DEBUG) Log.d(TAG, "onVCTParsed " + i);
+ if (i.getSourceId() != 0) {
+ mSourceIdToVctItemMap.put(i.getSourceId(), i);
+ i.setDescription(mSourceIdToVctItemDescriptionMap.get(i.getSourceId()));
+ }
+ int programNumber = i.getProgramNumber();
+ mProgramNumberToVctItemMap.put(programNumber, i);
+ List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber);
+ if (pmtList != null) {
+ mProgramNumberHandledStatus.put(programNumber, true);
+ handleVctItem(i, pmtList);
+ mHandledVctItemCount++;
+ if (mHandledVctItemCount >= mVctItemCount
+ && mVctSectionParsedCount >= mVctSectionParsed.length
+ && mListener != null) {
+ mListener.onAllVctItemsParsed();
+ }
+ } else {
+ mProgramNumberHandledStatus.put(programNumber, false);
+ Log.i(TAG, "onVCTParsed, but PMT for programNo " + programNumber
+ + " is not found yet.");
+ }
+ }
+ }
+
+ @Override
+ public void onEitParsed(int sourceId, List<EitItem> items) {
+ if (DEBUG) Log.d(TAG, "onEITParsed " + sourceId);
+ EventSourceEntry entry = new EventSourceEntry(mPid, sourceId);
+ mEitMap.put(entry, items);
+ handleEvents(sourceId);
+ }
+
+ @Override
+ public void onEttParsed(int sourceId, List<EttItem> descriptions) {
+ if (DEBUG) {
+ Log.d(TAG, String.format("onETTParsed sourceId: %d, descriptions.size(): %d",
+ sourceId, descriptions.size()));
+ }
+ for (EttItem item : descriptions) {
+ if (item.eventId == 0) {
+ // Channel description
+ mSourceIdToVctItemDescriptionMap.put(sourceId, item.text);
+ VctItem vctItem = mSourceIdToVctItemMap.get(sourceId);
+ if (vctItem != null) {
+ vctItem.setDescription(item.text);
+ List<PmtItem> pmtItems =
+ mProgramNumberToPMTMap.get(vctItem.getProgramNumber());
+ if (pmtItems != null) {
+ handleVctItem(vctItem, pmtItems);
+ }
+ }
+ }
+ }
+
+ // Event Information description
+ EventSourceEntry entry = new EventSourceEntry(mPid, sourceId);
+ mETTMap.put(entry, descriptions);
+ handleEvents(sourceId);
+ }
+ };
+ }
+
+ private static class EventSourceEntry {
+ public final int pid;
+ public final int sourceId;
+
+ public EventSourceEntry(int pid, int sourceId) {
+ this.pid = pid;
+ this.sourceId = sourceId;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + pid;
+ result = 31 * result + sourceId;
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof EventSourceEntry) {
+ EventSourceEntry another = (EventSourceEntry) obj;
+ return pid == another.pid && sourceId == another.sourceId;
+ }
+ return false;
+ }
+ }
+
+ private void handleVctItem(VctItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "handleVctItem " + channel);
+ }
+ if (mListener != null) {
+ mListener.onVctItemParsed(channel, pmtItems);
+ }
+ int sourceId = channel.getSourceId();
+ int statusIndex = mVctItemHandledStatus.indexOfKey(sourceId);
+ if (statusIndex < 0) {
+ mVctItemHandledStatus.put(sourceId, false);
+ return;
+ }
+ if (!mVctItemHandledStatus.valueAt(statusIndex)) {
+ List<EitItem> eitItems = mSourceIdToEitMap.get(sourceId);
+ if (eitItems != null) {
+ // When VCT is parsed later than EIT.
+ mVctItemHandledStatus.put(sourceId, true);
+ handleEitItems(channel, eitItems);
+ }
+ }
+ }
+
+ private void handleEitItems(VctItem channel, List<EitItem> items) {
+ if (mListener != null) {
+ mListener.onEitItemParsed(channel, items);
+ }
+ }
+
+ private void handleEvents(int sourceId) {
+ Map<Integer, EitItem> itemSet = new HashMap<>();
+ for (int pid : mEITPids) {
+ List<EitItem> eitItems = mEitMap.get(new EventSourceEntry(pid, sourceId));
+ if (eitItems != null) {
+ for (EitItem item : eitItems) {
+ item.setDescription(null);
+ itemSet.put(item.getEventId(), item);
+ }
+ }
+ }
+ for (int pid : mETTPids) {
+ List<EttItem> ettItems = mETTMap.get(new EventSourceEntry(pid, sourceId));
+ if (ettItems != null) {
+ for (EttItem ettItem : ettItems) {
+ if (ettItem.eventId != 0) {
+ EitItem item = itemSet.get(ettItem.eventId);
+ if (item != null) {
+ item.setDescription(ettItem.text);
+ }
+ }
+ }
+ }
+ }
+ List<EitItem> items = new ArrayList<>(itemSet.values());
+ mSourceIdToEitMap.put(sourceId, items);
+ VctItem channel = mSourceIdToVctItemMap.get(sourceId);
+ if (channel != null && mProgramNumberHandledStatus.get(channel.getProgramNumber())) {
+ mVctItemHandledStatus.put(sourceId, true);
+ handleEitItems(channel, items);
+ } else {
+ mVctItemHandledStatus.put(sourceId, false);
+ Log.i(TAG, "onEITParsed, but VCT for sourceId " + sourceId + " is not found yet.");
+ }
+ }
+
+ /**
+ * Creates MPEG-2 TS parser.
+ * @param listener TsOutputListener
+ */
+ public TsParser(TsOutputListener listener) {
+ startListening(ATSC_SI_BASE_PID);
+ startListening(PAT_PID);
+ mListener = listener;
+ }
+
+ private void startListening(int pid) {
+ mStreamMap.put(pid, new SectionStream(pid));
+ }
+
+ private boolean feedTSPacket(byte[] tsData, int pos) {
+ if (tsData.length < pos + TS_PACKET_SIZE) {
+ if (DEBUG) Log.d(TAG, "Data should include a single TS packet.");
+ return false;
+ }
+ if (tsData[pos] != TS_PACKET_START_CODE) {
+ if (DEBUG) Log.d(TAG, "Invalid ts packet.");
+ return false;
+ }
+ if ((tsData[pos + 1] & TS_PACKET_TEI_MASK) != 0) {
+ if (DEBUG) Log.d(TAG, "Erroneous ts packet.");
+ return false;
+ }
+
+ // For details for the structure of TS packet, see H.222.0 Table 2-2.
+ int pid = ((tsData[pos + 1] & 0x1f) << 8) | (tsData[pos + 2] & 0xff);
+ boolean hasAdaptation = (tsData[pos + 3] & 0x20) != 0;
+ boolean hasPayload = (tsData[pos + 3] & 0x10) != 0;
+ boolean payloadStartIndicator = (tsData[pos + 1] & 0x40) != 0;
+ int continuityCounter = tsData[pos + 3] & 0x0f;
+ Stream stream = mStreamMap.get(pid);
+ int payloadPos = pos;
+ payloadPos += hasAdaptation ? 5 + (tsData[pos + 4] & 0xff) : 4;
+ if (!hasPayload || stream == null) {
+ // We are not interested in this packet.
+ return false;
+ }
+ if (payloadPos > pos + TS_PACKET_SIZE) {
+ if (DEBUG) Log.d(TAG, "Payload should be included in a single TS packet.");
+ return false;
+ }
+ stream.feedData(Arrays.copyOfRange(tsData, payloadPos, pos + TS_PACKET_SIZE),
+ continuityCounter, payloadStartIndicator);
+ return true;
+ }
+
+ /**
+ * Feeds MPEG-2 TS data to parse.
+ * @param tsData buffer for ATSC TS stream
+ * @param pos the offset where buffer starts
+ * @param length The length of available data
+ */
+ public void feedTSData(byte[] tsData, int pos, int length) {
+ for (; pos <= length - TS_PACKET_SIZE; pos += TS_PACKET_SIZE) {
+ feedTSPacket(tsData, pos);
+ }
+ }
+
+ /**
+ * Retrieves the channel information regardless of being well-formed.
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ List<TunerChannel> incompleteChannels = new ArrayList<>();
+ for (int i = 0; i < mProgramNumberHandledStatus.size(); i++) {
+ if (!mProgramNumberHandledStatus.valueAt(i)) {
+ int programNumber = mProgramNumberHandledStatus.keyAt(i);
+ List<PmtItem> pmtList = mProgramNumberToPMTMap.get(programNumber);
+ if (pmtList != null) {
+ TunerChannel tunerChannel = new TunerChannel(programNumber, pmtList);
+ incompleteChannels.add(tunerChannel);
+ }
+ }
+ }
+ return incompleteChannels;
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/ChannelDataManager.java b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
new file mode 100644
index 00000000..a16bc522
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/ChannelDataManager.java
@@ -0,0 +1,706 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.content.ComponentName;
+import android.content.ContentProviderOperation;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.OperationApplicationException;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.RemoteException;
+import android.support.annotation.Nullable;
+import android.text.format.DateUtils;
+import android.util.Log;
+
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.util.ConvertUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Manages the channel info and EPG data through {@link TvInputManager}.
+ */
+public class ChannelDataManager implements Handler.Callback {
+ private static final String TAG = "ChannelDataManager";
+
+ private static final String[] ALL_PROGRAMS_SELECTION_ARGS = new String[] {
+ TvContract.Programs._ID,
+ TvContract.Programs.COLUMN_TITLE,
+ TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_CONTENT_RATING,
+ TvContract.Programs.COLUMN_BROADCAST_GENRE,
+ TvContract.Programs.COLUMN_CANONICAL_GENRE,
+ TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ TvContract.Programs.COLUMN_VERSION_NUMBER };
+ private static final String[] CHANNEL_DATA_SELECTION_ARGS = new String[] {
+ TvContract.Channels._ID,
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA,
+ TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1};
+
+ private static final int MSG_HANDLE_EVENTS = 1;
+ private static final int MSG_HANDLE_CHANNEL = 2;
+ private static final int MSG_BUILD_CHANNEL_MAP = 3;
+ private static final int MSG_REQUEST_PROGRAMS = 4;
+ private static final int MSG_CLEAR_CHANNELS = 6;
+ private static final int MSG_CHECK_VERSION = 7;
+
+ // Throttle the batch operations to avoid TransactionTooLargeException.
+ private static final int BATCH_OPERATION_COUNT = 100;
+ // At most 16 days of program information is delivered through an EIT,
+ // according to the Chapter 6.4 of ATSC Recommended Practice A/69.
+ private static final long PROGRAM_QUERY_DURATION = TimeUnit.DAYS.toMillis(16);
+
+ /**
+ * A version number to enforce consistency of the channel data.
+ *
+ * WARNING: If a change in the database serialization lead to breaking the backward
+ * compatibility, you must increment this value so that the old data are purged,
+ * and the user is requested to perform the auto-scan again to generate the new data set.
+ */
+ private static final int VERSION = 6;
+
+ private final Context mContext;
+ private final String mInputId;
+ private ProgramInfoListener mListener;
+ private ChannelScanListener mChannelScanListener;
+ private Handler mChannelScanHandler;
+ private final HandlerThread mHandlerThread;
+ private final Handler mHandler;
+ private final ConcurrentHashMap<Long, TunerChannel> mTunerChannelMap;
+ private final ConcurrentSkipListMap<TunerChannel, Long> mTunerChannelIdMap;
+ private final Uri mChannelsUri;
+
+ // Used for scanning
+ private final ConcurrentSkipListSet<TunerChannel> mScannedChannels;
+ private final ConcurrentSkipListSet<TunerChannel> mPreviousScannedChannels;
+ private final AtomicBoolean mIsScanning;
+ private final AtomicBoolean scanCompleted = new AtomicBoolean();
+
+ public interface ProgramInfoListener {
+
+ /**
+ * Invoked when a request for getting programs of a channel has been processed and passes
+ * the requested channel and the programs retrieved from database to the listener.
+ */
+ void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs);
+
+ /**
+ * Invoked when programs of a channel have been arrived and passes the arrived channel and
+ * programs to the listener.
+ */
+ void onProgramsArrived(TunerChannel channel, List<EitItem> programs);
+
+ /**
+ * Invoked when a channel has been arrived and passes the arrived channel to the listener.
+ */
+ void onChannelArrived(TunerChannel channel);
+
+ /**
+ * Invoked when the database schema has been changed and the old-format channels have been
+ * deleted. A receiver should notify to a user that re-scanning channels is necessary.
+ */
+ void onRescanNeeded();
+ }
+
+ public interface ChannelScanListener {
+ /**
+ * Invoked when all pending channels have been handled.
+ */
+ void onChannelHandlingDone();
+ }
+
+ public ChannelDataManager(Context context) {
+ mContext = context;
+ mInputId = TvContract.buildInputId(new ComponentName(mContext.getPackageName(),
+ TunerTvInputService.class.getName()));
+ mChannelsUri = TvContract.buildChannelsUriForInput(mInputId);
+ mTunerChannelMap = new ConcurrentHashMap<>();
+ mTunerChannelIdMap = new ConcurrentSkipListMap<>();
+ mHandlerThread = new HandlerThread("TvInputServiceBackgroundThread");
+ mHandlerThread.start();
+ mHandler = new Handler(mHandlerThread.getLooper(), this);
+ mIsScanning = new AtomicBoolean();
+ mScannedChannels = new ConcurrentSkipListSet<>();
+ mPreviousScannedChannels = new ConcurrentSkipListSet<>();
+ }
+
+ // Public methods
+ public void checkDataVersion(Context context) {
+ int version = TunerPreferences.getChannelDataVersion(context);
+ Log.d(TAG, "ChannelDataManager.VERSION=" + VERSION + " (current=" + version + ")");
+ if (version == VERSION) {
+ // Everything is awesome. Return and continue.
+ return;
+ }
+ setCurrentVersion(context);
+
+ if (version == TunerPreferences.CHANNEL_DATA_VERSION_NOT_SET) {
+ mHandler.sendEmptyMessage(MSG_CHECK_VERSION);
+ } else {
+ // The stored channel data seem outdated. Delete them all.
+ mHandler.sendEmptyMessage(MSG_CLEAR_CHANNELS);
+ }
+ }
+
+ public void setCurrentVersion(Context context) {
+ TunerPreferences.setChannelDataVersion(context, VERSION);
+ }
+
+ public void setListener(ProgramInfoListener listener) {
+ mListener = listener;
+ }
+
+ public void setChannelScanListener(ChannelScanListener listener, Handler handler) {
+ mChannelScanListener = listener;
+ mChannelScanHandler = handler;
+ }
+
+ public void release() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandlerThread.quitSafely();
+ }
+
+ public void releaseSafely() {
+ mHandlerThread.quitSafely();
+ }
+
+ public TunerChannel getChannel(long channelId) {
+ TunerChannel channel = mTunerChannelMap.get(channelId);
+ if (channel != null) {
+ return channel;
+ }
+ mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP);
+ byte[] data = null;
+ try (Cursor cursor = mContext.getContentResolver().query(TvContract.buildChannelUri(
+ channelId), CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ data = cursor.getBlob(1);
+ }
+ }
+ if (data == null) {
+ return null;
+ }
+ channel = TunerChannel.parseFrom(data);
+ if (channel == null) {
+ return null;
+ }
+ channel.setChannelId(channelId);
+ return channel;
+ }
+
+ public void requestProgramsData(TunerChannel channel) {
+ mHandler.removeMessages(MSG_REQUEST_PROGRAMS);
+ mHandler.obtainMessage(MSG_REQUEST_PROGRAMS, channel).sendToTarget();
+ }
+
+ public void notifyEventDetected(TunerChannel channel, List<EitItem> items) {
+ mHandler.obtainMessage(MSG_HANDLE_EVENTS, new ChannelEvent(channel, items)).sendToTarget();
+ }
+
+ public void notifyChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ if (mIsScanning.get()) {
+ // During scanning, channels should be handle first to improve scan time.
+ // EIT items can be handled in background after channel scan.
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel));
+ } else {
+ mHandler.obtainMessage(MSG_HANDLE_CHANNEL, channel).sendToTarget();
+ }
+ }
+
+ // For scanning process
+ /**
+ * Invoked when starting a scanning mode. This method gets the previous channels to detect the
+ * obsolete channels after scanning and initializes the variables used for scanning.
+ */
+ public void notifyScanStarted() {
+ mScannedChannels.clear();
+ mPreviousScannedChannels.clear();
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long channelId = cursor.getLong(0);
+ byte[] data = cursor.getBlob(1);
+ TunerChannel channel = TunerChannel.parseFrom(data);
+ if (channel != null) {
+ channel.setChannelId(channelId);
+ mPreviousScannedChannels.add(channel);
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ mIsScanning.set(true);
+ }
+
+ /**
+ * Invoked when completing the scanning mode. Passes {@code MSG_SCAN_COMPLETED} to the handler
+ * in order to wait for finishing the remaining messages in the handler queue. Then removes the
+ * obsolete channels, which are previously scanned but are not in the current scanned result.
+ */
+ public void notifyScanCompleted() {
+ // Send a dummy message to check whether there is any MSG_HANDLE_CHANNEL in queue
+ // and avoid race conditions.
+ scanCompleted.set(true);
+ mHandler.sendMessageAtFrontOfQueue(mHandler.obtainMessage(MSG_HANDLE_CHANNEL, null));
+ }
+
+ public void scannedChannelHandlingCompleted() {
+ mIsScanning.set(false);
+ if (!mPreviousScannedChannels.isEmpty()) {
+ ArrayList<ContentProviderOperation> ops = new ArrayList<>();
+ for (TunerChannel channel : mPreviousScannedChannels) {
+ ops.add(ContentProviderOperation.newDelete(
+ TvContract.buildChannelUri(channel.getChannelId())).build());
+ }
+ try {
+ mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, ops);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.e(TAG, "Error deleting obsolete channels", e);
+ }
+ }
+ if (mChannelScanListener != null && mChannelScanHandler != null) {
+ mChannelScanHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mChannelScanListener.onChannelHandlingDone();
+ }
+ });
+ } else {
+ Log.e(TAG, "Error. mChannelScanListener is null.");
+ }
+ }
+
+ /**
+ * Returns the number of scanned channels in the scanning mode.
+ */
+ public int getScannedChannelCount() {
+ return mScannedChannels.size();
+ }
+
+ /**
+ * Removes all callbacks and messages in handler to avoid previous messages from last channel.
+ */
+ public void removeAllCallbacksAndMessages() {
+ mHandler.removeCallbacksAndMessages(null);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_HANDLE_EVENTS: {
+ ChannelEvent event = (ChannelEvent) msg.obj;
+ handleEvents(event.channel, event.eitItems);
+ return true;
+ }
+ case MSG_HANDLE_CHANNEL: {
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (channel != null) {
+ handleChannel(channel);
+ }
+ if (scanCompleted.get() && mIsScanning.get()
+ && !mHandler.hasMessages(MSG_HANDLE_CHANNEL)) {
+ // Complete the scan when all found channels have already been handled.
+ scannedChannelHandlingCompleted();
+ }
+ return true;
+ }
+ case MSG_BUILD_CHANNEL_MAP: {
+ mHandler.removeMessages(MSG_BUILD_CHANNEL_MAP);
+ buildChannelMap();
+ return true;
+ }
+ case MSG_REQUEST_PROGRAMS: {
+ if (mHandler.hasMessages(MSG_REQUEST_PROGRAMS)) {
+ return true;
+ }
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (mListener != null) {
+ mListener.onRequestProgramsResponse(channel, getAllProgramsForChannel(channel));
+ }
+ return true;
+ }
+ case MSG_CLEAR_CHANNELS: {
+ clearChannels();
+ return true;
+ }
+ case MSG_CHECK_VERSION: {
+ checkVersion();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Private methods
+ private void handleEvents(TunerChannel channel, List<EitItem> items) {
+ long channelId = getChannelId(channel);
+ if (channelId <= 0) {
+ return;
+ }
+ channel.setChannelId(channelId);
+
+ // Schedule the audio and caption tracks of the current program and the programs being
+ // listed after the current one into TIS.
+ if (mListener != null) {
+ mListener.onProgramsArrived(channel, items);
+ }
+
+ long currentTime = System.currentTimeMillis();
+ List<EitItem> oldItems = getAllProgramsForChannel(channel, currentTime,
+ currentTime + PROGRAM_QUERY_DURATION);
+ ArrayList<ContentProviderOperation> ops = new ArrayList<>();
+ // TODO: Find a right way to check if the programs are added outside.
+ boolean addedOutside = false;
+ for (EitItem item : oldItems) {
+ if (item.getEventId() == 0) {
+ // The event has been added outside TV tuner.
+ addedOutside = true;
+ break;
+ }
+ }
+
+ // Inserting programs only when there is no overlapping with existing data assuming that:
+ // 1. external EPG is more accurate and rich and
+ // 2. the data we add here will be updated when we apply external EPG.
+ if (addedOutside) {
+ // oldItemCount cannot be 0 if addedOutside is true.
+ int oldItemCount = oldItems.size();
+ for (EitItem newItem : items) {
+ if (newItem.getEndTimeUtcMillis() < currentTime) {
+ continue;
+ }
+ long newItemStartTime = newItem.getStartTimeUtcMillis();
+ long newItemEndTime = newItem.getEndTimeUtcMillis();
+ if (newItemStartTime < oldItems.get(0).getStartTimeUtcMillis()) {
+ // Start time smaller than that of any old items. Insert if no overlap.
+ if (newItemEndTime > oldItems.get(0).getStartTimeUtcMillis()) continue;
+ } else if (newItemStartTime
+ > oldItems.get(oldItemCount - 1).getStartTimeUtcMillis()) {
+ // Start time larger than that of any old item. Insert if no overlap.
+ if (newItemStartTime
+ < oldItems.get(oldItemCount - 1).getEndTimeUtcMillis()) continue;
+ } else {
+ int pos = Collections.binarySearch(oldItems, newItem,
+ new Comparator<EitItem>() {
+ @Override
+ public int compare(EitItem lhs, EitItem rhs) {
+ return Long.compare(lhs.getStartTimeUtcMillis(),
+ rhs.getStartTimeUtcMillis());
+ }
+ });
+ if (pos >= 0) {
+ // Same start Time found. Overlapped.
+ continue;
+ }
+ int insertPoint = -1 - pos;
+ // Check the two adjacent items.
+ if (newItemStartTime < oldItems.get(insertPoint - 1).getEndTimeUtcMillis()
+ || newItemEndTime > oldItems.get(insertPoint).getStartTimeUtcMillis()) {
+ continue;
+ }
+ }
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert(
+ TvContract.Programs.CONTENT_URI), newItem, channel.getChannelId()));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+ applyBatch(channel.getName(), ops);
+ return;
+ }
+
+ List<EitItem> outdatedOldItems = new ArrayList<>();
+ Map<Integer, EitItem> newEitItemMap = new HashMap<>();
+ for (EitItem item : items) {
+ newEitItemMap.put(item.getEventId(), item);
+ }
+ for (EitItem oldItem : oldItems) {
+ EitItem item = newEitItemMap.get(oldItem.getEventId());
+ if (item == null) {
+ outdatedOldItems.add(oldItem);
+ continue;
+ }
+
+ // Since program descriptions arrive at different time, the older one may have the
+ // correct program description while the newer one has no clue what value is.
+ if (oldItem.getDescription() != null && item.getDescription() == null
+ && oldItem.getEventId() == item.getEventId()
+ && oldItem.getStartTime() == item.getStartTime()
+ && oldItem.getLengthInSecond() == item.getLengthInSecond()
+ && Objects.equals(oldItem.getContentRating(), item.getContentRating())
+ && Objects.equals(oldItem.getBroadcastGenre(), item.getBroadcastGenre())
+ && Objects.equals(oldItem.getCanonicalGenre(), item.getCanonicalGenre())) {
+ item.setDescription(oldItem.getDescription());
+ }
+ if (item.compareTo(oldItem) != 0) {
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newUpdate(
+ TvContract.buildProgramUri(oldItem.getProgramId())), item, null));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+ newEitItemMap.remove(item.getEventId());
+ }
+ for (EitItem unverifiedOldItems : outdatedOldItems) {
+ if (unverifiedOldItems.getStartTimeUtcMillis() > currentTime) {
+ // The given new EIT item list covers partial time span of EPG. Here, we delete old
+ // item only when it has an overlapping with the new EIT item list.
+ long startTime = unverifiedOldItems.getStartTimeUtcMillis();
+ long endTime = unverifiedOldItems.getEndTimeUtcMillis();
+ for (EitItem item : newEitItemMap.values()) {
+ long newItemStartTime = item.getStartTimeUtcMillis();
+ long newItemEndTime = item.getEndTimeUtcMillis();
+ if ((startTime >= newItemStartTime && startTime < newItemEndTime)
+ || (endTime > newItemStartTime && endTime <= newItemEndTime)) {
+ ops.add(ContentProviderOperation.newDelete(TvContract.buildProgramUri(
+ unverifiedOldItems.getProgramId())).build());
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ break;
+ }
+ }
+ }
+ }
+ for (EitItem item : newEitItemMap.values()) {
+ if (item.getEndTimeUtcMillis() < currentTime) {
+ continue;
+ }
+ ops.add(buildContentProviderOperation(ContentProviderOperation.newInsert(
+ TvContract.Programs.CONTENT_URI), item, channel.getChannelId()));
+ if (ops.size() >= BATCH_OPERATION_COUNT) {
+ applyBatch(channel.getName(), ops);
+ ops.clear();
+ }
+ }
+
+ applyBatch(channel.getName(), ops);
+ }
+
+ private ContentProviderOperation buildContentProviderOperation(
+ ContentProviderOperation.Builder builder, EitItem item, Long channelId) {
+ if (channelId != null) {
+ builder.withValue(TvContract.Programs.COLUMN_CHANNEL_ID, channelId);
+ }
+ if (item != null) {
+ builder.withValue(TvContract.Programs.COLUMN_TITLE, item.getTitleText())
+ .withValue(TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
+ item.getStartTimeUtcMillis())
+ .withValue(TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
+ item.getEndTimeUtcMillis())
+ .withValue(TvContract.Programs.COLUMN_CONTENT_RATING,
+ item.getContentRating())
+ .withValue(TvContract.Programs.COLUMN_AUDIO_LANGUAGE,
+ item.getAudioLanguage())
+ .withValue(TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ item.getDescription())
+ .withValue(TvContract.Programs.COLUMN_VERSION_NUMBER,
+ item.getEventId());
+ }
+ return builder.build();
+ }
+
+ private void applyBatch(String channelName, ArrayList<ContentProviderOperation> operations) {
+ try {
+ mContext.getContentResolver().applyBatch(TvContract.AUTHORITY, operations);
+ } catch (RemoteException | OperationApplicationException e) {
+ Log.e(TAG, "Error updating EPG " + channelName, e);
+ }
+ }
+
+ private void handleChannel(TunerChannel channel) {
+ long channelId = getChannelId(channel);
+ ContentValues values = new ContentValues();
+ values.put(TvContract.Channels.COLUMN_NETWORK_AFFILIATION, channel.getShortName());
+ values.put(TvContract.Channels.COLUMN_SERVICE_TYPE, channel.getServiceTypeName());
+ values.put(TvContract.Channels.COLUMN_TRANSPORT_STREAM_ID, channel.getTsid());
+ values.put(TvContract.Channels.COLUMN_DISPLAY_NUMBER, channel.getDisplayNumber());
+ values.put(TvContract.Channels.COLUMN_DISPLAY_NAME, channel.getName());
+ values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, channel.toByteArray());
+ values.put(TvContract.Channels.COLUMN_DESCRIPTION, channel.getDescription());
+ values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1, VERSION);
+
+ if (channelId <= 0) {
+ values.put(TvContract.Channels.COLUMN_INPUT_ID, mInputId);
+ values.put(TvContract.Channels.COLUMN_TYPE, "QAM256".equals(channel.getModulation())
+ ? TvContract.Channels.TYPE_ATSC_C : TvContract.Channels.TYPE_ATSC_T);
+ values.put(TvContract.Channels.COLUMN_SERVICE_ID, channel.getProgramNumber());
+
+ // ATSC doesn't have original_network_id
+ values.put(TvContract.Channels.COLUMN_ORIGINAL_NETWORK_ID, channel.getFrequency());
+
+ Uri channelUri = mContext.getContentResolver().insert(TvContract.Channels.CONTENT_URI,
+ values);
+ channelId = ContentUris.parseId(channelUri);
+ } else {
+ mContext.getContentResolver().update(
+ TvContract.buildChannelUri(channelId), values, null, null);
+ }
+ channel.setChannelId(channelId);
+ mTunerChannelMap.put(channelId, channel);
+ mTunerChannelIdMap.put(channel, channelId);
+ if (mIsScanning.get()) {
+ mScannedChannels.add(channel);
+ mPreviousScannedChannels.remove(channel);
+ }
+ if (mListener != null) {
+ mListener.onChannelArrived(channel);
+ }
+ }
+
+ private void clearChannels() {
+ int count = mContext.getContentResolver().delete(mChannelsUri, null, null);
+ if (count > 0) {
+ // We have just deleted obsolete data. Now tell the user that he or she needs
+ // to perform the auto-scan again.
+ if (mListener != null) {
+ mListener.onRescanNeeded();
+ }
+ }
+ }
+
+ private void checkVersion() {
+ String selection = TvContract.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1 + "<>?";
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, selection,
+ new String[] {Integer.toString(VERSION)}, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ // The stored channel data seem outdated. Delete them all.
+ clearChannels();
+ }
+ }
+ }
+
+ private long getChannelId(TunerChannel channel) {
+ Long channelId = mTunerChannelIdMap.get(channel);
+ if (channelId != null) {
+ return channelId;
+ }
+ mHandler.sendEmptyMessage(MSG_BUILD_CHANNEL_MAP);
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ channelId = cursor.getLong(0);
+ byte[] providerData = cursor.getBlob(1);
+ TunerChannel tunerChannel = TunerChannel.parseFrom(providerData);
+ if (tunerChannel != null && tunerChannel.compareTo(channel) == 0) {
+ channel.setChannelId(channelId);
+ mTunerChannelIdMap.put(channel, channelId);
+ mTunerChannelMap.put(channelId, channel);
+ return channelId;
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ return -1;
+ }
+
+ private List<EitItem> getAllProgramsForChannel(TunerChannel channel) {
+ return getAllProgramsForChannel(channel, null, null);
+ }
+
+ private List<EitItem> getAllProgramsForChannel(TunerChannel channel, @Nullable Long startTimeMs,
+ @Nullable Long endTimeMs) {
+ List<EitItem> items = new ArrayList<>();
+ long channelId = channel.getChannelId();
+ Uri programsUri = (startTimeMs == null || endTimeMs == null) ?
+ TvContract.buildProgramsUriForChannel(channelId) :
+ TvContract.buildProgramsUriForChannel(channelId, startTimeMs, endTimeMs);
+ try (Cursor cursor = mContext.getContentResolver().query(programsUri,
+ ALL_PROGRAMS_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long id = cursor.getLong(0);
+ String titleText = cursor.getString(1);
+ long startTime = ConvertUtils.convertUnixEpochToGPSTime(
+ cursor.getLong(2) / DateUtils.SECOND_IN_MILLIS);
+ long endTime = ConvertUtils.convertUnixEpochToGPSTime(
+ cursor.getLong(3) / DateUtils.SECOND_IN_MILLIS);
+ int lengthInSecond = (int) (endTime - startTime);
+ String contentRating = cursor.getString(4);
+ String broadcastGenre = cursor.getString(5);
+ String canonicalGenre = cursor.getString(6);
+ String description = cursor.getString(7);
+ int eventId = cursor.getInt(8);
+ EitItem eitItem = new EitItem(id, eventId, titleText, startTime, lengthInSecond,
+ contentRating, null, null, broadcastGenre, canonicalGenre, description);
+ items.add(eitItem);
+ } while (cursor.moveToNext());
+ }
+ }
+ return items;
+ }
+
+ private void buildChannelMap() {
+ ArrayList<TunerChannel> channels = new ArrayList<>();
+ try (Cursor cursor = mContext.getContentResolver().query(mChannelsUri,
+ CHANNEL_DATA_SELECTION_ARGS, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ do {
+ long channelId = cursor.getLong(0);
+ byte[] data = cursor.getBlob(1);
+ TunerChannel channel = TunerChannel.parseFrom(data);
+ if (channel != null) {
+ channel.setChannelId(channelId);
+ channels.add(channel);
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ mTunerChannelMap.clear();
+ mTunerChannelIdMap.clear();
+ for (TunerChannel channel : channels) {
+ mTunerChannelMap.put(channel.getChannelId(), channel);
+ mTunerChannelIdMap.put(channel, channel.getChannelId());
+ }
+ }
+
+ private static class ChannelEvent {
+ public final TunerChannel channel;
+ public final List<EitItem> eitItems;
+
+ public ChannelEvent(TunerChannel channel, List<EitItem> eitItems) {
+ this.channel = channel;
+ this.eitItems = eitItems;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/EventDetector.java b/src/com/android/tv/tuner/tvinput/EventDetector.java
new file mode 100644
index 00000000..27bbb8c7
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/EventDetector.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.data.PsiData;
+import com.android.tv.tuner.data.PsipData;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Detects channels and programs that are emerged or changed while parsing ATSC PSIP information.
+ */
+public class EventDetector {
+ private static final String TAG = "EventDetector";
+ private static final boolean DEBUG = false;
+ public static final int ALL_PROGRAM_NUMBERS = -1;
+
+ private final TunerHal mTunerHal;
+
+ private TsParser mTsParser;
+ private final Set<Integer> mPidSet = new HashSet<>();
+
+ // To prevent channel duplication
+ private final Set<Integer> mVctProgramNumberSet = new HashSet<>();
+ private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>();
+ private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray();
+ private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray();
+ private final EventListener mEventListener;
+ private int mFrequency;
+ private String mModulation;
+ private int mProgramNumber = ALL_PROGRAM_NUMBERS;
+
+ private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() {
+ @Override
+ public void onPatDetected(List<PsiData.PatItem> items) {
+ for (PsiData.PatItem i : items) {
+ if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) {
+ mTunerHal.addPidFilter(i.getPmtPid(), TunerHal.FILTER_TYPE_OTHER);
+ }
+ }
+ }
+
+ @Override
+ public void onEitPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onEitItemParsed(PsipData.VctItem channel, List<PsipData.EitItem> items) {
+ TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber());
+ if (DEBUG) {
+ Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " "
+ + channel.getProgramNumber());
+ }
+ int channelSourceId = channel.getSourceId();
+
+ // Source id 0 is useful for cases where a cable operator wishes to define a channel for
+ // which no EPG data is currently available.
+ // We don't handle such a case.
+ if (channelSourceId == 0) {
+ return;
+ }
+
+ // If at least a one caption track have been found in EIT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId);
+ for (PsipData.EitItem item : items) {
+ if (captionTracksFound) {
+ break;
+ }
+ List<AtscCaptionTrack> captionTracks = item.getCaptionTracks();
+ if (captionTracks != null && !captionTracks.isEmpty()) {
+ captionTracksFound = true;
+ }
+ }
+ mEitCaptionTracksFound.put(channelSourceId, captionTracksFound);
+ if (captionTracksFound) {
+ for (PsipData.EitItem item : items) {
+ item.setHasCaptionTrack();
+ }
+ }
+ if (tunerChannel != null && mEventListener != null) {
+ mEventListener.onEventDetected(tunerChannel, items);
+ }
+ }
+
+ @Override
+ public void onEttPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onAllVctItemsParsed() {
+ if (mEventListener != null) {
+ mEventListener.onChannelScanDone();
+ }
+ }
+
+ @Override
+ public void onVctItemParsed(PsipData.VctItem channel, List<PsiData.PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onVctItemParsed VCT " + channel);
+ Log.d(TAG, " PMT " + pmtItems);
+ }
+
+ // Merges the audio and caption tracks located in PMT items into the tracks of the given
+ // tuner channel.
+ TunerChannel tunerChannel = new TunerChannel(channel, pmtItems);
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ List<AtscCaptionTrack> captionTracks = new ArrayList<>();
+ for (PsiData.PmtItem pmtItem : pmtItems) {
+ if (pmtItem.getAudioTracks() != null) {
+ audioTracks.addAll(pmtItem.getAudioTracks());
+ }
+ if (pmtItem.getCaptionTracks() != null) {
+ captionTracks.addAll(pmtItem.getCaptionTracks());
+ }
+ }
+ int channelProgramNumber = channel.getProgramNumber();
+
+ // If at least a one caption track have been found in VCT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber)
+ || !captionTracks.isEmpty();
+ mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound);
+ if (captionTracksFound) {
+ tunerChannel.setHasCaptionTrack();
+ }
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+ tunerChannel.setFrequency(mFrequency);
+ tunerChannel.setModulation(mModulation);
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mVctProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mVctProgramNumberSet.add(channelProgramNumber);
+ }
+ if (mEventListener != null) {
+ mEventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ };
+
+ /**
+ * Listener for detecting ATSC TV channels and receiving EPG data.
+ */
+ public interface EventListener {
+
+ /**
+ * Fired when new information of an ATSC TV channel arrived.
+ *
+ * @param channel an ATSC TV channel
+ * @param channelArrivedAtFirstTime tells whether this channel arrived at first time
+ */
+ void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime);
+
+ /**
+ * Fired when new program events of an ATSC TV channel arrived.
+ *
+ * @param channel an ATSC TV channel
+ * @param items a list of EIT items that were received
+ */
+ void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items);
+
+ /**
+ * Fired when information of all detectable ATSC TV channels in current frequency arrived.
+ */
+ void onChannelScanDone();
+ }
+
+ /**
+ * Creates a detector for ATSC TV channles and program information.
+ * @param usbTunerInteface {@link TunerHal}
+ * @param listener for ATSC TV channels and program information
+ */
+ public EventDetector(TunerHal usbTunerInteface, EventListener listener) {
+ mTunerHal = usbTunerInteface;
+ mEventListener = listener;
+ }
+
+ private void reset() {
+ mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset()
+ mPidSet.clear();
+ mVctProgramNumberSet.clear();
+ mVctCaptionTracksFound.clear();
+ mEitCaptionTracksFound.clear();
+ mChannelMap.clear();
+ }
+
+ /**
+ * Starts detecting channel and program information.
+ *
+ * @param frequency The frequency to listen to.
+ * @param modulation The modulation type.
+ * @param programNumber The program number if this is for handling tune request. For scanning
+ * purpose, supply {@link #ALL_PROGRAM_NUMBERS}.
+ */
+ public void startDetecting(int frequency, String modulation, int programNumber) {
+ reset();
+ mFrequency = frequency;
+ mModulation = modulation;
+ mProgramNumber = programNumber;
+ }
+
+ private void startListening(int pid) {
+ if (mPidSet.contains(pid)) {
+ return;
+ }
+ mPidSet.add(pid);
+ mTunerHal.addPidFilter(pid, TunerHal.FILTER_TYPE_OTHER);
+ }
+
+ /**
+ * Feeds ATSC TS stream to detect channel and program information.
+ * @param data buffer for ATSC TS stream
+ * @param startOffset the offset where buffer starts
+ * @param length The length of available data
+ */
+ public void feedTSStream(byte[] data, int startOffset, int length) {
+ if (mPidSet.isEmpty()) {
+ startListening(TsParser.ATSC_SI_BASE_PID);
+ }
+ if (mTsParser != null) {
+ mTsParser.feedTSData(data, startOffset, length);
+ }
+ }
+
+ /**
+ * Retrieves the channel information regardless of being well-formed.
+ * @return {@link List} of {@link TunerChannel}
+ */
+ public List<TunerChannel> getMalFormedChannels() {
+ return mTsParser.getMalFormedChannels();
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
new file mode 100644
index 00000000..46ff4ea1
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/FileSourceEventDetector.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+
+import com.android.tv.tuner.data.PsiData.PatItem;
+import com.android.tv.tuner.data.PsiData.PmtItem;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.VctItem;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.source.FileTsStreamer;
+import com.android.tv.tuner.ts.TsParser;
+import com.android.tv.tuner.tvinput.EventDetector.EventListener;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * PSIP event detector for a file source.
+ *
+ * <p>Uses {@link TsParser} to analyze input MPEG-2 transport stream, detects and reports
+ * various PSIP-related events via {@link TsParser.TsOutputListener}.
+ */
+public class FileSourceEventDetector {
+ private static final String TAG = "FileSourceEventDetector";
+ private static final boolean DEBUG = true;
+ public static final int ALL_PROGRAM_NUMBERS = 0;
+
+ private TsParser mTsParser;
+ private final Set<Integer> mVctProgramNumberSet = new HashSet<>();
+ private final SparseArray<TunerChannel> mChannelMap = new SparseArray<>();
+ private final SparseBooleanArray mVctCaptionTracksFound = new SparseBooleanArray();
+ private final SparseBooleanArray mEitCaptionTracksFound = new SparseBooleanArray();
+ private final EventListener mEventListener;
+ private FileTsStreamer.StreamProvider mStreamProvider;
+ private int mProgramNumber = ALL_PROGRAM_NUMBERS;
+
+ public FileSourceEventDetector(EventDetector.EventListener listener) {
+ mEventListener = listener;
+ }
+
+ /**
+ * Starts detecting channel and program information.
+ *
+ * @param provider MPEG-2 transport stream source.
+ * @param programNumber The program number if this is for handling tune request. For scanning
+ * purpose, supply {@link #ALL_PROGRAM_NUMBERS}.
+ */
+ public void start(FileTsStreamer.StreamProvider provider, int programNumber) {
+ mStreamProvider = provider;
+ mProgramNumber = programNumber;
+ reset();
+ }
+
+ private void reset() {
+ mTsParser = new TsParser(mTsOutputListener); // TODO: Use TsParser.reset()
+ mStreamProvider.clearPidFilter();
+ mVctProgramNumberSet.clear();
+ mVctCaptionTracksFound.clear();
+ mEitCaptionTracksFound.clear();
+ mChannelMap.clear();
+ }
+
+ public void feedTSStream(byte[] data, int startOffset, int length) {
+ if (mStreamProvider.isFilterEmpty()) {
+ startListening(TsParser.ATSC_SI_BASE_PID);
+ startListening(TsParser.PAT_PID);
+ }
+ if (mTsParser != null) {
+ mTsParser.feedTSData(data, startOffset, length);
+ }
+ }
+
+ private void startListening(int pid) {
+ if (mStreamProvider.isInFilter(pid)) {
+ return;
+ }
+ mStreamProvider.addPidFilter(pid);
+ }
+
+ private final TsParser.TsOutputListener mTsOutputListener = new TsParser.TsOutputListener() {
+ @Override
+ public void onPatDetected(List<PatItem> items) {
+ for (PatItem i : items) {
+ if (mProgramNumber == ALL_PROGRAM_NUMBERS || mProgramNumber == i.getProgramNo()) {
+ mStreamProvider.addPidFilter(i.getPmtPid());
+ }
+ }
+ }
+
+ @Override
+ public void onEitPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onEitItemParsed(VctItem channel, List<EitItem> items) {
+ TunerChannel tunerChannel = mChannelMap.get(channel.getProgramNumber());
+ if (DEBUG) {
+ Log.d(TAG, "onEitItemParsed tunerChannel:" + tunerChannel + " "
+ + channel.getProgramNumber());
+ }
+ int channelSourceId = channel.getSourceId();
+
+ // Source id 0 is useful for cases where a cable operator wishes to define a channel for
+ // which no EPG data is currently available.
+ // We don't handle such a case.
+ if (channelSourceId == 0) {
+ return;
+ }
+
+ // If at least a one caption track have been found in EIT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mEitCaptionTracksFound.get(channelSourceId);
+ for (EitItem item : items) {
+ if (captionTracksFound) {
+ break;
+ }
+ List<AtscCaptionTrack> captionTracks = item.getCaptionTracks();
+ if (captionTracks != null && !captionTracks.isEmpty()) {
+ captionTracksFound = true;
+ }
+ }
+ mEitCaptionTracksFound.put(channelSourceId, captionTracksFound);
+ if (captionTracksFound) {
+ for (EitItem item : items) {
+ item.setHasCaptionTrack();
+ }
+ }
+ if (tunerChannel != null && mEventListener != null) {
+ mEventListener.onEventDetected(tunerChannel, items);
+ }
+ }
+
+ @Override
+ public void onEttPidDetected(int pid) {
+ startListening(pid);
+ }
+
+ @Override
+ public void onAllVctItemsParsed() {
+ // do nothing.
+ }
+
+ @Override
+ public void onVctItemParsed(VctItem channel, List<PmtItem> pmtItems) {
+ if (DEBUG) {
+ Log.d(TAG, "onVctItemParsed VCT " + channel);
+ Log.d(TAG, " PMT " + pmtItems);
+ }
+
+ // Merges the audio and caption tracks located in PMT items into the tracks of the given
+ // tuner channel.
+ TunerChannel tunerChannel = TunerChannel.forFile(channel, pmtItems);
+ List<AtscAudioTrack> audioTracks = new ArrayList<>();
+ List<AtscCaptionTrack> captionTracks = new ArrayList<>();
+ for (PmtItem pmtItem : pmtItems) {
+ if (pmtItem.getAudioTracks() != null) {
+ audioTracks.addAll(pmtItem.getAudioTracks());
+ }
+ if (pmtItem.getCaptionTracks() != null) {
+ captionTracks.addAll(pmtItem.getCaptionTracks());
+ }
+ }
+ int channelProgramNumber = channel.getProgramNumber();
+
+ // If at least a one caption track have been found in VCT items for the given channel,
+ // we starts to interpret the zero tracks as a clearance of the caption tracks.
+ boolean captionTracksFound = mVctCaptionTracksFound.get(channelProgramNumber)
+ || !captionTracks.isEmpty();
+ mVctCaptionTracksFound.put(channelProgramNumber, captionTracksFound);
+ if (captionTracksFound) {
+ tunerChannel.setHasCaptionTrack();
+ }
+ tunerChannel.setFilepath(mStreamProvider.getFilepath());
+ tunerChannel.setAudioTracks(audioTracks);
+ tunerChannel.setCaptionTracks(captionTracks);
+
+ mChannelMap.put(tunerChannel.getProgramNumber(), tunerChannel);
+ boolean found = mVctProgramNumberSet.contains(channelProgramNumber);
+ if (!found) {
+ mVctProgramNumberSet.add(channelProgramNumber);
+ }
+ if (mEventListener != null) {
+ mEventListener.onChannelDetected(tunerChannel, !found);
+ }
+ }
+ };
+}
diff --git a/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
new file mode 100644
index 00000000..3908fe6c
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/PlaybackBufferListener.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+/**
+ * The listener for buffer events occurred during playback.
+ */
+public interface PlaybackBufferListener {
+
+ /**
+ * Invoked when the start position of the buffer has been changed.
+ *
+ * @param startTimeMs the new start time of the buffer in millisecond
+ */
+ void onBufferStartTimeChanged(long startTimeMs);
+
+ /**
+ * Invoked when the state of the buffer has been changed.
+ *
+ * @param available whether the buffer is available or not
+ */
+ void onBufferStateChanged(boolean available);
+
+ /**
+ * Invoked when the disk speed is too slow to write the buffers.
+ */
+ void onDiskTooSlow();
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerDebug.java b/src/com/android/tv/tuner/tvinput/TunerDebug.java
new file mode 100644
index 00000000..a7a41ea7
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerDebug.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+/**
+ * A class to maintain various debugging information.
+ */
+public class TunerDebug {
+ private static final String TAG = "TunerDebug";
+ public static final boolean ENABLED = false;
+
+ private int mVideoFrameDrop;
+ private int mBytesInQueue;
+
+ private long mAudioPositionUs;
+ private long mAudioPtsUs;
+ private long mVideoPtsUs;
+
+ private long mLastAudioPositionUs;
+ private long mLastAudioPtsUs;
+ private long mLastVideoPtsUs;
+ private long mLastCheckTimestampMs;
+
+ private long mAudioPositionUsRate;
+ private long mAudioPtsUsRate;
+ private long mVideoPtsUsRate;
+
+ private TunerDebug() {
+ mVideoFrameDrop = 0;
+ mLastCheckTimestampMs = SystemClock.elapsedRealtime();
+ }
+
+ private static class LazyHolder {
+ private static final TunerDebug INSTANCE = new TunerDebug();
+ }
+
+ public static TunerDebug getInstance() {
+ return LazyHolder.INSTANCE;
+ }
+
+ public static void notifyVideoFrameDrop(long delta) {
+ // TODO: provide timestamp mismatch information using delta
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mVideoFrameDrop++;
+ }
+
+ public static int getVideoFrameDrop() {
+ TunerDebug sTunerDebug = getInstance();
+ int videoFrameDrop = sTunerDebug.mVideoFrameDrop;
+ if (videoFrameDrop > 0) {
+ Log.d(TAG, "Dropped video frame: " + videoFrameDrop);
+ }
+ sTunerDebug.mVideoFrameDrop = 0;
+ return videoFrameDrop;
+ }
+
+ public static void setBytesInQueue(int bytesInQueue) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mBytesInQueue = bytesInQueue;
+ }
+
+ public static int getBytesInQueue() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mBytesInQueue;
+ }
+
+ public static void setAudioPositionUs(long audioPositionUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mAudioPositionUs = audioPositionUs;
+ }
+
+ public static long getAudioPositionUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPositionUs;
+ }
+
+ public static void setAudioPtsUs(long audioPtsUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mAudioPtsUs = audioPtsUs;
+ }
+
+ public static long getAudioPtsUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPtsUs;
+ }
+
+ public static void setVideoPtsUs(long videoPtsUs) {
+ TunerDebug sTunerDebug = getInstance();
+ sTunerDebug.mVideoPtsUs = videoPtsUs;
+ }
+
+ public static long getVideoPtsUs() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mVideoPtsUs;
+ }
+
+ public static void calculateDiff() {
+ TunerDebug sTunerDebug = getInstance();
+ long currentTime = SystemClock.elapsedRealtime();
+ long duration = currentTime - sTunerDebug.mLastCheckTimestampMs;
+ if (duration != 0) {
+ sTunerDebug.mAudioPositionUsRate =
+ (sTunerDebug.mAudioPositionUs - sTunerDebug.mLastAudioPositionUs) * 1000
+ / duration;
+ sTunerDebug.mAudioPtsUsRate =
+ (sTunerDebug.mAudioPtsUs - sTunerDebug.mLastAudioPtsUs) * 1000
+ / duration;
+ sTunerDebug.mVideoPtsUsRate =
+ (sTunerDebug.mVideoPtsUs - sTunerDebug.mLastVideoPtsUs) * 1000
+ / duration;
+ }
+
+ sTunerDebug.mLastAudioPositionUs = sTunerDebug.mAudioPositionUs;
+ sTunerDebug.mLastAudioPtsUs = sTunerDebug.mAudioPtsUs;
+ sTunerDebug.mLastVideoPtsUs = sTunerDebug.mVideoPtsUs;
+ sTunerDebug.mLastCheckTimestampMs = currentTime;
+ }
+
+ public static long getAudioPositionUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPositionUsRate;
+ }
+
+ public static long getAudioPtsUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mAudioPtsUsRate;
+ }
+
+ public static long getVideoPtsUsRate() {
+ TunerDebug sTunerDebug = getInstance();
+ return sTunerDebug.mVideoPtsUsRate;
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
new file mode 100644
index 00000000..acdd149f
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSession.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.content.Context;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputService;
+import android.net.Uri;
+import android.support.annotation.MainThread;
+import android.support.annotation.Nullable;
+import android.support.annotation.WorkerThread;
+import android.util.Log;
+
+/**
+ * Processes DVR recordings, and deletes the previously recorded contents.
+ */
+public class TunerRecordingSession extends TvInputService.RecordingSession {
+ private static final String TAG = "TunerRecordingSession";
+ private static final boolean DEBUG = false;
+
+ private final TunerRecordingSessionWorker mSessionWorker;
+
+ public TunerRecordingSession(Context context, String inputId,
+ ChannelDataManager channelDataManager) {
+ super(context);
+ mSessionWorker = new TunerRecordingSessionWorker(context, inputId, channelDataManager,
+ this);
+ }
+
+ // RecordingSession
+ @MainThread
+ @Override
+ public void onTune(Uri channelUri) {
+ // TODO(dvr): support calling more than once, http://b/27171225
+ if (DEBUG) {
+ Log.d(TAG, "Requesting recording session tune: " + channelUri);
+ }
+ mSessionWorker.tune(channelUri);
+ }
+
+ @MainThread
+ @Override
+ public void onRelease() {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting recording session release.");
+ }
+ mSessionWorker.release();
+ }
+
+ @MainThread
+ @Override
+ public void onStartRecording(@Nullable Uri programUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting start recording.");
+ }
+ mSessionWorker.startRecording(programUri);
+ }
+
+ @MainThread
+ @Override
+ public void onStopRecording() {
+ if (DEBUG) {
+ Log.d(TAG, "Requesting stop recording.");
+ }
+ mSessionWorker.stopRecording();
+ }
+
+ // Called from TunerRecordingSessionImpl in a worker thread.
+ @WorkerThread
+ public void onTuned(Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Notifying recording session tuned.");
+ }
+ notifyTuned(channelUri);
+ }
+
+ @WorkerThread
+ public void onRecordFinished(final Uri recordedProgramUri) {
+ if (DEBUG) {
+ Log.d(TAG, "Notifying record successfully finished.");
+ }
+ notifyRecordingStopped(recordedProgramUri);
+ }
+
+ @WorkerThread
+ public void onError(int reason) {
+ Log.w(TAG, "Notifying recording error: " + reason);
+ notifyError(reason);
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
new file mode 100644
index 00000000..6ec55e4f
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerRecordingSessionWorker.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.support.annotation.IntDef;
+import android.support.annotation.MainThread;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.recording.RecordingCapability;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.dvr.RecordedProgram;
+import com.android.tv.tuner.DvbDeviceAccessor;
+import com.android.tv.tuner.data.PsipData;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.ExoPlayerSampleExtractor;
+import com.android.tv.tuner.exoplayer.SampleExtractor;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+import java.util.Locale;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implements a DVR feature.
+ */
+public class TunerRecordingSessionWorker implements PlaybackBufferListener,
+ EventDetector.EventListener, SampleExtractor.OnCompletionListener,
+ Handler.Callback {
+ private static final String TAG = "TunerRecordingSessionW";
+ private static final boolean DEBUG = false;
+
+ private static final String SORT_BY_TIME = TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS
+ + ", " + TvContract.Programs.COLUMN_CHANNEL_ID + ", "
+ + TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS;
+ private static final long STORAGE_MONITOR_INTERVAL_MS = TimeUnit.SECONDS.toMillis(4);
+ private static final long MIN_PARTIAL_RECORDING_DURATION_MS = TimeUnit.SECONDS.toMillis(10);
+ private static final long PREPARE_RECORDER_POLL_MS = 50;
+ private static final int MSG_TUNE = 1;
+ private static final int MSG_START_RECORDING = 2;
+ private static final int MSG_PREPARE_RECODER = 3;
+ private static final int MSG_STOP_RECORDING = 4;
+ private static final int MSG_MONITOR_STORAGE_STATUS = 5;
+ private static final int MSG_RELEASE = 6;
+ private final RecordingCapability mCapabilities;
+
+ public RecordingCapability getCapabilities() {
+ return mCapabilities;
+ }
+
+ @IntDef({STATE_IDLE, STATE_TUNED, STATE_RECORDING})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface DvrSessionState {}
+ private static final int STATE_IDLE = 1;
+ private static final int STATE_TUNED = 2;
+ private static final int STATE_RECORDING = 3;
+
+ private static final long CHANNEL_ID_NONE = -1;
+
+ private final Context mContext;
+ private final ChannelDataManager mChannelDataManager;
+ private final DvrStorageStatusManager mDvrStorageStatusManager;
+ private final Handler mHandler;
+ private final TsDataSourceManager mSourceManager;
+ private final Random mRandom = new Random();
+
+ private TsDataSource mTunerSource;
+ private TunerChannel mChannel;
+ private File mStorageDir;
+ private long mRecordStartTime;
+ private long mRecordEndTime;
+ private boolean mRecorderRunning;
+ private BufferManager mBufferManager;
+ private SampleExtractor mRecorder;
+ private final TunerRecordingSession mSession;
+ @DvrSessionState private int mSessionState = STATE_IDLE;
+ private final String mInputId;
+ private Uri mProgramUri;
+
+ public TunerRecordingSessionWorker(Context context, String inputId,
+ ChannelDataManager dataManager, TunerRecordingSession session) {
+ mRandom.setSeed(System.nanoTime());
+ mContext = context;
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mHandler = new Handler(handlerThread.getLooper(), this);
+ mDvrStorageStatusManager =
+ TvApplication.getSingletons(context).getDvrStorageStatusManager();
+ mChannelDataManager = dataManager;
+ mChannelDataManager.checkDataVersion(context);
+ mSourceManager = TsDataSourceManager.createSourceManager(true);
+ mCapabilities = new DvbDeviceAccessor(context).getRecordingCapability(inputId);
+ mInputId = inputId;
+ if (DEBUG) Log.d(TAG, mCapabilities.toString());
+ mSession = session;
+ }
+
+ // PlaybackBufferListener
+ @Override
+ public void onBufferStartTimeChanged(long startTimeMs) { }
+
+ @Override
+ public void onBufferStateChanged(boolean available) { }
+
+ @Override
+ public void onDiskTooSlow() { }
+
+ // EventDetector.EventListener
+ @Override
+ public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ if (mChannel == null || mChannel.compareTo(channel) != 0) {
+ return;
+ }
+ mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
+ }
+
+ @Override
+ public void onEventDetected(TunerChannel channel, List<PsipData.EitItem> items) {
+ if (mChannel == null || mChannel.compareTo(channel) != 0) {
+ return;
+ }
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ // do nothing.
+ }
+
+ // SampleExtractor.OnCompletionListener
+ @Override
+ public void onCompletion(boolean success, long lastExtractedPositionUs) {
+ onRecordingResult(success, lastExtractedPositionUs);
+ reset();
+ }
+
+ /**
+ * Tunes to {@code channelUri}.
+ */
+ @MainThread
+ public void tune(Uri channelUri) {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.obtainMessage(MSG_TUNE, channelUri).sendToTarget();
+ }
+
+ /**
+ * Starts recording.
+ */
+ @MainThread
+ public void startRecording(@Nullable Uri programUri) {
+ mHandler.obtainMessage(MSG_START_RECORDING, programUri).sendToTarget();
+ }
+
+ /**
+ * Stops recording.
+ */
+ @MainThread
+ public void stopRecording() {
+ mHandler.sendEmptyMessage(MSG_STOP_RECORDING);
+ }
+
+ /**
+ * Releases all resources.
+ */
+ @MainThread
+ public void release() {
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_RELEASE);
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_TUNE: {
+ Uri channelUri = (Uri) msg.obj;
+ if (DEBUG) Log.d(TAG, "Tune to " + channelUri);
+ if (doTune(channelUri)) {
+ mSession.onTuned(channelUri);
+ } else {
+ reset();
+ }
+ return true;
+ }
+ case MSG_START_RECORDING: {
+ if (DEBUG) Log.d(TAG, "Start recording");
+ if (!doStartRecording((Uri) msg.obj)) {
+ reset();
+ }
+ return true;
+ }
+ case MSG_PREPARE_RECODER: {
+ if (DEBUG) Log.d(TAG, "Preparing recorder");
+ if (!mRecorderRunning) {
+ return true;
+ }
+ try {
+ if (!mRecorder.prepare()) {
+ mHandler.sendEmptyMessageDelayed(MSG_PREPARE_RECODER,
+ PREPARE_RECORDER_POLL_MS);
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to start recording. Couldn't prepare an extractor");
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ reset();
+ }
+ return true;
+ }
+ case MSG_STOP_RECORDING: {
+ if (DEBUG) Log.d(TAG, "Stop recording");
+ if (mSessionState != STATE_RECORDING) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ reset();
+ return true;
+ }
+ if (mRecorderRunning) {
+ stopRecorder();
+ }
+ return true;
+ }
+ case MSG_MONITOR_STORAGE_STATUS: {
+ if (mSessionState != STATE_RECORDING) {
+ return true;
+ }
+ if (!mDvrStorageStatusManager.isStorageSufficient()) {
+ if (mRecorderRunning) {
+ stopRecorder();
+ }
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ reset();
+ } else {
+ mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS,
+ STORAGE_MONITOR_INTERVAL_MS);
+ }
+ return true;
+ }
+ case MSG_RELEASE: {
+ // Since release was requested, current recording will be cancelled
+ // without notification.
+ reset();
+ mSourceManager.release();
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.getLooper().quitSafely();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Nullable
+ private TunerChannel getChannel(Uri channelUri) {
+ if (channelUri == null) {
+ return null;
+ }
+ long channelId;
+ try {
+ channelId = ContentUris.parseId(channelUri);
+ } catch (UnsupportedOperationException | NumberFormatException e) {
+ channelId = CHANNEL_ID_NONE;
+ }
+ return (channelId == CHANNEL_ID_NONE) ? null : mChannelDataManager.getChannel(channelId);
+ }
+
+ private String getStorageKey() {
+ long prefix = System.currentTimeMillis();
+ int suffix = mRandom.nextInt();
+ return String.format(Locale.ENGLISH, "%016x_%016x", prefix, suffix);
+ }
+
+ private void reset() {
+ if (mRecorder != null) {
+ mRecorder.release();
+ mRecorder = null;
+ }
+ if (mBufferManager != null) {
+ mBufferManager.close();
+ mBufferManager = null;
+ }
+ if (mTunerSource != null) {
+ mSourceManager.releaseDataSource(mTunerSource);
+ mTunerSource = null;
+ }
+ mSessionState = STATE_IDLE;
+ mRecorderRunning = false;
+ }
+
+ private boolean doTune(Uri channelUri) {
+ if (mSessionState != STATE_IDLE) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Tuning was requested from wrong status.");
+ return false;
+ }
+ mChannel = getChannel(channelUri);
+ if (mChannel == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.w(TAG, "Failed to start recording. Couldn't find the channel for " + mChannel);
+ return false;
+ }
+ if (!mDvrStorageStatusManager.isStorageSufficient()) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ Log.w(TAG, "Tuning failed due to insufficient storage.");
+ return false;
+ }
+ mTunerSource = mSourceManager.createDataSource(mContext, mChannel, this);
+ if (mTunerSource == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_RESOURCE_BUSY);
+ Log.w(TAG, "Tuner stream cannot be created due to resource shortage.");
+ return false;
+ }
+ mSessionState = STATE_TUNED;
+ return true;
+ }
+
+ private boolean doStartRecording(@Nullable Uri programUri) {
+ if (mSessionState != STATE_TUNED) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Recording session status abnormal");
+ return false;
+ }
+ mStorageDir = mDvrStorageStatusManager.isStorageSufficient() ?
+ new File(mDvrStorageStatusManager.getRecordingRootDataDirectory(),
+ getStorageKey()) : null;
+ if (mStorageDir == null) {
+ mSession.onError(TvInputManager.RECORDING_ERROR_INSUFFICIENT_SPACE);
+ Log.w(TAG, "Failed to start recording due to insufficient storage.");
+ return false;
+ }
+ // Since tuning might be happened a while ago, shifts the start position of tuned source.
+ mTunerSource.shiftStartPosition(mTunerSource.getBufferedPosition());
+ mBufferManager = new BufferManager(new DvrStorageManager(mStorageDir, true));
+ mRecordStartTime = System.currentTimeMillis();
+ mRecorder = new ExoPlayerSampleExtractor(Uri.EMPTY, mTunerSource, mBufferManager, this,
+ true);
+ mRecorder.setOnCompletionListener(this, mHandler);
+ mProgramUri = programUri;
+ mSessionState = STATE_RECORDING;
+ mRecorderRunning = true;
+ mHandler.sendEmptyMessage(MSG_PREPARE_RECODER);
+ mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS);
+ mHandler.sendEmptyMessageDelayed(MSG_MONITOR_STORAGE_STATUS,
+ STORAGE_MONITOR_INTERVAL_MS);
+ return true;
+ }
+
+ private void stopRecorder() {
+ // Do not change session status.
+ if (mRecorder != null) {
+ mRecorder.release();
+ mRecordEndTime = System.currentTimeMillis();
+ mRecorder = null;
+ }
+ mRecorderRunning = false;
+ mHandler.removeMessages(MSG_MONITOR_STORAGE_STATUS);
+ Log.i(TAG, "Recording stopped");
+ }
+
+ private static class Program {
+ private final long mChannelId;
+ private final String mTitle;
+ private String mSeriesId;
+ private final String mSeasonTitle;
+ private final String mEpisodeTitle;
+ private final String mSeasonNumber;
+ private final String mEpisodeNumber;
+ private final String mDescription;
+ private final String mPosterArtUri;
+ private final String mThumbnailUri;
+ private final String mCanonicalGenres;
+ private final String mContentRatings;
+ private final long mStartTimeUtcMillis;
+ private final long mEndTimeUtcMillis;
+ private final int mVideoWidth;
+ private final int mVideoHeight;
+ private final byte[] mInternalProviderData;
+
+ private static final String[] PROJECTION = {
+ TvContract.Programs.COLUMN_CHANNEL_ID,
+ TvContract.Programs.COLUMN_TITLE,
+ TvContract.Programs.COLUMN_SEASON_TITLE,
+ TvContract.Programs.COLUMN_EPISODE_TITLE,
+ TvContract.Programs.COLUMN_SEASON_DISPLAY_NUMBER,
+ TvContract.Programs.COLUMN_EPISODE_DISPLAY_NUMBER,
+ TvContract.Programs.COLUMN_SHORT_DESCRIPTION,
+ TvContract.Programs.COLUMN_POSTER_ART_URI,
+ TvContract.Programs.COLUMN_THUMBNAIL_URI,
+ TvContract.Programs.COLUMN_CANONICAL_GENRE,
+ TvContract.Programs.COLUMN_CONTENT_RATING,
+ TvContract.Programs.COLUMN_START_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_END_TIME_UTC_MILLIS,
+ TvContract.Programs.COLUMN_VIDEO_WIDTH,
+ TvContract.Programs.COLUMN_VIDEO_HEIGHT,
+ TvContract.Programs.COLUMN_INTERNAL_PROVIDER_DATA
+ };
+
+ public Program(Cursor cursor) {
+ int index = 0;
+ mChannelId = cursor.getLong(index++);
+ mTitle = cursor.getString(index++);
+ mSeasonTitle = cursor.getString(index++);
+ mEpisodeTitle = cursor.getString(index++);
+ mSeasonNumber = cursor.getString(index++);
+ mEpisodeNumber = cursor.getString(index++);
+ mDescription = cursor.getString(index++);
+ mPosterArtUri = cursor.getString(index++);
+ mThumbnailUri = cursor.getString(index++);
+ mCanonicalGenres = cursor.getString(index++);
+ mContentRatings = cursor.getString(index++);
+ mStartTimeUtcMillis = cursor.getLong(index++);
+ mEndTimeUtcMillis = cursor.getLong(index++);
+ mVideoWidth = cursor.getInt(index++);
+ mVideoHeight = cursor.getInt(index++);
+ mInternalProviderData = cursor.getBlob(index++);
+ SoftPreconditions.checkArgument(index == PROJECTION.length);
+ }
+
+ public Program(long channelId) {
+ mChannelId = channelId;
+ mTitle = "Unknown";
+ mSeasonTitle = "";
+ mEpisodeTitle = "";
+ mSeasonNumber = "";
+ mEpisodeNumber = "";
+ mDescription = "Unknown";
+ mPosterArtUri = null;
+ mThumbnailUri = null;
+ mCanonicalGenres = null;
+ mContentRatings = null;
+ mStartTimeUtcMillis = 0;
+ mEndTimeUtcMillis = 0;
+ mVideoWidth = 0;
+ mVideoHeight = 0;
+ mInternalProviderData = null;
+ }
+
+ public static Program onQuery(Cursor c) {
+ Program program = null;
+ if (c != null && c.moveToNext()) {
+ program = new Program(c);
+ }
+ return program;
+ }
+
+ public ContentValues buildValues() {
+ ContentValues values = new ContentValues();
+ int index = 0;
+ values.put(PROJECTION[index++], mChannelId);
+ values.put(PROJECTION[index++], mTitle);
+ values.put(PROJECTION[index++], mSeasonTitle);
+ values.put(PROJECTION[index++], mEpisodeTitle);
+ values.put(PROJECTION[index++], mSeasonNumber);
+ values.put(PROJECTION[index++], mEpisodeNumber);
+ values.put(PROJECTION[index++], mDescription);
+ values.put(PROJECTION[index++], mPosterArtUri);
+ values.put(PROJECTION[index++], mThumbnailUri);
+ values.put(PROJECTION[index++], mCanonicalGenres);
+ values.put(PROJECTION[index++], mContentRatings);
+ values.put(PROJECTION[index++], mStartTimeUtcMillis);
+ values.put(PROJECTION[index++], mEndTimeUtcMillis);
+ values.put(PROJECTION[index++], mVideoWidth);
+ values.put(PROJECTION[index++], mVideoHeight);
+ values.put(PROJECTION[index++], mInternalProviderData);
+ SoftPreconditions.checkArgument(index == PROJECTION.length);
+ return values;
+ }
+ }
+
+ private Program getRecordedProgram() {
+ ContentResolver resolver = mContext.getContentResolver();
+ Uri programUri = mProgramUri;
+ if (mProgramUri == null) {
+ long avg = mRecordStartTime / 2 + mRecordEndTime / 2;
+ programUri = TvContract.buildProgramsUriForChannel(mChannel.getChannelId(), avg, avg);
+ }
+ try (Cursor c = resolver.query(programUri, Program.PROJECTION, null, null, SORT_BY_TIME)) {
+ if (c != null) {
+ Program result = Program.onQuery(c);
+ if (DEBUG) {
+ Log.v(TAG, "Finished query for " + this);
+ }
+ return result;
+ } else {
+ if (c == null) {
+ Log.e(TAG, "Unknown query error for " + this);
+ } else {
+ if (DEBUG) Log.d(TAG, "Canceled query for " + this);
+ }
+ return null;
+ }
+ }
+ }
+
+ private Uri insertRecordedProgram(Program program, long channelId, String storageUri,
+ long totalBytes, long startTime, long endTime) {
+ // TODO: Set title even though program is null.
+ RecordedProgram recordedProgram = RecordedProgram.builder()
+ .setInputId(mInputId)
+ .setChannelId(channelId)
+ .setDataUri(storageUri)
+ .setDurationMillis(endTime - startTime)
+ .setDataBytes(totalBytes)
+ // startTime and endTime could be overridden by program's start and end value.
+ .setStartTimeUtcMillis(startTime)
+ .setEndTimeUtcMillis(endTime)
+ .build();
+ ContentValues values = RecordedProgram.toValues(recordedProgram);
+ if (program != null) {
+ values.putAll(program.buildValues());
+ }
+ return mContext.getContentResolver().insert(TvContract.RecordedPrograms.CONTENT_URI,
+ values);
+ }
+
+ private void onRecordingResult(boolean success, long lastExtractedPositionUs) {
+ if (mSessionState != STATE_RECORDING) {
+ // Error notification is not needed.
+ Log.e(TAG, "Recording session status abnormal");
+ return;
+ }
+ if (mRecorderRunning) {
+ // In case of recorder not being stopped, because of premature termination of recording.
+ stopRecorder();
+ }
+ if (!success && lastExtractedPositionUs <
+ TimeUnit.MILLISECONDS.toMicros(MIN_PARTIAL_RECORDING_DURATION_MS)) {
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.w(TAG, "Recording failed during recording");
+ return;
+ }
+ Log.i(TAG, "recording finished " + (success ? "completely" : "partially"));
+ Uri uri = insertRecordedProgram(getRecordedProgram(), mChannel.getChannelId(),
+ Uri.fromFile(mStorageDir).toString(), 1024 * 1024, mRecordStartTime,
+ mRecordStartTime + TimeUnit.MICROSECONDS.toMillis(lastExtractedPositionUs));
+ if (uri == null) {
+ new DeleteRecordingTask().execute(mStorageDir);
+ mSession.onError(TvInputManager.RECORDING_ERROR_UNKNOWN);
+ Log.e(TAG, "Inserting a recording to DB failed");
+ return;
+ }
+ mSession.onRecordFinished(uri);
+ }
+
+ private static class DeleteRecordingTask extends AsyncTask<File, Void, Void> {
+
+ @Override
+ public Void doInBackground(File... files) {
+ if (files == null || files.length == 0) {
+ return null;
+ }
+ for(File file : files) {
+ Utils.deleteDirOrFile(file);
+ }
+ return null;
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerSession.java b/src/com/android/tv/tuner/tvinput/TunerSession.java
new file mode 100644
index 00000000..abfd2b30
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerSession.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.PlaybackParams;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputService;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Message;
+import android.os.SystemClock;
+import android.text.Html;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.cc.CaptionLayout;
+import com.android.tv.tuner.cc.CaptionTrackRenderer;
+import com.android.tv.tuner.data.Cea708Data.CaptionEvent;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.util.GlobalSettingsUtils;
+import com.android.tv.tuner.util.StatusTextUtils;
+import com.android.tv.tuner.util.SystemPropertiesProxy;
+
+/**
+ * Provides a tuner TV input session. It handles Overlay UI works. Main tuner input functions
+ * are implemented in {@link TunerSessionWorker}.
+ */
+public class TunerSession extends TvInputService.Session implements Handler.Callback {
+ private static final String TAG = "TunerSession";
+ private static final boolean DEBUG = false;
+ private static final String USBTUNER_SHOW_DEBUG = "persist.tv.tuner.show_debug";
+
+ public static final int MSG_UI_SHOW_MESSAGE = 1;
+ public static final int MSG_UI_HIDE_MESSAGE = 2;
+ public static final int MSG_UI_SHOW_AUDIO_UNPLAYABLE = 3;
+ public static final int MSG_UI_HIDE_AUDIO_UNPLAYABLE = 4;
+ public static final int MSG_UI_PROCESS_CAPTION_TRACK = 5;
+ public static final int MSG_UI_START_CAPTION_TRACK = 6;
+ public static final int MSG_UI_STOP_CAPTION_TRACK = 7;
+ public static final int MSG_UI_RESET_CAPTION_TRACK = 8;
+ public static final int MSG_UI_SET_STATUS_TEXT = 9;
+ public static final int MSG_UI_TOAST_RESCAN_NEEDED = 10;
+
+ private final Context mContext;
+ private final Handler mUiHandler;
+ private final View mOverlayView;
+ private final TextView mMessageView;
+ private final TextView mStatusView;
+ private final TextView mAudioStatusView;
+ private final ViewGroup mMessageLayout;
+ private final CaptionTrackRenderer mCaptionTrackRenderer;
+ private final TunerSessionWorker mSessionWorker;
+ private boolean mReleased = false;
+ private boolean mPlayPaused;
+ private long mTuneStartTimestamp;
+
+ public TunerSession(Context context, ChannelDataManager channelDataManager,
+ BufferManager bufferManager) {
+ super(context);
+ mContext = context;
+ mUiHandler = new Handler(this);
+ LayoutInflater inflater = (LayoutInflater)
+ context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ mOverlayView = inflater.inflate(R.layout.ut_overlay_view, null);
+ mMessageLayout = (ViewGroup) mOverlayView.findViewById(R.id.message_layout);
+ mMessageLayout.setVisibility(View.INVISIBLE);
+ mMessageView = (TextView) mOverlayView.findViewById(R.id.message);
+ mStatusView = (TextView) mOverlayView.findViewById(R.id.tuner_status);
+ boolean showDebug = SystemPropertiesProxy.getBoolean(USBTUNER_SHOW_DEBUG, false);
+ mStatusView.setVisibility(showDebug ? View.VISIBLE : View.INVISIBLE);
+ mAudioStatusView = (TextView) mOverlayView.findViewById(R.id.audio_status);
+ mAudioStatusView.setVisibility(View.INVISIBLE);
+ mAudioStatusView.setText(Html.fromHtml(StatusTextUtils.getAudioWarningInHTML(
+ context.getString(R.string.ut_surround_sound_disabled))));
+ CaptionLayout captionLayout = (CaptionLayout) mOverlayView.findViewById(R.id.caption);
+ mCaptionTrackRenderer = new CaptionTrackRenderer(captionLayout);
+ mSessionWorker = new TunerSessionWorker(context, channelDataManager,
+ bufferManager, this);
+ }
+
+ public boolean isReleased() {
+ return mReleased;
+ }
+
+ @Override
+ public View onCreateOverlayView() {
+ return mOverlayView;
+ }
+
+ @Override
+ public boolean onSelectTrack(int type, String trackId) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_SELECT_TRACK, type, 0, trackId);
+ return false;
+ }
+
+ @Override
+ public void onSetCaptionEnabled(boolean enabled) {
+ mSessionWorker.setCaptionEnabled(enabled);
+ }
+
+ @Override
+ public void onSetStreamVolume(float volume) {
+ mSessionWorker.setStreamVolume(volume);
+ }
+
+ @Override
+ public boolean onSetSurface(Surface surface) {
+ mSessionWorker.setSurface(surface);
+ return true;
+ }
+
+ @Override
+ public void onTimeShiftPause() {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_PAUSE);
+ mPlayPaused = true;
+ }
+
+ @Override
+ public void onTimeShiftResume() {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_RESUME);
+ mPlayPaused = false;
+ }
+
+ @Override
+ public void onTimeShiftSeekTo(long timeMs) {
+ if (DEBUG) Log.d(TAG, "Timeshift seekTo requested position: " + timeMs / 1000);
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_TIMESHIFT_SEEK_TO,
+ mPlayPaused ? 1 : 0, 0, timeMs);
+ }
+
+ @Override
+ public void onTimeShiftSetPlaybackParams(PlaybackParams params) {
+ mSessionWorker.sendMessage(
+ TunerSessionWorker.MSG_TIMESHIFT_SET_PLAYBACKPARAMS, params);
+ }
+
+ @Override
+ public long onTimeShiftGetStartPosition() {
+ return mSessionWorker.getStartPosition();
+ }
+
+ @Override
+ public long onTimeShiftGetCurrentPosition() {
+ return mSessionWorker.getCurrentPosition();
+ }
+
+ @Override
+ public boolean onTune(Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "onTune to " + channelUri != null ? channelUri.toString() : "");
+ }
+ if (channelUri == null) {
+ Log.w(TAG, "onTune() is failed due to null channelUri.");
+ mSessionWorker.stopTune();
+ return false;
+ }
+ mTuneStartTimestamp = SystemClock.elapsedRealtime();
+ mSessionWorker.tune(channelUri);
+ mPlayPaused = false;
+ return true;
+ }
+
+ @TargetApi(Build.VERSION_CODES.N)
+ @Override
+ public void onTimeShiftPlay(Uri recordUri) {
+ if (recordUri == null) {
+ Log.w(TAG, "onTimeShiftPlay() is failed due to null channelUri.");
+ mSessionWorker.stopTune();
+ return;
+ }
+ mTuneStartTimestamp = SystemClock.elapsedRealtime();
+ mSessionWorker.tune(recordUri);
+ mPlayPaused = false;
+ }
+
+ @Override
+ public void onUnblockContent(TvContentRating unblockedRating) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_UNBLOCKED_RATING,
+ unblockedRating);
+ }
+
+ @Override
+ public void onRelease() {
+ if (DEBUG) {
+ Log.d(TAG, "onRelease");
+ }
+ mReleased = true;
+ mSessionWorker.release();
+ mUiHandler.removeCallbacksAndMessages(null);
+ }
+
+ /**
+ * Sets {@link AudioCapabilities}.
+ */
+ public void setAudioCapabilities(AudioCapabilities audioCapabilities) {
+ mSessionWorker.sendMessage(TunerSessionWorker.MSG_AUDIO_CAPABILITIES_CHANGED,
+ audioCapabilities);
+ }
+
+ @Override
+ public void notifyVideoAvailable() {
+ super.notifyVideoAvailable();
+ if (mTuneStartTimestamp != 0) {
+ Log.i(TAG, "[Profiler] Video available in "
+ + (SystemClock.elapsedRealtime() - mTuneStartTimestamp) + " ms");
+ mTuneStartTimestamp = 0;
+ }
+ }
+
+ @Override
+ public void notifyVideoUnavailable(int reason) {
+ super.notifyVideoUnavailable(reason);
+ if (reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING
+ && reason != TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL) {
+ notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ }
+ }
+
+ public void sendUiMessage(int message) {
+ mUiHandler.sendEmptyMessage(message);
+ }
+
+ public void sendUiMessage(int message, Object object) {
+ mUiHandler.obtainMessage(message, object).sendToTarget();
+ }
+
+ public void sendUiMessage(int message, int arg1, int arg2, Object object) {
+ mUiHandler.obtainMessage(message, arg1, arg2, object).sendToTarget();
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_UI_SHOW_MESSAGE: {
+ mMessageView.setText((String) msg.obj);
+ mMessageLayout.setVisibility(View.VISIBLE);
+ return true;
+ }
+ case MSG_UI_HIDE_MESSAGE: {
+ mMessageLayout.setVisibility(View.INVISIBLE);
+ return true;
+ }
+ case MSG_UI_SHOW_AUDIO_UNPLAYABLE: {
+ // Showing message of enabling surround sound only when global surround sound
+ // setting is "never".
+ final int value = GlobalSettingsUtils.getEncodedSurroundOutputSettings(mContext);
+ if (value == GlobalSettingsUtils.ENCODED_SURROUND_OUTPUT_NEVER) {
+ mAudioStatusView.setVisibility(View.VISIBLE);
+ } else {
+ Log.e(TAG, "Audio is unavailable, surround sound setting is " + value);
+ }
+ return true;
+ }
+ case MSG_UI_HIDE_AUDIO_UNPLAYABLE: {
+ mAudioStatusView.setVisibility(View.INVISIBLE);
+ return true;
+ }
+ case MSG_UI_PROCESS_CAPTION_TRACK: {
+ mCaptionTrackRenderer.processCaptionEvent((CaptionEvent) msg.obj);
+ return true;
+ }
+ case MSG_UI_START_CAPTION_TRACK: {
+ mCaptionTrackRenderer.start((AtscCaptionTrack) msg.obj);
+ return true;
+ }
+ case MSG_UI_STOP_CAPTION_TRACK: {
+ mCaptionTrackRenderer.stop();
+ return true;
+ }
+ case MSG_UI_RESET_CAPTION_TRACK: {
+ mCaptionTrackRenderer.reset();
+ return true;
+ }
+ case MSG_UI_SET_STATUS_TEXT: {
+ mStatusView.setText((CharSequence) msg.obj);
+ return true;
+ }
+ case MSG_UI_TOAST_RESCAN_NEEDED: {
+ Toast.makeText(mContext, R.string.ut_rescan_needed, Toast.LENGTH_LONG).show();
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
new file mode 100644
index 00000000..c0a613a4
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerSessionWorker.java
@@ -0,0 +1,1583 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.MediaFormat;
+import android.media.PlaybackParams;
+import android.media.tv.TvContentRating;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvTrackInfo;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.SystemClock;
+import android.support.annotation.AnyThread;
+import android.support.annotation.MainThread;
+import android.support.annotation.WorkerThread;
+import android.text.Html;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.view.Surface;
+import android.view.accessibility.CaptioningManager;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.ExoPlayer;
+import com.android.tv.common.SoftPreconditions;
+import com.android.tv.common.TvContentRatingCache;
+import com.android.tv.tuner.TunerPreferences;
+import com.android.tv.tuner.data.Cea708Data;
+import com.android.tv.tuner.data.Channel;
+import com.android.tv.tuner.data.PsipData.EitItem;
+import com.android.tv.tuner.data.PsipData.TvTracksInterface;
+import com.android.tv.tuner.data.Track.AtscAudioTrack;
+import com.android.tv.tuner.data.Track.AtscCaptionTrack;
+import com.android.tv.tuner.data.TunerChannel;
+import com.android.tv.tuner.exoplayer.MpegTsRendererBuilder;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.DvrStorageManager;
+import com.android.tv.tuner.exoplayer.MpegTsPlayer;
+import com.android.tv.tuner.source.TsDataSource;
+import com.android.tv.tuner.source.TsDataSourceManager;
+import com.android.tv.tuner.util.StatusTextUtils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * {@link TunerSessionWorker} implements a handler thread which processes TV input jobs
+ * such as handling {@link ExoPlayer}, managing a tuner device, trickplay, and so on.
+ */
+@WorkerThread
+public class TunerSessionWorker implements PlaybackBufferListener,
+ MpegTsPlayer.VideoEventListener, MpegTsPlayer.Listener, EventDetector.EventListener,
+ ChannelDataManager.ProgramInfoListener, Handler.Callback {
+ private static final String TAG = "TunerSessionWorker";
+ private static final boolean DEBUG = false;
+ private static final boolean ENABLE_PROFILER = true;
+ private static final String PLAY_FROM_CHANNEL = "channel";
+
+ // Public messages
+ public static final int MSG_SELECT_TRACK = 1;
+ public static final int MSG_UPDATE_CAPTION_TRACK = 2;
+ public static final int MSG_SET_STREAM_VOLUME = 3;
+ public static final int MSG_TIMESHIFT_PAUSE = 4;
+ public static final int MSG_TIMESHIFT_RESUME = 5;
+ public static final int MSG_TIMESHIFT_SEEK_TO = 6;
+ public static final int MSG_TIMESHIFT_SET_PLAYBACKPARAMS = 7;
+ public static final int MSG_AUDIO_CAPABILITIES_CHANGED = 8;
+ public static final int MSG_UNBLOCKED_RATING = 9;
+
+ // Private messages
+ private static final int MSG_TUNE = 1000;
+ private static final int MSG_RELEASE = 1001;
+ private static final int MSG_RETRY_PLAYBACK = 1002;
+ private static final int MSG_START_PLAYBACK = 1003;
+ private static final int MSG_UPDATE_PROGRAM = 1008;
+ private static final int MSG_SCHEDULE_OF_PROGRAMS = 1009;
+ private static final int MSG_UPDATE_CHANNEL_INFO = 1010;
+ private static final int MSG_TRICKPLAY_BY_SEEK = 1011;
+ private static final int MSG_SMOOTH_TRICKPLAY_MONITOR = 1012;
+ private static final int MSG_PARENTAL_CONTROLS = 1015;
+ private static final int MSG_RESCHEDULE_PROGRAMS = 1016;
+ private static final int MSG_BUFFER_START_TIME_CHANGED = 1017;
+ private static final int MSG_CHECK_SIGNAL = 1018;
+ private static final int MSG_DISCOVER_CAPTION_SERVICE_NUMBER = 1019;
+ private static final int MSG_RESET_PLAYBACK = 1020;
+ private static final int MSG_BUFFER_STATE_CHANGED = 1021;
+ private static final int MSG_PROGRAM_DATA_RESULT = 1022;
+ private static final int MSG_STOP_TUNE = 1023;
+ private static final int MSG_SET_SURFACE = 1024;
+ private static final int MSG_NOTIFY_AUDIO_TRACK_UPDATED = 1025;
+
+ private static final int TS_PACKET_SIZE = 188;
+ private static final int CHECK_NO_SIGNAL_INITIAL_DELAY_MS = 4000;
+ private static final int CHECK_NO_SIGNAL_PERIOD_MS = 500;
+ private static final int RECOVER_STOPPED_PLAYBACK_PERIOD_MS = 2500;
+ private static final int PARENTAL_CONTROLS_INTERVAL_MS = 5000;
+ private static final int RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS = 4000;
+ private static final int RESCHEDULE_PROGRAMS_INTERVAL_MS = 10000;
+ private static final int RESCHEDULE_PROGRAMS_TOLERANCE_MS = 2000;
+ // The following 3s is defined empirically. This should be larger than 2s considering video
+ // key frame interval in the TS stream.
+ private static final int PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS = 3000;
+ private static final int PLAYBACK_RETRY_DELAY_MS = 5000;
+ private static final int MAX_IMMEDIATE_RETRY_COUNT = 5;
+ private static final long INVALID_TIME = -1;
+
+ // Some examples of the track ids of the audio tracks, "a0", "a1", "a2".
+ // The number after prefix is being used for indicating a index of the given audio track.
+ private static final String AUDIO_TRACK_PREFIX = "a";
+
+ // Some examples of the tracks id of the caption tracks, "s1", "s2", "s3".
+ // The number after prefix is being used for indicating a index of a caption service number
+ // of the given caption track.
+ private static final String SUBTITLE_TRACK_PREFIX = "s";
+ private static final int TRACK_PREFIX_SIZE = 1;
+ private static final String VIDEO_TRACK_ID = "v";
+ private static final long BUFFER_UNDERFLOW_BUFFER_MS = 5000;
+
+ // Actual interval would be divided by the speed.
+ private static final int EXPECTED_KEY_FRAME_INTERVAL_MS = 500;
+ private static final int MIN_TRICKPLAY_SEEK_INTERVAL_MS = 20;
+ private static final int TRICKPLAY_MONITOR_INTERVAL_MS = 250;
+
+ private final Context mContext;
+ private final ChannelDataManager mChannelDataManager;
+ private final TsDataSourceManager mSourceManager;
+ private volatile Surface mSurface;
+ private volatile float mVolume = 1.0f;
+ private volatile boolean mCaptionEnabled;
+ private volatile MpegTsPlayer mPlayer;
+ private volatile TunerChannel mChannel;
+ private volatile Long mRecordingDuration;
+ private volatile long mRecordStartTimeMs;
+ private volatile long mBufferStartTimeMs;
+ private String mRecordingId;
+ private final Handler mHandler;
+ private int mRetryCount;
+ private final ArrayList<TvTrackInfo> mTvTracks;
+ private final SparseArray<AtscAudioTrack> mAudioTrackMap;
+ private final SparseArray<AtscCaptionTrack> mCaptionTrackMap;
+ private AtscCaptionTrack mCaptionTrack;
+ private PlaybackParams mPlaybackParams = new PlaybackParams();
+ private boolean mPlayerStarted = false;
+ private boolean mReportedDrawnToSurface = false;
+ private boolean mReportedWeakSignal = false;
+ private EitItem mProgram;
+ private List<EitItem> mPrograms;
+ private final TvInputManager mTvInputManager;
+ private boolean mChannelBlocked;
+ private TvContentRating mUnblockedContentRating;
+ private long mLastPositionMs;
+ private AudioCapabilities mAudioCapabilities;
+ private final CountDownLatch mReleaseLatch = new CountDownLatch(1);
+ private long mLastLimitInBytes;
+ private long mLastPositionInBytes;
+ private final BufferManager mBufferManager;
+ private final TvContentRatingCache mTvContentRatingCache = TvContentRatingCache.getInstance();
+ private final TunerSession mSession;
+ private int mPlayerState = ExoPlayer.STATE_IDLE;
+ private long mPreparingStartTimeMs;
+ private long mBufferingStartTimeMs;
+ private long mReadyStartTimeMs;
+
+ public TunerSessionWorker(Context context, ChannelDataManager channelDataManager,
+ BufferManager bufferManager, TunerSession tunerSession) {
+ if (DEBUG) Log.d(TAG, "TunerSessionWorker created");
+ mContext = context;
+
+ // HandlerThread should be set up before it is registered as a listener in the all other
+ // components.
+ HandlerThread handlerThread = new HandlerThread(TAG);
+ handlerThread.start();
+ mHandler = new Handler(handlerThread.getLooper(), this);
+ mSession = tunerSession;
+ mChannelDataManager = channelDataManager;
+ mChannelDataManager.setListener(this);
+ mChannelDataManager.checkDataVersion(mContext);
+ mSourceManager = TsDataSourceManager.createSourceManager(false);
+ mTvInputManager = (TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE);
+ mTvTracks = new ArrayList<>();
+ mAudioTrackMap = new SparseArray<>();
+ mCaptionTrackMap = new SparseArray<>();
+ CaptioningManager captioningManager =
+ (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
+ mCaptionEnabled = captioningManager.isEnabled();
+ mPlaybackParams.setSpeed(1.0f);
+ mBufferManager = bufferManager;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ mReadyStartTimeMs = INVALID_TIME;
+ }
+
+ // Public methods
+ @MainThread
+ public void tune(Uri channelUri) {
+ mHandler.removeCallbacksAndMessages(null);
+ mSourceManager.setHasPendingTune();
+ sendMessage(MSG_TUNE, channelUri);
+ }
+
+ @MainThread
+ public void stopTune() {
+ mHandler.removeCallbacksAndMessages(null);
+ sendMessage(MSG_STOP_TUNE);
+ }
+
+ /**
+ * Sets {@link Surface}.
+ */
+ @MainThread
+ public void setSurface(Surface surface) {
+ if (surface != null && !surface.isValid()) {
+ Log.w(TAG, "Ignoring invalid surface.");
+ return;
+ }
+ // mSurface is kept even when tune is called right after. But, messages can be deleted by
+ // tune or updateChannelBlockStatus. So mSurface should be stored here, not through message.
+ mSurface = surface;
+ mHandler.sendEmptyMessage(MSG_SET_SURFACE);
+ }
+
+ /**
+ * Sets volume.
+ */
+ @MainThread
+ public void setStreamVolume(float volume) {
+ // mVolume is kept even when tune is called right after. But, messages can be deleted by
+ // tune or updateChannelBlockStatus. So mVolume is stored here and mPlayer.setVolume will be
+ // called in MSG_SET_STREAM_VOLUME.
+ mVolume = volume;
+ mHandler.sendEmptyMessage(MSG_SET_STREAM_VOLUME);
+ }
+
+ /**
+ * Sets if caption is enabled or disabled.
+ */
+ @MainThread
+ public void setCaptionEnabled(boolean captionEnabled) {
+ // mCaptionEnabled is kept even when tune is called right after. But, messages can be
+ // deleted by tune or updateChannelBlockStatus. So mCaptionEnabled is stored here and
+ // start/stopCaptionTrack will be called in MSG_UPDATE_CAPTION_STATUS.
+ mCaptionEnabled = captionEnabled;
+ mHandler.sendEmptyMessage(MSG_UPDATE_CAPTION_TRACK);
+ }
+
+ public TunerChannel getCurrentChannel() {
+ return mChannel;
+ }
+
+ @MainThread
+ public long getStartPosition() {
+ return mBufferStartTimeMs;
+ }
+
+
+ private String getRecordingPath() {
+ return Uri.parse(mRecordingId).getPath();
+ }
+
+ private Long getDurationForRecording(String recordingId) {
+ try {
+ DvrStorageManager storageManager =
+ new DvrStorageManager(new File(getRecordingPath()), false);
+ Pair<String, MediaFormat> trackInfo = null;
+ try {
+ trackInfo = storageManager.readTrackInfoFile(false);
+ } catch (FileNotFoundException e) {
+ }
+ if (trackInfo == null) {
+ trackInfo = storageManager.readTrackInfoFile(true);
+ }
+ Long durationUs = trackInfo.second.getLong(MediaFormat.KEY_DURATION);
+ // we need duration by milli for trickplay notification.
+ return durationUs != null ? durationUs / 1000 : null;
+ } catch (IOException e) {
+ Log.e(TAG, "meta file for recording was not found: " + recordingId);
+ return null;
+ }
+ }
+
+ @MainThread
+ public long getCurrentPosition() {
+ // TODO: More precise time may be necessary.
+ MpegTsPlayer mpegTsPlayer = mPlayer;
+ long currentTime = mpegTsPlayer != null
+ ? mRecordStartTimeMs + mpegTsPlayer.getCurrentPosition() : mRecordStartTimeMs;
+ if (mChannel == null && mPlayerState == ExoPlayer.STATE_ENDED) {
+ currentTime = mRecordingDuration + mRecordStartTimeMs;
+ }
+ if (DEBUG) {
+ long systemCurrentTime = System.currentTimeMillis();
+ Log.d(TAG, "currentTime = " + currentTime
+ + " ; System.currentTimeMillis() = " + systemCurrentTime
+ + " ; diff = " + (currentTime - systemCurrentTime));
+ }
+ return currentTime;
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType) {
+ mHandler.sendEmptyMessage(messageType);
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType, Object object) {
+ mHandler.obtainMessage(messageType, object).sendToTarget();
+ }
+
+ @AnyThread
+ public void sendMessage(int messageType, int arg1, int arg2, Object object) {
+ mHandler.obtainMessage(messageType, arg1, arg2, object).sendToTarget();
+ }
+
+ @MainThread
+ public void release() {
+ if (DEBUG) Log.d(TAG, "release()");
+ mChannelDataManager.setListener(null);
+ mHandler.removeCallbacksAndMessages(null);
+ mHandler.sendEmptyMessage(MSG_RELEASE);
+ try {
+ mReleaseLatch.await();
+ } catch (InterruptedException e) {
+ Log.e(TAG, "Couldn't wait for finish of MSG_RELEASE", e);
+ } finally {
+ mHandler.getLooper().quitSafely();
+ }
+ }
+
+ // MpegTsPlayer.Listener
+ // Called in the same thread as mHandler.
+ @Override
+ public void onStateChanged(boolean playWhenReady, int playbackState) {
+ if (DEBUG) Log.d(TAG, "ExoPlayer state change: " + playbackState + " " + playWhenReady);
+ if (playbackState == mPlayerState) {
+ return;
+ }
+ mReadyStartTimeMs = INVALID_TIME;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ if (playbackState == ExoPlayer.STATE_READY) {
+ if (DEBUG) Log.d(TAG, "ExoPlayer ready");
+ if (!mPlayerStarted) {
+ sendMessage(MSG_START_PLAYBACK, mPlayer);
+ }
+ mReadyStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_PREPARING) {
+ mPreparingStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_BUFFERING) {
+ mBufferingStartTimeMs = SystemClock.elapsedRealtime();
+ } else if (playbackState == ExoPlayer.STATE_ENDED) {
+ // Final status
+ // notification of STATE_ENDED from MpegTsPlayer will be ignored afterwards.
+ Log.i(TAG, "Player ended: end of stream");
+ if (mChannel != null) {
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ }
+ }
+ mPlayerState = playbackState;
+ }
+
+ @Override
+ public void onError(Exception e) {
+ if (TunerPreferences.getStoreTsStream(mContext)) {
+ // Crash intentionally to capture the error causing TS file.
+ Log.e(TAG, "Crash intentionally to capture the error causing TS file. "
+ + e.getMessage());
+ SoftPreconditions.checkState(false);
+ }
+ // There maybe some errors that finally raise ExoPlaybackException and will be handled here.
+ // If we are playing live stream, retrying playback maybe helpful. But for recorded stream,
+ // retrying playback is not helpful.
+ if (mChannel != null) {
+ mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer).sendToTarget();
+ }
+ }
+
+ @Override
+ public void onVideoSizeChanged(int width, int height, float pixelWidthHeight) {
+ if (mChannel != null && mChannel.hasVideo()) {
+ updateVideoTrack(width, height);
+ }
+ if (mRecordingId != null) {
+ updateVideoTrack(width, height);
+ }
+ }
+
+ @Override
+ public void onDrawnToSurface(MpegTsPlayer player, Surface surface) {
+ if (mSurface != null && mPlayerStarted) {
+ if (DEBUG) Log.d(TAG, "MSG_DRAWN_TO_SURFACE");
+ mBufferStartTimeMs = mRecordStartTimeMs =
+ (mRecordingId != null) ? 0 : System.currentTimeMillis();
+ notifyVideoAvailable();
+ mReportedDrawnToSurface = true;
+
+ // If surface is drawn successfully, it means that the playback was brought back
+ // to normal and therefore, the playback recovery status will be reset through
+ // setting a zero value to the retry count.
+ // TODO: Consider audio only channels for detecting playback status changes to
+ // be normal.
+ mRetryCount = 0;
+ if (mCaptionEnabled && mCaptionTrack != null) {
+ startCaptionTrack();
+ } else {
+ stopCaptionTrack();
+ }
+ mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED);
+ }
+ }
+
+ @Override
+ public void onSmoothTrickplayForceStopped() {
+ if (mPlayer == null || !mHandler.hasMessages(MSG_SMOOTH_TRICKPLAY_MONITOR)) {
+ return;
+ }
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ doTrickplayBySeek((int) mPlayer.getCurrentPosition());
+ }
+
+ @Override
+ public void onAudioUnplayable() {
+ if (mPlayer == null) {
+ return;
+ }
+ Log.i(TAG, "AC3 audio cannot be played due to device limitation");
+ mSession.sendUiMessage(
+ TunerSession.MSG_UI_SHOW_AUDIO_UNPLAYABLE);
+ }
+
+ // MpegTsPlayer.VideoEventListener
+ @Override
+ public void onEmitCaptionEvent(Cea708Data.CaptionEvent event) {
+ mSession.sendUiMessage(TunerSession.MSG_UI_PROCESS_CAPTION_TRACK, event);
+ }
+
+ @Override
+ public void onDiscoverCaptionServiceNumber(int serviceNumber) {
+ sendMessage(MSG_DISCOVER_CAPTION_SERVICE_NUMBER, serviceNumber);
+ }
+
+ // ChannelDataManager.ProgramInfoListener
+ @Override
+ public void onProgramsArrived(TunerChannel channel, List<EitItem> programs) {
+ sendMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(channel, programs));
+ }
+
+ @Override
+ public void onChannelArrived(TunerChannel channel) {
+ sendMessage(MSG_UPDATE_CHANNEL_INFO, channel);
+ }
+
+ @Override
+ public void onRescanNeeded() {
+ mSession.sendUiMessage(TunerSession.MSG_UI_TOAST_RESCAN_NEEDED);
+ }
+
+ @Override
+ public void onRequestProgramsResponse(TunerChannel channel, List<EitItem> programs) {
+ sendMessage(MSG_PROGRAM_DATA_RESULT, new Pair<>(channel, programs));
+ }
+
+ // PlaybackBufferListener
+ @Override
+ public void onBufferStartTimeChanged(long startTimeMs) {
+ sendMessage(MSG_BUFFER_START_TIME_CHANGED, startTimeMs);
+ }
+
+ @Override
+ public void onBufferStateChanged(boolean available) {
+ sendMessage(MSG_BUFFER_STATE_CHANGED, available);
+ }
+
+ @Override
+ public void onDiskTooSlow() {
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ }
+
+ // EventDetector.EventListener
+ @Override
+ public void onChannelDetected(TunerChannel channel, boolean channelArrivedAtFirstTime) {
+ mChannelDataManager.notifyChannelDetected(channel, channelArrivedAtFirstTime);
+ }
+
+ @Override
+ public void onEventDetected(TunerChannel channel, List<EitItem> items) {
+ mChannelDataManager.notifyEventDetected(channel, items);
+ }
+
+ @Override
+ public void onChannelScanDone() {
+ // do nothing.
+ }
+
+ private long parseChannel(Uri uri) {
+ try {
+ List<String> paths = uri.getPathSegments();
+ if (paths.size() > 1 && paths.get(0).equals(PLAY_FROM_CHANNEL)) {
+ return ContentUris.parseId(uri);
+ }
+ } catch (UnsupportedOperationException | NumberFormatException e) {
+ }
+ return -1;
+ }
+
+ private static class RecordedProgram {
+ private final long mChannelId;
+ private final String mDataUri;
+
+ private static final String[] PROJECTION = {
+ TvContract.Programs.COLUMN_CHANNEL_ID,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI,
+ };
+
+ public RecordedProgram(Cursor cursor) {
+ int index = 0;
+ mChannelId = cursor.getLong(index++);
+ mDataUri = cursor.getString(index++);
+ }
+
+ public RecordedProgram(long channelId, String dataUri) {
+ mChannelId = channelId;
+ mDataUri = dataUri;
+ }
+
+ public static RecordedProgram onQuery(Cursor c) {
+ RecordedProgram recording = null;
+ if (c != null && c.moveToNext()) {
+ recording = new RecordedProgram(c);
+ }
+ return recording;
+ }
+
+ public String getDataUri() {
+ return mDataUri;
+ }
+ }
+
+ private RecordedProgram getRecordedProgram(Uri recordedUri) {
+ ContentResolver resolver = mContext.getContentResolver();
+ try(Cursor c = resolver.query(recordedUri, RecordedProgram.PROJECTION, null, null, null)) {
+ if (c != null) {
+ RecordedProgram result = RecordedProgram.onQuery(c);
+ if (DEBUG) {
+ Log.d(TAG, "Finished query for " + this);
+ }
+ return result;
+ } else {
+ if (c == null) {
+ Log.e(TAG, "Unknown query error for " + this);
+ } else {
+ if (DEBUG) Log.d(TAG, "Canceled query for " + this);
+ }
+ return null;
+ }
+ }
+ }
+
+ private String parseRecording(Uri uri) {
+ RecordedProgram recording = getRecordedProgram(uri);
+ if (recording != null) {
+ return recording.getDataUri();
+ }
+ return null;
+ }
+
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_TUNE: {
+ if (DEBUG) Log.d(TAG, "MSG_TUNE");
+
+ // When sequential tuning messages arrived, it skips middle tuning messages in order
+ // to change to the last requested channel quickly.
+ if (mHandler.hasMessages(MSG_TUNE)) {
+ return true;
+ }
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
+ Uri channelUri = (Uri) msg.obj;
+ String recording = null;
+ long channelId = parseChannel(channelUri);
+ TunerChannel channel = (channelId == -1) ? null
+ : mChannelDataManager.getChannel(channelId);
+ if (channelId == -1) {
+ recording = parseRecording(channelUri);
+ }
+ if (channel == null && recording == null) {
+ Log.w(TAG, "onTune() is failed. Can't find channel for " + channelUri);
+ stopTune();
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return true;
+ }
+ mHandler.removeCallbacksAndMessages(null);
+ if (channel != null) {
+ mChannelDataManager.requestProgramsData(channel);
+ }
+ prepareTune(channel, recording);
+ // TODO: Need to refactor. notifyContentAllowed() should not be called if parental
+ // control is turned on.
+ mSession.notifyContentAllowed();
+ resetPlayback();
+ resetTvTracks();
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS);
+ return true;
+ }
+ case MSG_STOP_TUNE: {
+ if (DEBUG) Log.d(TAG, "MSG_STOP_TUNE");
+ mChannel = null;
+ stopPlayback();
+ stopCaptionTrack();
+ resetTvTracks();
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return true;
+ }
+ case MSG_RELEASE: {
+ if (DEBUG) Log.d(TAG, "MSG_RELEASE");
+ mHandler.removeCallbacksAndMessages(null);
+ stopPlayback();
+ stopCaptionTrack();
+ mSourceManager.release();
+ mReleaseLatch.countDown();
+ return true;
+ }
+ case MSG_RETRY_PLAYBACK: {
+ if (mPlayer == msg.obj) {
+ Log.i(TAG, "Retrying the playback for channel: " + mChannel);
+ mHandler.removeMessages(MSG_RETRY_PLAYBACK);
+ // When there is a request of retrying playback, don't reuse TunerHal.
+ mSourceManager.setKeepTuneStatus(false);
+ mRetryCount++;
+ if (DEBUG) {
+ Log.d(TAG, "MSG_RETRY_PLAYBACK " + mRetryCount);
+ }
+ if (mRetryCount <= MAX_IMMEDIATE_RETRY_COUNT) {
+ resetPlayback();
+ } else {
+ // When it reaches this point, it may be due to an error that occurred in
+ // the tuner device. Calling stopPlayback() resets the tuner device
+ // to recover from the error.
+ stopPlayback();
+ stopCaptionTrack();
+
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+
+ // After MAX_IMMEDIATE_RETRY_COUNT, give some delay of an empirically chosen
+ // value before recovering the playback.
+ mHandler.sendEmptyMessageDelayed(MSG_RESET_PLAYBACK,
+ RECOVER_STOPPED_PLAYBACK_PERIOD_MS);
+ }
+ }
+ return true;
+ }
+ case MSG_RESET_PLAYBACK: {
+ if (DEBUG) Log.d(TAG, "MSG_RESET_PLAYBACK");
+ resetPlayback();
+ return true;
+ }
+ case MSG_START_PLAYBACK: {
+ if (DEBUG) Log.d(TAG, "MSG_START_PLAYBACK");
+ if (mChannel != null || mRecordingId != null) {
+ startPlayback(msg.obj);
+ }
+ return true;
+ }
+ case MSG_UPDATE_PROGRAM: {
+ if (mChannel != null) {
+ EitItem program = (EitItem) msg.obj;
+ updateTvTracks(program, false);
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ }
+ return true;
+ }
+ case MSG_SCHEDULE_OF_PROGRAMS: {
+ mHandler.removeMessages(MSG_UPDATE_PROGRAM);
+ Pair<TunerChannel, List<EitItem>> pair =
+ (Pair<TunerChannel, List<EitItem>>) msg.obj;
+ TunerChannel channel = pair.first;
+ if (mChannel == null) {
+ return true;
+ }
+ if (mChannel != null && mChannel.compareTo(channel) != 0) {
+ return true;
+ }
+ mPrograms = pair.second;
+ EitItem currentProgram = getCurrentProgram();
+ if (currentProgram == null) {
+ mProgram = null;
+ }
+ long currentTimeMs = getCurrentPosition();
+ if (mPrograms != null) {
+ for (EitItem item : mPrograms) {
+ if (currentProgram != null && currentProgram.compareTo(item) == 0) {
+ if (DEBUG) {
+ Log.d(TAG, "Update current TvTracks " + item);
+ }
+ if (mProgram != null && mProgram.compareTo(item) == 0) {
+ continue;
+ }
+ mProgram = item;
+ updateTvTracks(item, false);
+ } else if (item.getStartTimeUtcMillis() > currentTimeMs) {
+ if (DEBUG) {
+ Log.d(TAG, "Update next TvTracks " + item + " "
+ + (item.getStartTimeUtcMillis() - currentTimeMs));
+ }
+ mHandler.sendMessageDelayed(
+ mHandler.obtainMessage(MSG_UPDATE_PROGRAM, item),
+ item.getStartTimeUtcMillis() - currentTimeMs);
+ }
+ }
+ }
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ return true;
+ }
+ case MSG_UPDATE_CHANNEL_INFO: {
+ TunerChannel channel = (TunerChannel) msg.obj;
+ if (mChannel != null && mChannel.compareTo(channel) == 0) {
+ updateChannelInfo(channel);
+ }
+ return true;
+ }
+ case MSG_PROGRAM_DATA_RESULT: {
+ TunerChannel channel = (TunerChannel) ((Pair) msg.obj).first;
+
+ // If there already exists, skip it since real-time data is a top priority,
+ if (mChannel != null && mChannel.compareTo(channel) == 0
+ && mPrograms == null && mProgram == null) {
+ sendMessage(MSG_SCHEDULE_OF_PROGRAMS, msg.obj);
+ }
+ return true;
+ }
+ case MSG_TRICKPLAY_BY_SEEK: {
+ if (mPlayer == null) {
+ return true;
+ }
+ doTrickplayBySeek(msg.arg1);
+ return true;
+ }
+ case MSG_SMOOTH_TRICKPLAY_MONITOR: {
+ if (mPlayer == null) {
+ return true;
+ }
+ long systemCurrentTime = System.currentTimeMillis();
+ long position = getCurrentPosition();
+ if (mRecordingId == null) {
+ // Checks if the position exceeds the upper bound when forwarding,
+ // or exceed the lower bound when rewinding.
+ // If the direction is not checked, there can be some issues.
+ // (See b/29939781 for more details.)
+ if ((position > systemCurrentTime && mPlaybackParams.getSpeed() > 0L)
+ || (position < mBufferStartTimeMs && mPlaybackParams.getSpeed() < 0L)) {
+ doTimeShiftResume();
+ return true;
+ }
+ } else {
+ if (position > mRecordingDuration || position < 0) {
+ doTimeShiftPause();
+ return true;
+ }
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR,
+ TRICKPLAY_MONITOR_INTERVAL_MS);
+ return true;
+ }
+ case MSG_RESCHEDULE_PROGRAMS: {
+ doReschedulePrograms();
+ return true;
+ }
+ case MSG_PARENTAL_CONTROLS: {
+ doParentalControls();
+ mHandler.removeMessages(MSG_PARENTAL_CONTROLS);
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS,
+ PARENTAL_CONTROLS_INTERVAL_MS);
+ return true;
+ }
+ case MSG_UNBLOCKED_RATING: {
+ mUnblockedContentRating = (TvContentRating) msg.obj;
+ doParentalControls();
+ mHandler.removeMessages(MSG_PARENTAL_CONTROLS);
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS,
+ PARENTAL_CONTROLS_INTERVAL_MS);
+ return true;
+ }
+ case MSG_DISCOVER_CAPTION_SERVICE_NUMBER: {
+ int serviceNumber = (int) msg.obj;
+ doDiscoverCaptionServiceNumber(serviceNumber);
+ return true;
+ }
+ case MSG_SELECT_TRACK: {
+ if (mChannel != null) {
+ doSelectTrack(msg.arg1, (String) msg.obj);
+ } else if (mRecordingId != null) {
+ // TODO : mChannel == null && mRecordingId != null
+ Log.d(TAG, "track selected for recording");
+ }
+ return true;
+ }
+ case MSG_UPDATE_CAPTION_TRACK: {
+ if (mCaptionEnabled) {
+ startCaptionTrack();
+ } else {
+ stopCaptionTrack();
+ }
+ return true;
+ }
+ case MSG_TIMESHIFT_PAUSE: {
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_PAUSE");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftPause();
+ return true;
+ }
+ case MSG_TIMESHIFT_RESUME: {
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_RESUME");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftResume();
+ return true;
+ }
+ case MSG_TIMESHIFT_SEEK_TO: {
+ long position = (long) msg.obj;
+ if (DEBUG) Log.d(TAG, "MSG_TIMESHIFT_SEEK_TO (position=" + position + ")");
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftSeekTo(position);
+ return true;
+ }
+ case MSG_TIMESHIFT_SET_PLAYBACKPARAMS: {
+ if (mPlayer == null) {
+ return true;
+ }
+ doTimeShiftSetPlaybackParams((PlaybackParams) msg.obj);
+ return true;
+ }
+ case MSG_AUDIO_CAPABILITIES_CHANGED: {
+ AudioCapabilities capabilities = (AudioCapabilities) msg.obj;
+ if (DEBUG) {
+ Log.d(TAG, "MSG_AUDIO_CAPABILITIES_CHANGED " + capabilities);
+ }
+ if (capabilities == null) {
+ return true;
+ }
+ if (!capabilities.equals(mAudioCapabilities)) {
+ // HDMI supported encodings are changed. restart player.
+ mAudioCapabilities = capabilities;
+ resetPlayback();
+ }
+ return true;
+ }
+ case MSG_SET_STREAM_VOLUME: {
+ if (mPlayer != null && mPlayer.isPlaying()) {
+ mPlayer.setVolume(mVolume);
+ }
+ return true;
+ }
+ case MSG_BUFFER_START_TIME_CHANGED: {
+ if (mPlayer == null) {
+ return true;
+ }
+ mBufferStartTimeMs = (long) msg.obj;
+ if (!hasEnoughBackwardBuffer()
+ && (!mPlayer.isPlaying() || mPlaybackParams.getSpeed() < 1.0f)) {
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setAudioTrack(true);
+ mPlaybackParams.setSpeed(1.0f);
+ }
+ return true;
+ }
+ case MSG_BUFFER_STATE_CHANGED: {
+ boolean available = (boolean) msg.obj;
+ mSession.notifyTimeShiftStatusChanged(available
+ ? TvInputManager.TIME_SHIFT_STATUS_AVAILABLE
+ : TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ return true;
+ }
+ case MSG_CHECK_SIGNAL: {
+ if (mChannel == null || mPlayer == null) {
+ return true;
+ }
+ TsDataSource source = mPlayer.getDataSource();
+ long limitInBytes = source != null ? source.getBufferedPosition() : 0L;
+ long positionInBytes = source != null ? source.getLastReadPosition() : 0L;
+ if (TunerDebug.ENABLED) {
+ TunerDebug.calculateDiff();
+ mSession.sendUiMessage(TunerSession.MSG_UI_SET_STATUS_TEXT,
+ Html.fromHtml(
+ StatusTextUtils.getStatusWarningInHTML(
+ (limitInBytes - mLastLimitInBytes)
+ / TS_PACKET_SIZE,
+ TunerDebug.getVideoFrameDrop(),
+ TunerDebug.getBytesInQueue(),
+ TunerDebug.getAudioPositionUs(),
+ TunerDebug.getAudioPositionUsRate(),
+ TunerDebug.getAudioPtsUs(),
+ TunerDebug.getAudioPtsUsRate(),
+ TunerDebug.getVideoPtsUs(),
+ TunerDebug.getVideoPtsUsRate()
+ )));
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("MSG_CHECK_SIGNAL position: %d, limit: %d",
+ positionInBytes, limitInBytes));
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE);
+ long currentTime = SystemClock.elapsedRealtime();
+ boolean noBufferRead = positionInBytes == mLastPositionInBytes
+ && limitInBytes == mLastLimitInBytes;
+ boolean isBufferingTooLong = mBufferingStartTimeMs != INVALID_TIME
+ && currentTime - mBufferingStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isPreparingTooLong = mPreparingStartTimeMs != INVALID_TIME
+ && currentTime - mPreparingStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ boolean isWeakSignal = source != null
+ && mChannel.getType() == Channel.TYPE_TUNER
+ && (noBufferRead || isBufferingTooLong || isPreparingTooLong);
+ if (isWeakSignal && !mReportedWeakSignal) {
+ if (!mHandler.hasMessages(MSG_RETRY_PLAYBACK)) {
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(
+ MSG_RETRY_PLAYBACK, mPlayer), PLAYBACK_RETRY_DELAY_MS);
+ }
+ if (mPlayer != null) {
+ mPlayer.setAudioTrack(false);
+ }
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ } else if (!isWeakSignal && mReportedWeakSignal) {
+ boolean isPlaybackStable = mReadyStartTimeMs != INVALID_TIME
+ && currentTime - mReadyStartTimeMs
+ > PLAYBACK_STATE_CHANGED_WAITING_THRESHOLD_MS;
+ if (!isPlaybackStable) {
+ // Wait until playback becomes stable.
+ } else if (mReportedDrawnToSurface) {
+ mHandler.removeMessages(MSG_RETRY_PLAYBACK);
+ notifyVideoAvailable();
+ mPlayer.setAudioTrack(true);
+ }
+ }
+ mLastLimitInBytes = limitInBytes;
+ mLastPositionInBytes = positionInBytes;
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_PERIOD_MS);
+ return true;
+ }
+ case MSG_SET_SURFACE: {
+ if (mPlayer != null) {
+ mPlayer.setSurface(mSurface);
+ } else {
+ // TODO: Since surface is dynamically set, we can remove the dependency of
+ // playback start on mSurface nullity.
+ resetPlayback();
+ }
+ return true;
+ }
+ case MSG_NOTIFY_AUDIO_TRACK_UPDATED: {
+ notifyAudioTracksUpdated();
+ return true;
+ }
+ default: {
+ Log.w(TAG, "Unhandled message code: " + msg.what);
+ return false;
+ }
+ }
+ }
+
+ // Private methods
+ private void doSelectTrack(int type, String trackId) {
+ int numTrackId = trackId != null
+ ? Integer.parseInt(trackId.substring(TRACK_PREFIX_SIZE)) : -1;
+ if (type == TvTrackInfo.TYPE_AUDIO) {
+ if (trackId == null) {
+ return;
+ }
+ AtscAudioTrack audioTrack = mAudioTrackMap.get(numTrackId);
+ if (audioTrack == null) {
+ return;
+ }
+ int oldAudioPid = mChannel.getAudioPid();
+ mChannel.selectAudioTrack(audioTrack.index);
+ int newAudioPid = mChannel.getAudioPid();
+ if (oldAudioPid != newAudioPid) {
+ mPlayer.setSelectedTrack(MpegTsPlayer.TRACK_TYPE_AUDIO, audioTrack.index);
+ }
+ mSession.notifyTrackSelected(type, trackId);
+ } else if (type == TvTrackInfo.TYPE_SUBTITLE) {
+ if (trackId == null) {
+ mSession.notifyTrackSelected(type, null);
+ mCaptionTrack = null;
+ stopCaptionTrack();
+ return;
+ }
+ for (TvTrackInfo track : mTvTracks) {
+ if (track.getId().equals(trackId)) {
+ // The service number of the caption service is used for track id of a
+ // subtitle track. Passes the following track id on to TsParser.
+ mSession.notifyTrackSelected(type, trackId);
+ mCaptionTrack = mCaptionTrackMap.get(numTrackId);
+ startCaptionTrack();
+ return;
+ }
+ }
+ }
+ }
+
+ private MpegTsPlayer createPlayer(AudioCapabilities capabilities, BufferManager bufferManager) {
+ if (capabilities == null) {
+ Log.w(TAG, "No Audio Capabilities");
+ }
+
+ MpegTsPlayer player = new MpegTsPlayer(
+ new MpegTsRendererBuilder(mContext, bufferManager, this),
+ mHandler, mSourceManager, capabilities, this);
+ Log.i(TAG, "Passthrough AC3 renderer");
+ if (DEBUG) Log.d(TAG, "ExoPlayer created");
+ return player;
+ }
+
+ private void startCaptionTrack() {
+ if (mCaptionEnabled && mCaptionTrack != null) {
+ mSession.sendUiMessage(
+ TunerSession.MSG_UI_START_CAPTION_TRACK, mCaptionTrack);
+ if (mPlayer != null) {
+ mPlayer.setCaptionServiceNumber(mCaptionTrack.serviceNumber);
+ }
+ }
+ }
+
+ private void stopCaptionTrack() {
+ if (mPlayer != null) {
+ mPlayer.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER);
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_STOP_CAPTION_TRACK);
+ }
+
+ private void resetTvTracks() {
+ mTvTracks.clear();
+ mAudioTrackMap.clear();
+ mCaptionTrackMap.clear();
+ mSession.sendUiMessage(TunerSession.MSG_UI_RESET_CAPTION_TRACK);
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateTvTracks(TvTracksInterface tvTracksInterface, boolean fromPmt) {
+ if (DEBUG) {
+ Log.d(TAG, "UpdateTvTracks " + tvTracksInterface);
+ }
+ List<AtscAudioTrack> audioTracks = tvTracksInterface.getAudioTracks();
+ List<AtscCaptionTrack> captionTracks = tvTracksInterface.getCaptionTracks();
+ // According to ATSC A/69 chapter 6.9, both PMT and EIT should have descriptors for audio
+ // tracks, but in real world, we see some bogus audio track info in EIT, so, we trust audio
+ // track info in PMT more and use info in EIT only when we have nothing.
+ if (audioTracks != null && !audioTracks.isEmpty()
+ && (mChannel.getAudioTracks() == null || fromPmt)) {
+ updateAudioTracks(audioTracks);
+ }
+ if (captionTracks == null || captionTracks.isEmpty()) {
+ if (tvTracksInterface.hasCaptionTrack()) {
+ updateCaptionTracks(captionTracks);
+ }
+ } else {
+ updateCaptionTracks(captionTracks);
+ }
+ }
+
+ private void removeTvTracks(int trackType) {
+ Iterator<TvTrackInfo> iterator = mTvTracks.iterator();
+ while (iterator.hasNext()) {
+ TvTrackInfo tvTrackInfo = iterator.next();
+ if (tvTrackInfo.getType() == trackType) {
+ iterator.remove();
+ }
+ }
+ }
+
+ private void updateVideoTrack(int width, int height) {
+ removeTvTracks(TvTrackInfo.TYPE_VIDEO);
+ mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID)
+ .setVideoWidth(width).setVideoHeight(height).build());
+ mSession.notifyTracksChanged(mTvTracks);
+ mSession.notifyTrackSelected(TvTrackInfo.TYPE_VIDEO, VIDEO_TRACK_ID);
+ }
+
+ private void updateAudioTracks(List<AtscAudioTrack> audioTracks) {
+ if (DEBUG) {
+ Log.d(TAG, "Update AudioTracks " + audioTracks);
+ }
+ mAudioTrackMap.clear();
+ if (audioTracks != null) {
+ int index = 0;
+ for (AtscAudioTrack audioTrack : audioTracks) {
+ audioTrack.index = index;
+ mAudioTrackMap.put(index, audioTrack);
+ ++index;
+ }
+ }
+ mHandler.sendEmptyMessage(MSG_NOTIFY_AUDIO_TRACK_UPDATED);
+ }
+
+ private void notifyAudioTracksUpdated() {
+ if (mPlayer == null) {
+ // Audio tracks will be updated later once player initialization is done.
+ return;
+ }
+ int audioTrackCount = mPlayer.getTrackCount(MpegTsPlayer.TRACK_TYPE_AUDIO);
+ removeTvTracks(TvTrackInfo.TYPE_AUDIO);
+ for (int i = 0; i < audioTrackCount; i++) {
+ AtscAudioTrack audioTrack = mAudioTrackMap.get(i);
+ if (audioTrack == null) {
+ continue;
+ }
+ String language = audioTrack.language;
+ if (language == null && mChannel.getAudioTracks() != null
+ && mChannel.getAudioTracks().size() == mAudioTrackMap.size()) {
+ // If a language is not present, use a language field in PMT section parsed.
+ language = mChannel.getAudioTracks().get(i).language;
+ }
+ // Save the index to the audio track.
+ // Later, when an audio track is selected, both the audio pid and its audio stream
+ // type reside in the selected index position of the tuner channel's audio data.
+ audioTrack.index = i;
+ TvTrackInfo.Builder builder = new TvTrackInfo.Builder(
+ TvTrackInfo.TYPE_AUDIO, AUDIO_TRACK_PREFIX + i);
+ builder.setLanguage(language);
+ builder.setAudioChannelCount(audioTrack.channelCount);
+ builder.setAudioSampleRate(audioTrack.sampleRate);
+ TvTrackInfo track = builder.build();
+ mTvTracks.add(track);
+ }
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateCaptionTracks(List<AtscCaptionTrack> captionTracks) {
+ if (DEBUG) {
+ Log.d(TAG, "Update CaptionTrack " + captionTracks);
+ }
+ removeTvTracks(TvTrackInfo.TYPE_SUBTITLE);
+ mCaptionTrackMap.clear();
+ if (captionTracks != null) {
+ for (AtscCaptionTrack captionTrack : captionTracks) {
+ if (mCaptionTrackMap.indexOfKey(captionTrack.serviceNumber) >= 0) {
+ continue;
+ }
+ String language = captionTrack.language;
+
+ // The service number of the caption service is used for track id of a subtitle.
+ // Later, when a subtitle is chosen, track id will be passed on to TsParser.
+ TvTrackInfo.Builder builder =
+ new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE,
+ SUBTITLE_TRACK_PREFIX + captionTrack.serviceNumber);
+ builder.setLanguage(language);
+ mTvTracks.add(builder.build());
+ mCaptionTrackMap.put(captionTrack.serviceNumber, captionTrack);
+ }
+ }
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+
+ private void updateChannelInfo(TunerChannel channel) {
+ if (DEBUG) {
+ Log.d(TAG, String.format("Channel Info (old) videoPid: %d audioPid: %d " +
+ "audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(),
+ mChannel.getAudioPids().size()));
+ }
+
+ // The list of the audio tracks resided in a channel is often changed depending on a
+ // program being on the air. So, we should update the streaming PIDs and types of the
+ // tuned channel according to the newly received channel data.
+ int oldVideoPid = mChannel.getVideoPid();
+ int oldAudioPid = mChannel.getAudioPid();
+ List<Integer> audioPids = channel.getAudioPids();
+ List<Integer> audioStreamTypes = channel.getAudioStreamTypes();
+ int size = audioPids.size();
+ mChannel.setVideoPid(channel.getVideoPid());
+ mChannel.setAudioPids(audioPids);
+ mChannel.setAudioStreamTypes(audioStreamTypes);
+ updateTvTracks(channel, true);
+ int index = audioPids.isEmpty() ? -1 : 0;
+ for (int i = 0; i < size; ++i) {
+ if (audioPids.get(i) == oldAudioPid) {
+ index = i;
+ break;
+ }
+ }
+ mChannel.selectAudioTrack(index);
+ mSession.notifyTrackSelected(TvTrackInfo.TYPE_AUDIO,
+ index == -1 ? null : AUDIO_TRACK_PREFIX + index);
+
+ // Reset playback if there is a change in the listening streaming PIDs.
+ if (oldVideoPid != mChannel.getVideoPid()
+ || oldAudioPid != mChannel.getAudioPid()) {
+ // TODO: Implement a switching between tracks more smoothly.
+ resetPlayback();
+ }
+ if (DEBUG) {
+ Log.d(TAG, String.format("Channel Info (new) videoPid: %d audioPid: %d " +
+ " audioSize: %d", mChannel.getVideoPid(), mChannel.getAudioPid(),
+ mChannel.getAudioPids().size()));
+ }
+ }
+
+ private void stopPlayback() {
+ mChannelDataManager.removeAllCallbacksAndMessages();
+ if (mPlayer != null) {
+ mPlayer.setPlayWhenReady(false);
+ mPlayer.release();
+ mPlayer = null;
+ mPlayerState = ExoPlayer.STATE_IDLE;
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayerStarted = false;
+ mReportedDrawnToSurface = false;
+ mPreparingStartTimeMs = INVALID_TIME;
+ mBufferingStartTimeMs = INVALID_TIME;
+ mReadyStartTimeMs = INVALID_TIME;
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_AUDIO_UNPLAYABLE);
+ mSession.notifyTimeShiftStatusChanged(TvInputManager.TIME_SHIFT_STATUS_UNAVAILABLE);
+ }
+ }
+
+ private void startPlayback(Object playerObj) {
+ // TODO: provide hasAudio()/hasVideo() for play recordings.
+ if (mPlayer == null || mPlayer != playerObj) {
+ return;
+ }
+ if (mChannel != null && !mChannel.hasAudio()) {
+ if (DEBUG) Log.d(TAG, "Channel " + mChannel + " does not have audio.");
+ // Playbacks with video-only stream have not been tested yet.
+ // No video-only channel has been found.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN);
+ return;
+ }
+ if (mChannel != null && ((mChannel.hasAudio() && !mPlayer.hasAudio())
+ || (mChannel.hasVideo() && !mPlayer.hasVideo()))) {
+ // Tracks haven't been detected in the extractor. Try again.
+ sendMessage(MSG_RETRY_PLAYBACK, mPlayer);
+ return;
+ }
+ // Since mSurface is volatile, we define a local variable surface to keep the same value
+ // inside this method.
+ Surface surface = mSurface;
+ if (surface != null && !mPlayerStarted) {
+ mPlayer.setSurface(surface);
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setVolume(mVolume);
+ if (mChannel != null && !mChannel.hasVideo() && mChannel.hasAudio()) {
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_AUDIO_ONLY);
+ } else if (!mReportedWeakSignal) {
+ // Doesn't show buffering during weak signal.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING);
+ }
+ mSession.sendUiMessage(TunerSession.MSG_UI_HIDE_MESSAGE);
+ mPlayerStarted = true;
+ }
+ }
+
+ private void preparePlayback() {
+ SoftPreconditions.checkState(mPlayer == null);
+ if (mChannel == null && mRecordingId == null) {
+ return;
+ }
+ mSourceManager.setKeepTuneStatus(true);
+ BufferManager bufferManager = mChannel != null ? mBufferManager : new BufferManager(
+ new DvrStorageManager(new File(getRecordingPath()), false));
+ MpegTsPlayer player = createPlayer(mAudioCapabilities, bufferManager);
+ player.setCaptionServiceNumber(Cea708Data.EMPTY_SERVICE_NUMBER);
+ player.setVideoEventListener(this);
+ player.setCaptionServiceNumber(mCaptionTrack != null ?
+ mCaptionTrack.serviceNumber : Cea708Data.EMPTY_SERVICE_NUMBER);
+ if (!player.prepare(mContext, mChannel, this)) {
+ mSourceManager.setKeepTuneStatus(false);
+ player.release();
+ if (!mHandler.hasMessages(MSG_TUNE)) {
+ // When prepare failed, there may be some errors related to hardware. In that
+ // case, retry playback immediately may not help.
+ notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RETRY_PLAYBACK, mPlayer),
+ PLAYBACK_RETRY_DELAY_MS);
+ }
+ } else {
+ mPlayer = player;
+ mPlayerStarted = false;
+ mHandler.removeMessages(MSG_CHECK_SIGNAL);
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS);
+ }
+ }
+
+ private void resetPlayback() {
+ long timestamp, oldTimestamp;
+ timestamp = SystemClock.elapsedRealtime();
+ stopPlayback();
+ stopCaptionTrack();
+ if (ENABLE_PROFILER) {
+ oldTimestamp = timestamp;
+ timestamp = SystemClock.elapsedRealtime();
+ Log.i(TAG, "[Profiler] stopPlayback() takes " + (timestamp - oldTimestamp) + " ms");
+ }
+ if (mChannelBlocked || mSurface == null) {
+ return;
+ }
+ preparePlayback();
+ }
+
+ private void prepareTune(TunerChannel channel, String recording) {
+ mChannelBlocked = false;
+ mUnblockedContentRating = null;
+ mRetryCount = 0;
+ mChannel = channel;
+ mRecordingId = recording;
+ mRecordingDuration = recording != null ? getDurationForRecording(recording) : null;
+ mProgram = null;
+ mPrograms = null;
+ mBufferStartTimeMs = mRecordStartTimeMs =
+ (mRecordingId != null) ? 0 : System.currentTimeMillis();
+ mLastPositionMs = 0;
+ mCaptionTrack = null;
+ mHandler.sendEmptyMessage(MSG_PARENTAL_CONTROLS);
+ }
+
+ private void doReschedulePrograms() {
+ long currentPositionMs = getCurrentPosition();
+ long forwardDifference = Math.abs(currentPositionMs - mLastPositionMs
+ - RESCHEDULE_PROGRAMS_INTERVAL_MS);
+ mLastPositionMs = currentPositionMs;
+
+ // A gap is measured as the time difference between previous and next current position
+ // periodically. If the gap has a significant difference with an interval of a period,
+ // this means that there is a change of playback status and the programs of the current
+ // channel should be rescheduled to new playback timeline.
+ if (forwardDifference > RESCHEDULE_PROGRAMS_TOLERANCE_MS) {
+ if (DEBUG) {
+ Log.d(TAG, "reschedule programs size:"
+ + (mPrograms != null ? mPrograms.size() : 0) + " current program: "
+ + getCurrentProgram());
+ }
+ mHandler.obtainMessage(MSG_SCHEDULE_OF_PROGRAMS, new Pair<>(mChannel, mPrograms))
+ .sendToTarget();
+ }
+ mHandler.removeMessages(MSG_RESCHEDULE_PROGRAMS);
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INTERVAL_MS);
+ }
+
+ private int getTrickPlaySeekIntervalMs() {
+ return Math.max(EXPECTED_KEY_FRAME_INTERVAL_MS / (int) Math.abs(mPlaybackParams.getSpeed()),
+ MIN_TRICKPLAY_SEEK_INTERVAL_MS);
+ }
+
+ private void doTrickplayBySeek(int seekPositionMs) {
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ if (mPlaybackParams.getSpeed() == 1.0f || !mPlayer.isPrepared()) {
+ return;
+ }
+ if (seekPositionMs < mBufferStartTimeMs - mRecordStartTimeMs) {
+ if (mPlaybackParams.getSpeed() > 1.0f) {
+ // If fast forwarding, the seekPositionMs can be out of the buffered range
+ // because of chuck evictions.
+ seekPositionMs = (int) (mBufferStartTimeMs - mRecordStartTimeMs);
+ } else {
+ mPlayer.seekTo(mBufferStartTimeMs - mRecordStartTimeMs);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setAudioTrack(true);
+ return;
+ }
+ } else if (seekPositionMs > System.currentTimeMillis() - mRecordStartTimeMs) {
+ mPlayer.seekTo(System.currentTimeMillis() - mRecordStartTimeMs);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setAudioTrack(true);
+ return;
+ }
+
+ long delayForNextSeek = getTrickPlaySeekIntervalMs();
+ if (!mPlayer.isBuffering()) {
+ mPlayer.seekTo(seekPositionMs);
+ } else {
+ delayForNextSeek = MIN_TRICKPLAY_SEEK_INTERVAL_MS;
+ }
+ seekPositionMs += mPlaybackParams.getSpeed() * delayForNextSeek;
+ mHandler.sendMessageDelayed(mHandler.obtainMessage(
+ MSG_TRICKPLAY_BY_SEEK, seekPositionMs, 0), delayForNextSeek);
+ }
+
+ private void doTimeShiftPause() {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ if (!hasEnoughBackwardBuffer()) {
+ return;
+ }
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setPlayWhenReady(false);
+ mPlayer.setAudioTrack(true);
+ }
+
+ private void doTimeShiftResume() {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlaybackParams.setSpeed(1.0f);
+ mPlayer.setPlayWhenReady(true);
+ mPlayer.setAudioTrack(true);
+ }
+
+ private void doTimeShiftSeekTo(long timeMs) {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlayer.seekTo((int) (timeMs - mRecordStartTimeMs));
+ }
+
+ private void doTimeShiftSetPlaybackParams(PlaybackParams params) {
+ if (!hasEnoughBackwardBuffer() && params.getSpeed() < 1.0f) {
+ return;
+ }
+ mPlaybackParams = params;
+ float speed = mPlaybackParams.getSpeed();
+ if (speed == 1.0f) {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ doTimeShiftResume();
+ } else if (mPlayer.supportSmoothTrickPlay(speed)) {
+ mHandler.removeMessages(MSG_TRICKPLAY_BY_SEEK);
+ mPlayer.setAudioTrack(false);
+ mPlayer.startSmoothTrickplay(mPlaybackParams);
+ mHandler.sendEmptyMessageDelayed(MSG_SMOOTH_TRICKPLAY_MONITOR,
+ TRICKPLAY_MONITOR_INTERVAL_MS);
+ } else {
+ mHandler.removeMessages(MSG_SMOOTH_TRICKPLAY_MONITOR);
+ if (!mHandler.hasMessages(MSG_TRICKPLAY_BY_SEEK)) {
+ mPlayer.setAudioTrack(false);
+ mPlayer.setPlayWhenReady(false);
+ // Initiate trickplay
+ mHandler.sendMessage(mHandler.obtainMessage(MSG_TRICKPLAY_BY_SEEK,
+ (int) (mPlayer.getCurrentPosition()
+ + speed * getTrickPlaySeekIntervalMs()), 0));
+ }
+ }
+ }
+
+ private EitItem getCurrentProgram() {
+ if (mPrograms == null || mPrograms.isEmpty()) {
+ return null;
+ }
+ if (mChannel.getType() == Channel.TYPE_FILE) {
+ // For the playback from the local file, we use the first one from the given program.
+ EitItem first = mPrograms.get(0);
+ if (first != null && (mProgram == null
+ || first.getStartTimeUtcMillis() < mProgram.getStartTimeUtcMillis())) {
+ return first;
+ }
+ return null;
+ }
+ long currentTimeMs = getCurrentPosition();
+ for (EitItem item : mPrograms) {
+ if (item.getStartTimeUtcMillis() <= currentTimeMs
+ && item.getEndTimeUtcMillis() >= currentTimeMs) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ private void doParentalControls() {
+ boolean isParentalControlsEnabled = mTvInputManager.isParentalControlsEnabled();
+ if (isParentalControlsEnabled) {
+ TvContentRating blockContentRating = getContentRatingOfCurrentProgramBlocked();
+ if (DEBUG) {
+ if (blockContentRating != null) {
+ Log.d(TAG, "Check parental controls: blocked by content rating - "
+ + blockContentRating);
+ } else {
+ Log.d(TAG, "Check parental controls: available");
+ }
+ }
+ updateChannelBlockStatus(blockContentRating != null, blockContentRating);
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Check parental controls: available");
+ }
+ updateChannelBlockStatus(false, null);
+ }
+ }
+
+ private void doDiscoverCaptionServiceNumber(int serviceNumber) {
+ int index = mCaptionTrackMap.indexOfKey(serviceNumber);
+ if (index < 0) {
+ AtscCaptionTrack captionTrack = new AtscCaptionTrack();
+ captionTrack.serviceNumber = serviceNumber;
+ captionTrack.wideAspectRatio = false;
+ captionTrack.easyReader = false;
+ mCaptionTrackMap.put(serviceNumber, captionTrack);
+ mTvTracks.add(new TvTrackInfo.Builder(TvTrackInfo.TYPE_SUBTITLE,
+ SUBTITLE_TRACK_PREFIX + serviceNumber).build());
+ mSession.notifyTracksChanged(mTvTracks);
+ }
+ }
+
+ private TvContentRating getContentRatingOfCurrentProgramBlocked() {
+ EitItem currentProgram = getCurrentProgram();
+ if (currentProgram == null) {
+ return null;
+ }
+ TvContentRating[] ratings = mTvContentRatingCache
+ .getRatings(currentProgram.getContentRating());
+ if (ratings == null) {
+ return null;
+ }
+ for (TvContentRating rating : ratings) {
+ if (!Objects.equals(mUnblockedContentRating, rating) && mTvInputManager
+ .isRatingBlocked(rating)) {
+ return rating;
+ }
+ }
+ return null;
+ }
+
+ private void updateChannelBlockStatus(boolean channelBlocked,
+ TvContentRating contentRating) {
+ if (mChannelBlocked == channelBlocked) {
+ return;
+ }
+ mChannelBlocked = channelBlocked;
+ if (mChannelBlocked) {
+ mHandler.removeCallbacksAndMessages(null);
+ stopPlayback();
+ resetTvTracks();
+ if (contentRating != null) {
+ mSession.notifyContentBlocked(contentRating);
+ }
+ mHandler.sendEmptyMessageDelayed(MSG_PARENTAL_CONTROLS, PARENTAL_CONTROLS_INTERVAL_MS);
+ } else {
+ mHandler.removeCallbacksAndMessages(null);
+ resetPlayback();
+ mSession.notifyContentAllowed();
+ mHandler.sendEmptyMessageDelayed(MSG_RESCHEDULE_PROGRAMS,
+ RESCHEDULE_PROGRAMS_INITIAL_DELAY_MS);
+ mHandler.removeMessages(MSG_CHECK_SIGNAL);
+ mHandler.sendEmptyMessageDelayed(MSG_CHECK_SIGNAL, CHECK_NO_SIGNAL_INITIAL_DELAY_MS);
+ }
+ }
+
+ private boolean hasEnoughBackwardBuffer() {
+ return mPlayer.getCurrentPosition() + BUFFER_UNDERFLOW_BUFFER_MS
+ >= mBufferStartTimeMs - mRecordStartTimeMs;
+ }
+
+ private void notifyVideoUnavailable(final int reason) {
+ mReportedWeakSignal = (reason == TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL);
+ if (mSession != null) {
+ mSession.notifyVideoUnavailable(reason);
+ }
+ }
+
+ private void notifyVideoAvailable() {
+ mReportedWeakSignal = false;
+ if (mSession != null) {
+ mSession.notifyVideoAvailable();
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
new file mode 100644
index 00000000..e734b779
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerStorageCleanUpService.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.os.AsyncTask;
+
+import com.android.tv.TvApplication;
+import com.android.tv.dvr.DvrStorageStatusManager;
+import com.android.tv.util.Utils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Creates {@link JobService} to clean up recorded program files which are not referenced
+ * from database.
+ */
+public class TunerStorageCleanUpService extends JobService {
+ private CleanUpStorageTask mTask;
+
+ @Override
+ public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, false);
+ super.onCreate();
+ mTask = new CleanUpStorageTask(this, this);
+ }
+
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ mTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, params);
+ return true;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+
+ /**
+ * Cleans up recorded program files which are not referenced from database.
+ * Cleaning up will be done periodically.
+ */
+ public static class CleanUpStorageTask extends AsyncTask<JobParameters, Void, JobParameters[]> {
+ private final static String[] mProjection = {
+ TvContract.RecordedPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.RecordedPrograms.COLUMN_RECORDING_DATA_URI
+ };
+ private final static long ELAPSED_MILLIS_TO_DELETE = TimeUnit.DAYS.toMillis(1);
+
+ private final Context mContext;
+ private final DvrStorageStatusManager mDvrStorageStatusManager;
+ private final JobService mJobService;
+ private final ContentResolver mContentResolver;
+
+ /**
+ * Creates a recurring storage cleaning task.
+ *
+ * @param context {@link Context}
+ * @param jobService {@link JobService}
+ */
+ public CleanUpStorageTask(Context context, JobService jobService) {
+ mContext = context;
+ mDvrStorageStatusManager =
+ TvApplication.getSingletons(mContext).getDvrStorageStatusManager();
+ mJobService = jobService;
+ mContentResolver = mContext.getContentResolver();
+ }
+
+ private Set<String> getRecordedProgramsDirs() {
+ try (Cursor c = mContentResolver.query(
+ TvContract.RecordedPrograms.CONTENT_URI, mProjection, null, null, null)) {
+ if (c == null) {
+ return null;
+ }
+ Set<String> recordedProgramDirs = new HashSet<>();
+ while (c.moveToNext()) {
+ String packageName = c.getString(0);
+ String dataUriString = c.getString(1);
+ if (dataUriString == null) {
+ continue;
+ }
+ Uri dataUri = Uri.parse(dataUriString);
+ if (!Utils.isInBundledPackageSet(packageName)
+ || dataUri == null || dataUri.getPath() == null
+ || !ContentResolver.SCHEME_FILE.equals(dataUri.getScheme())) {
+ continue;
+ }
+ File recordedProgramDir = new File(dataUri.getPath());
+ try {
+ recordedProgramDirs.add(recordedProgramDir.getCanonicalPath());
+ } catch (IOException | SecurityException e) {
+ }
+ }
+ return recordedProgramDirs;
+ }
+ }
+
+ @Override
+ protected JobParameters[] doInBackground(JobParameters... params) {
+ if (mDvrStorageStatusManager.getDvrStorageStatus()
+ == DvrStorageStatusManager.STORAGE_STATUS_MISSING) {
+ return params;
+ }
+ File dvrRecordingDir = mDvrStorageStatusManager.getRecordingRootDataDirectory();
+ if (dvrRecordingDir == null || !dvrRecordingDir.isDirectory()) {
+ return params;
+ }
+ Set<String> recordedProgramDirs = getRecordedProgramsDirs();
+ if (recordedProgramDirs == null) {
+ return params;
+ }
+ File[] files = dvrRecordingDir.listFiles();
+ if (files == null || files.length == 0) {
+ return params;
+ }
+ for (File recordingDir : files) {
+ try {
+ if (!recordedProgramDirs.contains(recordingDir.getCanonicalPath())) {
+ long lastModified = recordingDir.lastModified();
+ long now = System.currentTimeMillis();
+ if (lastModified != 0
+ && lastModified < now - ELAPSED_MILLIS_TO_DELETE) {
+ // To prevent current recordings from being deleted,
+ // deletes recordings which was not modified for long enough time.
+ Utils.deleteDirOrFile(recordingDir);
+ }
+ }
+ } catch (IOException | SecurityException e) {
+ // would not happen
+ }
+ }
+ return params;
+ }
+
+ @Override
+ protected void onPostExecute(JobParameters[] params) {
+ for (JobParameters param : params) {
+ mJobService.jobFinished(param, false);
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/tuner/tvinput/TunerTvInputService.java b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java
new file mode 100644
index 00000000..684ebdbd
--- /dev/null
+++ b/src/com/android/tv/tuner/tvinput/TunerTvInputService.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.tvinput;
+
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.tv.TvContract;
+import android.media.tv.TvInputService;
+import android.util.Log;
+
+import com.google.android.exoplayer.audio.AudioCapabilities;
+import com.google.android.exoplayer.audio.AudioCapabilitiesReceiver;
+import com.android.tv.TvApplication;
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.tuner.exoplayer.buffer.BufferManager;
+import com.android.tv.tuner.exoplayer.buffer.TrickplayStorageManager;
+import com.android.tv.tuner.util.SystemPropertiesProxy;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * {@link TunerTvInputService} serves TV channels coming from a tuner device.
+ */
+public class TunerTvInputService extends TvInputService
+ implements AudioCapabilitiesReceiver.Listener{
+ private static final String TAG = "TunerTvInputService";
+ private static final boolean DEBUG = false;
+
+ private static final String MAX_BUFFER_SIZE_KEY = "tv.tuner.buffersize_mbytes";
+ private static final int MAX_BUFFER_SIZE_DEF = 2 * 1024; // 2GB
+ private static final int MIN_BUFFER_SIZE_DEF = 256; // 256MB
+ private static final int DVR_STORAGE_CLEANUP_JOB_ID = 100;
+
+ // WeakContainer for {@link TvInputSessionImpl}
+ private final Set<TunerSession> mTunerSessions = Collections.newSetFromMap(new WeakHashMap<>());
+ private ChannelDataManager mChannelDataManager;
+ private AudioCapabilitiesReceiver mAudioCapabilitiesReceiver;
+ private AudioCapabilities mAudioCapabilities;
+ private BufferManager mBufferManager;
+
+ @Override
+ public void onCreate() {
+ TvApplication.setCurrentRunningProcess(this, false);
+ super.onCreate();
+ if (DEBUG) Log.d(TAG, "onCreate");
+ mChannelDataManager = new ChannelDataManager(getApplicationContext());
+ mAudioCapabilitiesReceiver = new AudioCapabilitiesReceiver(getApplicationContext(), this);
+ mAudioCapabilitiesReceiver.register();
+ mBufferManager = createBufferManager();
+ if (CommonFeatures.DVR.isEnabled(this)) {
+ JobScheduler jobScheduler =
+ (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ JobInfo pendingJob = jobScheduler.getPendingJob(DVR_STORAGE_CLEANUP_JOB_ID);
+ if (pendingJob != null) {
+ // storage cleaning job is already scheduled.
+ } else {
+ JobInfo job = new JobInfo.Builder(DVR_STORAGE_CLEANUP_JOB_ID,
+ new ComponentName(this, TunerStorageCleanUpService.class))
+ .setPersisted(true).setPeriodic(TimeUnit.DAYS.toMillis(1)).build();
+ jobScheduler.schedule(job);
+ }
+ }
+ if (mBufferManager == null) {
+ Log.i(TAG, "Trickplay is disabled");
+ } else {
+ Log.i(TAG, "Trickplay is enabled");
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (DEBUG) Log.d(TAG, "onDestroy");
+ super.onDestroy();
+ mChannelDataManager.release();
+ mAudioCapabilitiesReceiver.unregister();
+ if (mBufferManager != null) {
+ mBufferManager.close();
+ }
+ }
+
+ @Override
+ public RecordingSession onCreateRecordingSession(String inputId) {
+ return new TunerRecordingSession(this, inputId, mChannelDataManager);
+ }
+
+ @Override
+ public Session onCreateSession(String inputId) {
+ if (DEBUG) Log.d(TAG, "onCreateSession");
+ try {
+ final TunerSession session = new TunerSession(
+ this, mChannelDataManager, mBufferManager);
+ mTunerSessions.add(session);
+ session.setAudioCapabilities(mAudioCapabilities);
+ session.setOverlayViewEnabled(true);
+ return session;
+ } catch (RuntimeException e) {
+ // There are no available DVB devices.
+ Log.e(TAG, "Creating a session for " + inputId + " failed.", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void onAudioCapabilitiesChanged(AudioCapabilities audioCapabilities) {
+ mAudioCapabilities = audioCapabilities;
+ for (TunerSession session : mTunerSessions) {
+ if (!session.isReleased()) {
+ session.setAudioCapabilities(audioCapabilities);
+ }
+ }
+ }
+
+ private BufferManager createBufferManager() {
+ int maxBufferSizeMb =
+ SystemPropertiesProxy.getInt(MAX_BUFFER_SIZE_KEY, MAX_BUFFER_SIZE_DEF);
+ if (maxBufferSizeMb >= MIN_BUFFER_SIZE_DEF) {
+ return new BufferManager(
+ new TrickplayStorageManager(getApplicationContext(), getCacheDir(),
+ 1024L * 1024 * maxBufferSizeMb));
+ }
+ return null;
+ }
+
+ public static String getInputId(Context context) {
+ return TvContract.buildInputId(new ComponentName(context, TunerTvInputService.class));
+ }
+}
diff --git a/src/com/android/tv/tuner/util/ByteArrayBuffer.java b/src/com/android/tv/tuner/util/ByteArrayBuffer.java
new file mode 100644
index 00000000..da887e7d
--- /dev/null
+++ b/src/com/android/tv/tuner/util/ByteArrayBuffer.java
@@ -0,0 +1,149 @@
+/*
+ * $HeadURL: http://svn.apache.org/repos/asf/httpcomponents/httpcore/trunk/module-main/src/main/java/org/apache/http/util/ByteArrayBuffer.java $
+ * $Revision: 496070 $
+ * $Date: 2007-01-14 04:18:34 -0800 (Sun, 14 Jan 2007) $
+ *
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+
+package com.android.tv.tuner.util;
+
+/**
+ * An expandable byte buffer built on byte array.
+ */
+public final class ByteArrayBuffer {
+
+ private byte[] buffer;
+ private int len;
+
+ public ByteArrayBuffer(int capacity) {
+ super();
+ if (capacity < 0) {
+ throw new IllegalArgumentException("Buffer capacity may not be negative");
+ }
+ this.buffer = new byte[capacity];
+ }
+
+ private void expand(int newlen) {
+ byte newbuffer[] = new byte[Math.max(this.buffer.length << 1, newlen)];
+ System.arraycopy(this.buffer, 0, newbuffer, 0, this.len);
+ this.buffer = newbuffer;
+ }
+
+ public void append(final byte[] b, int off, int len) {
+ if (b == null) {
+ return;
+ }
+ if ((off < 0) || (off > b.length) || (len < 0) ||
+ ((off + len) < 0) || ((off + len) > b.length)) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len == 0) {
+ return;
+ }
+ int newlen = this.len + len;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ System.arraycopy(b, off, this.buffer, this.len, len);
+ this.len = newlen;
+ }
+
+ public void append(int b) {
+ int newlen = this.len + 1;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ this.buffer[this.len] = (byte) b;
+ this.len = newlen;
+ }
+
+ public void append(final char[] b, int off, int len) {
+ if (b == null) {
+ return;
+ }
+ if ((off < 0) || (off > b.length) || (len < 0) ||
+ ((off + len) < 0) || ((off + len) > b.length)) {
+ throw new IndexOutOfBoundsException();
+ }
+ if (len == 0) {
+ return;
+ }
+ int oldlen = this.len;
+ int newlen = oldlen + len;
+ if (newlen > this.buffer.length) {
+ expand(newlen);
+ }
+ for (int i1 = off, i2 = oldlen; i2 < newlen; i1++, i2++) {
+ this.buffer[i2] = (byte) b[i1];
+ }
+ this.len = newlen;
+ }
+
+ public void clear() {
+ this.len = 0;
+ }
+
+ public byte[] toByteArray() {
+ byte[] b = new byte[this.len];
+ if (this.len > 0) {
+ System.arraycopy(this.buffer, 0, b, 0, this.len);
+ }
+ return b;
+ }
+
+ public int byteAt(int i) {
+ return this.buffer[i];
+ }
+
+ public int capacity() {
+ return this.buffer.length;
+ }
+
+ public int length() {
+ return this.len;
+ }
+
+ public byte[] buffer() {
+ return this.buffer;
+ }
+
+ public void setLength(int len) {
+ if (len < 0 || len > this.buffer.length) {
+ throw new IndexOutOfBoundsException();
+ }
+ this.len = len;
+ }
+
+ public boolean isEmpty() {
+ return this.len == 0;
+ }
+
+ public boolean isFull() {
+ return this.len == this.buffer.length;
+ }
+
+}
diff --git a/src/com/android/tv/tuner/util/ConvertUtils.java b/src/com/android/tv/tuner/util/ConvertUtils.java
new file mode 100644
index 00000000..abf18d8c
--- /dev/null
+++ b/src/com/android/tv/tuner/util/ConvertUtils.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.util;
+
+/**
+ * Utility class for converting date and time.
+ */
+public class ConvertUtils {
+ // Time diff between 1.1.1970 00:00:00 and 6.1.1980 00:00:00
+ private static final long DIFF_BETWEEN_UNIX_EPOCH_AND_GPS = 315964800;
+
+ private ConvertUtils() { }
+
+ public static long convertGPSTimeToUnixEpoch(long gpsTime) {
+ return gpsTime + DIFF_BETWEEN_UNIX_EPOCH_AND_GPS;
+ }
+
+ public static long convertUnixEpochToGPSTime(long epochTime) {
+ return epochTime - DIFF_BETWEEN_UNIX_EPOCH_AND_GPS;
+ }
+}
diff --git a/src/com/android/tv/tuner/util/GlobalSettingsUtils.java b/src/com/android/tv/tuner/util/GlobalSettingsUtils.java
new file mode 100644
index 00000000..0cefcbed
--- /dev/null
+++ b/src/com/android/tv/tuner/util/GlobalSettingsUtils.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.util;
+
+import android.content.Context;
+import android.provider.Settings;
+
+/**
+ * Utility class that get information of global settings.
+ */
+public class GlobalSettingsUtils {
+ // Since global surround setting is hided, add the related variable here for checking surround
+ // sound setting when the audio is unavailable. Remove this workaround after b/31254857 fixed.
+ private static final String ENCODED_SURROUND_OUTPUT = "encoded_surround_output";
+ public static final int ENCODED_SURROUND_OUTPUT_NEVER = 1;
+
+ private GlobalSettingsUtils () { }
+
+ public static int getEncodedSurroundOutputSettings(Context context) {
+ return Settings.Global.getInt(context.getContentResolver(), ENCODED_SURROUND_OUTPUT, 0);
+ }
+}
diff --git a/src/com/android/tv/tuner/util/Ints.java b/src/com/android/tv/tuner/util/Ints.java
new file mode 100644
index 00000000..0b1be426
--- /dev/null
+++ b/src/com/android/tv/tuner/util/Ints.java
@@ -0,0 +1,28 @@
+package com.android.tv.tuner.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Static utility methods pertaining to int primitives. (Referred Guava's Ints class)
+ */
+public class Ints {
+ private Ints() {}
+
+ public static int[] toArray(List<Integer> integerList) {
+ int[] intArray = new int[integerList.size()];
+ int i = 0;
+ for (Integer data : integerList) {
+ intArray[i++] = data;
+ }
+ return intArray;
+ }
+
+ public static List<Integer> asList(int[] intArray) {
+ List<Integer> integerList = new ArrayList<>(intArray.length);
+ for (int data : intArray) {
+ integerList.add(data);
+ }
+ return integerList;
+ }
+}
diff --git a/src/com/android/tv/tuner/util/StatusTextUtils.java b/src/com/android/tv/tuner/util/StatusTextUtils.java
new file mode 100644
index 00000000..2633834b
--- /dev/null
+++ b/src/com/android/tv/tuner/util/StatusTextUtils.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.util;
+
+import java.util.Locale;
+
+/**
+ * Utility class for tuner status messages.
+ */
+public class StatusTextUtils {
+ private static final int PACKETS_PER_SEC_YELLOW = 1500;
+ private static final int PACKETS_PER_SEC_RED = 1000;
+ private static final int AUDIO_POSITION_MS_RATE_DIFF_YELLOW = 100;
+ private static final int AUDIO_POSITION_MS_RATE_DIFF_RED = 200;
+ private static final String COLOR_RED = "red";
+ private static final String COLOR_YELLOW = "yellow";
+ private static final String COLOR_GREEN = "green";
+ private static final String COLOR_GRAY = "gray";
+
+ private StatusTextUtils() { }
+
+ /**
+ * Returns tuner status warning message in HTML.
+ *
+ * <p>This is only called for debuging and always shown in english.</p>
+ */
+ public static String getStatusWarningInHTML(long packetsPerSec,
+ int videoFrameDrop, int bytesInQueue,
+ long audioPositionUs, long audioPositionUsRate,
+ long audioPtsUs, long audioPtsUsRate,
+ long videoPtsUs, long videoPtsUsRate) {
+ StringBuffer buffer = new StringBuffer();
+
+ // audioPosition should go in rate of 1000ms.
+ long audioPositionMsRate = audioPositionUsRate / 1000;
+ String audioPositionColor;
+ if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_RED) {
+ audioPositionColor = COLOR_RED;
+ } else if (Math.abs(audioPositionMsRate - 1000) > AUDIO_POSITION_MS_RATE_DIFF_YELLOW) {
+ audioPositionColor = COLOR_YELLOW;
+ } else {
+ audioPositionColor = COLOR_GRAY;
+ }
+ buffer.append(String.format(Locale.US, "<font color=%s>", audioPositionColor));
+ buffer.append(
+ String.format(Locale.US, "audioPositionMs: %d (%d)<br>", audioPositionUs / 1000,
+ audioPositionMsRate));
+ buffer.append("</font>\n");
+ buffer.append("<font color=" + COLOR_GRAY + ">");
+ buffer.append(String.format(Locale.US, "audioPtsMs: %d (%d, %d)<br>", audioPtsUs / 1000,
+ audioPtsUsRate / 1000, (audioPtsUs - audioPositionUs) / 1000));
+ buffer.append(String.format(Locale.US, "videoPtsMs: %d (%d, %d)<br>", videoPtsUs / 1000,
+ videoPtsUsRate / 1000, (videoPtsUs - audioPositionUs) / 1000));
+ buffer.append("</font>\n");
+
+ appendStatusLine(buffer, "KbytesInQueue", bytesInQueue / 1000, 1, 10);
+ buffer.append("<br/>");
+ appendErrorStatusLine(buffer, "videoFrameDrop", videoFrameDrop, 0, 2);
+ buffer.append("<br/>");
+ appendStatusLine(buffer, "packetsPerSec", packetsPerSec, PACKETS_PER_SEC_RED,
+ PACKETS_PER_SEC_YELLOW);
+ return buffer.toString();
+ }
+
+ /**
+ * Returns audio unavailable warning message in HTML.
+ */
+ public static String getAudioWarningInHTML(String msg) {
+ return String.format("<font color=%s>%s</font>\n", COLOR_YELLOW, msg);
+ }
+
+ private static void appendStatusLine(StringBuffer buffer, String factorName, long value,
+ int minRed, int minYellow) {
+ buffer.append("<font color=");
+ if (value <= minRed) {
+ buffer.append(COLOR_RED);
+ } else if (value <= minYellow) {
+ buffer.append(COLOR_YELLOW);
+ } else {
+ buffer.append(COLOR_GREEN);
+ }
+ buffer.append(">");
+ buffer.append(factorName);
+ buffer.append(" : ");
+ buffer.append(value);
+ buffer.append("</font>");
+ }
+
+ private static void appendErrorStatusLine(StringBuffer buffer, String factorName, int value,
+ int minGreen, int minYellow) {
+ buffer.append("<font color=");
+ if (value <= minGreen) {
+ buffer.append(COLOR_GREEN);
+ } else if (value <= minYellow) {
+ buffer.append(COLOR_YELLOW);
+ } else {
+ buffer.append(COLOR_RED);
+ }
+ buffer.append(">");
+ buffer.append(factorName);
+ buffer.append(" : ");
+ buffer.append(value);
+ buffer.append("</font>");
+ }
+}
diff --git a/src/com/android/tv/dvr/SeasonRecording.java b/src/com/android/tv/tuner/util/StringUtils.java
index 7f89e135..15571e75 100644
--- a/src/com/android/tv/dvr/SeasonRecording.java
+++ b/src/com/android/tv/tuner/util/StringUtils.java
@@ -14,22 +14,25 @@
* limitations under the License.
*/
-package com.android.tv.dvr;
-
-import java.util.List;
+package com.android.tv.tuner.util;
/**
- * A data class for one recorded contents.
+ * Utility class for handling {@link String}.
*/
-public class SeasonRecording {
- private static final String TAG = "Recording";
+public final class StringUtils {
+
+ private StringUtils() { }
/**
- * Constant for all season.
+ * Returns compares two strings lexicographically and handles null values quietly.
*/
- private static final int ALL_SEASON = -1;
-
- private List<ScheduledRecording> mSchedule;
- private String mTitle;
- private int mSeasonNumber;
+ public static int compare(String a, String b) {
+ if (a == null) {
+ return b == null ? 0 : -1;
+ }
+ if (b == null) {
+ return 1;
+ }
+ return a.compareTo(b);
+ }
}
diff --git a/src/com/android/tv/tuner/util/SystemPropertiesProxy.java b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java
new file mode 100644
index 00000000..62a64361
--- /dev/null
+++ b/src/com/android/tv/tuner/util/SystemPropertiesProxy.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.util;
+
+import android.util.Log;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Proxy class that gives an access to a hidden API {@link android.os.SystemProperties#getBoolean}.
+ */
+public class SystemPropertiesProxy {
+ private static final String TAG = "SystemPropertiesProxy";
+
+ private SystemPropertiesProxy() { }
+
+ public static boolean getBoolean(String key, boolean def)
+ throws IllegalArgumentException {
+ try {
+ Class SystemPropertiesClass = Class.forName("android.os.SystemProperties");
+ Method getBooleanMethod = SystemPropertiesClass.getDeclaredMethod("getBoolean",
+ String.class, boolean.class);
+ getBooleanMethod.setAccessible(true);
+ return (boolean) getBooleanMethod.invoke(SystemPropertiesClass, key, def);
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException
+ | ClassNotFoundException e) {
+ Log.e(TAG, "Failed to invoke SystemProperties.getBoolean()", e);
+ }
+ return def;
+ }
+
+ public static int getInt(String key, int def)
+ throws IllegalArgumentException {
+ try {
+ Class SystemPropertiesClass = Class.forName("android.os.SystemProperties");
+ Method getIntMethod = SystemPropertiesClass.getDeclaredMethod("getInt",
+ String.class, int.class);
+ getIntMethod.setAccessible(true);
+ return (int) getIntMethod.invoke(SystemPropertiesClass, key, def);
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException
+ | ClassNotFoundException e) {
+ Log.e(TAG, "Failed to invoke SystemProperties.getInt()", e);
+ }
+ return def;
+ }
+}
diff --git a/src/com/android/tv/tuner/util/TisConfiguration.java b/src/com/android/tv/tuner/util/TisConfiguration.java
new file mode 100644
index 00000000..ca861d67
--- /dev/null
+++ b/src/com/android/tv/tuner/util/TisConfiguration.java
@@ -0,0 +1,22 @@
+package com.android.tv.tuner.util;
+
+import android.content.Context;
+
+/**
+ * A helper class of tuner configuration.
+ */
+public class TisConfiguration {
+ private static final String LC_PACKAGE_NAME = "com.android.tv";
+
+ public static boolean isPackagedWithLiveChannels(Context context) {
+ return (LC_PACKAGE_NAME.equals(context.getPackageName()));
+ }
+
+ public static boolean isInternalTunerTvInput(Context context) {
+ return (!LC_PACKAGE_NAME.equals(context.getPackageName()));
+ }
+
+ public static int getTunerHwDeviceId(Context context) {
+ return 0; // FIXME: Make it OEM configurable
+ }
+}
diff --git a/src/com/android/tv/tuner/util/TunerInputInfoUtils.java b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java
new file mode 100644
index 00000000..5c411f64
--- /dev/null
+++ b/src/com/android/tv/tuner/util/TunerInputInfoUtils.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.tuner.util;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvInputManager;
+import android.os.Build;
+import android.support.annotation.Nullable;
+import android.support.v4.os.BuildCompat;
+import android.util.Log;
+
+import com.android.tv.common.feature.CommonFeatures;
+import com.android.tv.tuner.R;
+import com.android.tv.tuner.TunerHal;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
+
+/**
+ * Utility class for providing tuner input info.
+ */
+public class TunerInputInfoUtils {
+ private static final String TAG = "TunerInputInfoUtils";
+ private static final boolean DEBUG = false;
+
+ /**
+ * Builds tuner input's info.
+ */
+ @Nullable
+ @TargetApi(Build.VERSION_CODES.N)
+ public static TvInputInfo buildTunerInputInfo(Context context, boolean fromBuiltInTuner) {
+ int numOfDevices = TunerHal.getTunerCount(context);
+ if (numOfDevices == 0) {
+ return null;
+ }
+ TvInputInfo.Builder builder = new TvInputInfo.Builder(context, new ComponentName(context,
+ TunerTvInputService.class));
+ if (fromBuiltInTuner) {
+ builder.setLabel(R.string.bt_app_name);
+ } else {
+ builder.setLabel(R.string.ut_app_name);
+ }
+ try {
+ return builder.setCanRecord(CommonFeatures.DVR.isEnabled(context))
+ .setTunerCount(numOfDevices)
+ .build();
+ } catch (NullPointerException e) {
+ // TunerTvInputService is not enabled.
+ return null;
+ }
+ }
+
+ /**
+ * Updates tuner input's info.
+ *
+ * @param context {@link Context} instance
+ */
+ public static void updateTunerInputInfo(Context context) {
+ if (BuildCompat.isAtLeastN()) {
+ if (DEBUG) Log.d(TAG, "updateTunerInputInfo()");
+ TvInputInfo info = buildTunerInputInfo(context, isBuiltInTuner(context));
+ if (info != null) {
+ ((TvInputManager) context.getSystemService(Context.TV_INPUT_SERVICE))
+ .updateTvInputInfo(info);
+ if (DEBUG) {
+ Log.d(TAG, "TvInputInfo [" + info.loadLabel(context)
+ + "] updated: " + info.toString());
+ }
+ } else {
+ if (DEBUG) {
+ Log.d(TAG, "Updating tuner input's info failed. Input is not ready yet.");
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns if the current tuner service is for a built-in tuner.
+ *
+ * @param context {@link Context} instance
+ */
+ public static boolean isBuiltInTuner(Context context) {
+ return TunerHal.getTunerType(context) == TunerHal.TUNER_TYPE_BUILT_IN;
+ }
+}
diff --git a/src/com/android/tv/ui/AppLayerTvView.java b/src/com/android/tv/ui/AppLayerTvView.java
index c7b94a15..09acb36b 100644
--- a/src/com/android/tv/ui/AppLayerTvView.java
+++ b/src/com/android/tv/ui/AppLayerTvView.java
@@ -19,6 +19,10 @@ package com.android.tv.ui;
import android.content.Context;
import android.media.tv.TvView;
import android.util.AttributeSet;
+import android.view.SurfaceView;
+import android.view.View;
+
+import com.android.tv.experiments.Experiments;
/**
* A TvView class for application layer when multiple windows are being used in the app.
@@ -46,4 +50,13 @@ public class AppLayerTvView extends TvView {
public boolean hasWindowFocus() {
return true;
}
+
+ @Override
+ public void onViewAdded(View child) {
+ if (child instanceof SurfaceView) {
+ // Note: See b/29118070 for detail.
+ ((SurfaceView) child).setSecure(!Experiments.ENABLE_DEVELOPER_FEATURES.get());
+ }
+ super.onViewAdded(child);
+ }
}
diff --git a/src/com/android/tv/ui/ChannelBannerView.java b/src/com/android/tv/ui/ChannelBannerView.java
index a36ba83c..3cf4de83 100644
--- a/src/com/android/tv/ui/ChannelBannerView.java
+++ b/src/com/android/tv/ui/ChannelBannerView.java
@@ -25,6 +25,7 @@ import android.content.Context;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.graphics.Bitmap;
+import android.media.tv.TvContentRating;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.net.Uri;
@@ -50,10 +51,14 @@ import android.widget.TextView;
import com.android.tv.MainActivity;
import com.android.tv.R;
-import com.android.tv.common.recording.RecordedProgram;
+import com.android.tv.TvApplication;
+import com.android.tv.common.feature.CommonFeatures;
import com.android.tv.data.Channel;
import com.android.tv.data.Program;
import com.android.tv.data.StreamInfo;
+import com.android.tv.dvr.DvrManager;
+import com.android.tv.dvr.ScheduledRecording;
+import com.android.tv.parental.ContentRatingsManager;
import com.android.tv.util.ImageCache;
import com.android.tv.util.ImageLoader;
import com.android.tv.util.ImageLoader.ImageLoaderCallback;
@@ -89,6 +94,8 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
*/
public static final int LOCK_CHANNEL_INFO = 2;
+ private static final int DISPLAYED_CONTENT_RATINGS_COUNT = 3;
+
private static final String EMPTY_STRING = "";
private static Program sNoProgram;
@@ -106,17 +113,21 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
private TextView mChannelNameTextView;
private TextView mProgramTimeTextView;
private ProgressBar mRemainingTimeView;
+ private TextView mRecordingIndicatorView;
private TextView mClosedCaptionTextView;
private TextView mAspectRatioTextView;
private TextView mResolutionTextView;
private TextView mAudioChannelTextView;
+ private TextView[] mContentRatingsTextViews = new TextView[DISPLAYED_CONTENT_RATINGS_COUNT];
private TextView mProgramDescriptionTextView;
private String mProgramDescriptionText;
private View mAnchorView;
private Channel mCurrentChannel;
private Program mLastUpdatedProgram;
- private RecordedProgram mLastUpdatedRecordedProgram;
private final Handler mHandler = new Handler();
+ private final DvrManager mDvrManager;
+ private ContentRatingsManager mContentRatingsManager;
+ private TvContentRating mBlockingContentRating;
private int mLockType;
@@ -147,6 +158,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
private final int mChannelBannerTextColor;
private final int mChannelBannerDimTextColor;
private final int mResizeAnimDuration;
+ private final int mRecordingIconPadding;
private final Interpolator mResizeInterpolator;
private final AnimatorListenerAdapter mResizeAnimatorListener = new AnimatorListenerAdapter() {
@@ -208,10 +220,12 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
R.dimen.channel_banner_channel_logo_margin_start);
mProgramDescriptionTextViewWidth = mResources.getDimensionPixelSize(
R.dimen.channel_banner_program_description_width);
- mChannelBannerTextColor = Utils.getColor(mResources, R.color.channel_banner_text_color);
- mChannelBannerDimTextColor = Utils.getColor(mResources,
- R.color.channel_banner_dim_text_color);
+ mChannelBannerTextColor = mResources.getColor(R.color.channel_banner_text_color, null);
+ mChannelBannerDimTextColor = mResources.getColor(R.color.channel_banner_dim_text_color,
+ null);
mResizeAnimDuration = mResources.getInteger(R.integer.channel_banner_fast_anim_duration);
+ mRecordingIconPadding = mResources.getDimensionPixelOffset(
+ R.dimen.channel_banner_recording_icon_padding);
mResizeInterpolator = AnimationUtils.loadInterpolator(context,
android.R.interpolator.linear_out_slow_in);
@@ -221,6 +235,14 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
mProgramDescriptionFadeOutAnimator = AnimatorInflater.loadAnimator(mMainActivity,
R.animator.channel_banner_program_description_fade_out);
+ if (CommonFeatures.DVR.isEnabled(mMainActivity)) {
+ mDvrManager = TvApplication.getSingletons(mMainActivity).getDvrManager();
+ } else {
+ mDvrManager = null;
+ }
+ mContentRatingsManager = TvApplication.getSingletons(getContext())
+ .getTvInputManagerHelper().getContentRatingsManager();
+
if (sNoProgram == null) {
sNoProgram = new Program.Builder()
.setTitle(context.getString(R.string.channel_banner_no_title))
@@ -266,10 +288,14 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
mChannelNameTextView = (TextView) findViewById(R.id.channel_name);
mProgramTimeTextView = (TextView) findViewById(R.id.program_time_text);
mRemainingTimeView = (ProgressBar) findViewById(R.id.remaining_time);
+ mRecordingIndicatorView = (TextView) findViewById(R.id.recording_indicator);
mClosedCaptionTextView = (TextView) findViewById(R.id.closed_caption);
mAspectRatioTextView = (TextView) findViewById(R.id.aspect_ratio);
mResolutionTextView = (TextView) findViewById(R.id.resolution);
mAudioChannelTextView = (TextView) findViewById(R.id.audio_channel);
+ mContentRatingsTextViews[0] = (TextView) findViewById(R.id.content_ratings_0);
+ mContentRatingsTextViews[1] = (TextView) findViewById(R.id.content_ratings_1);
+ mContentRatingsTextViews[2] = (TextView) findViewById(R.id.content_ratings_2);
mProgramDescriptionTextView = (TextView) findViewById(R.id.program_description);
mAnchorView = findViewById(R.id.anchor);
@@ -335,6 +361,15 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
}
/**
+ * Sets the content rating that blocks the current watched channel for displaying it in the
+ * channel banner.
+ */
+ public void setBlockingContentRating(TvContentRating rating) {
+ mBlockingContentRating = rating;
+ updateProgramRatings(mMainActivity.getCurrentProgram());
+ }
+
+ /**
* Update channel banner view.
*
* @param info A StreamInfo that includes stream information.
@@ -343,8 +378,11 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
public void updateViews(StreamInfo info) {
resetAnimationEffects();
Channel channel = mMainActivity.getCurrentChannel();
- if (!Objects.equals(mCurrentChannel, channel) && isShown()) {
- scheduleHide();
+ if (!Objects.equals(mCurrentChannel, channel)) {
+ mBlockingContentRating = null;
+ if (isShown()) {
+ scheduleHide();
+ }
}
mCurrentChannel = channel;
mChannelView.setVisibility(VISIBLE);
@@ -355,11 +393,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
: null);
updateChannelInfo();
}
- if (mMainActivity.isRecordingPlayback()) {
- updateProgramInfo(mMainActivity.getPlayingRecordedProgram());
- } else {
- updateProgramInfo(mMainActivity.getCurrentProgram());
- }
+ updateProgramInfo(mMainActivity.getCurrentProgram());
}
private void updateStreamInfo(StreamInfo info) {
@@ -380,6 +414,9 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
mAspectRatioTextView.setVisibility(View.GONE);
mResolutionTextView.setVisibility(View.GONE);
mAudioChannelTextView.setVisibility(View.GONE);
+ for (int i = 0; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) {
+ mContentRatingsTextViews[i].setVisibility(View.GONE);
+ }
}
}
@@ -439,15 +476,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
private String getCurrentInputId() {
Channel channel = mMainActivity.getCurrentChannel();
- if (channel != null) {
- return channel.getInputId();
- } else if (mMainActivity.isRecordingPlayback()) {
- RecordedProgram recordedProgram = mMainActivity.getPlayingRecordedProgram();
- if (recordedProgram != null) {
- return recordedProgram.getInputId();
- }
- }
- return null;
+ return channel != null ? channel.getInputId() : null;
}
private void updateTvInputLogo(Bitmap bitmap) {
@@ -531,7 +560,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
private void updateProgramInfo(Program program) {
if (mLockType == LOCK_CHANNEL_INFO) {
program = sLockedChannelProgram;
- } else if (!Program.isValid(program) || TextUtils.isEmpty(program.getTitle())) {
+ } else if (program == null || !program.isValid() || TextUtils.isEmpty(program.getTitle())) {
program = sNoProgram;
}
@@ -542,10 +571,12 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
updateProgramTextView(program);
}
updateProgramTimeInfo(program);
+ updateRecordingStatus(program);
+ updateProgramRatings(program);
// When the program is changed, but the previous resize animation has not ended yet,
// cancel the animation.
- boolean isProgramChanged = !Objects.equals(mLastUpdatedProgram, program);
+ boolean isProgramChanged = !program.equals(mLastUpdatedProgram);
if (mResizeAnimator != null && isProgramChanged) {
setLastUpdatedProgram(program);
mProgramInfoUpdatePendingByResizing = true;
@@ -568,67 +599,15 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
setLastUpdatedProgram(program);
}
- private void updateProgramInfo(RecordedProgram recordedProgram) {
- if (mLockType == LOCK_CHANNEL_INFO) {
- updateProgramInfo(sLockedChannelProgram);
- return;
- } else if (recordedProgram == null) {
- updateProgramInfo(sNoProgram);
- return;
- }
-
- if (mLastUpdatedRecordedProgram == null
- || !TextUtils.equals(recordedProgram.getTitle(),
- mLastUpdatedRecordedProgram.getTitle())
- || !TextUtils.equals(recordedProgram.getEpisodeDisplayTitle(getContext()),
- mLastUpdatedRecordedProgram.getEpisodeDisplayTitle(getContext()))) {
- updateProgramTextView(recordedProgram);
- }
- updateProgramTimeInfo(recordedProgram);
-
- // When the program is changed, but the previous resize animation has not ended yet,
- // cancel the animation.
- boolean isProgramChanged = !Objects.equals(mLastUpdatedRecordedProgram, recordedProgram);
- if (mResizeAnimator != null && isProgramChanged) {
- setLastUpdatedRecordedProgram(recordedProgram);
- mProgramInfoUpdatePendingByResizing = true;
- mResizeAnimator.cancel();
- } else if (mResizeAnimator == null) {
- if (mLockType != LOCK_NONE
- || TextUtils.isEmpty(recordedProgram.getShortDescription())) {
- mProgramDescriptionTextView.setVisibility(GONE);
- mProgramDescriptionText = "";
- } else {
- mProgramDescriptionTextView.setVisibility(VISIBLE);
- mProgramDescriptionText = recordedProgram.getShortDescription();
- }
- String description = mProgramDescriptionTextView.getText().toString();
- boolean needFadeAnimation = isProgramChanged
- || !description.equals(mProgramDescriptionText);
- updateBannerHeight(needFadeAnimation);
- } else {
- mProgramInfoUpdatePendingByResizing = true;
- }
- setLastUpdatedRecordedProgram(recordedProgram);
- }
-
private void updateProgramTextView(Program program) {
if (program == null) {
return;
}
updateProgramTextView(program == sLockedChannelProgram, program.getTitle(),
- program.getEpisodeTitle(), program.getEpisodeDisplayTitle(getContext()));
- }
-
- private void updateProgramTextView(RecordedProgram recordedProgram) {
- if (recordedProgram == null) {
- return;
- }
- updateProgramTextView(false, recordedProgram.getTitle(), recordedProgram.getEpisodeTitle(),
- recordedProgram.getEpisodeDisplayTitle(getContext()));
+ program.getEpisodeDisplayTitle(getContext()));
}
- private void updateProgramTextView(boolean dimText, String title, String episodeTitle,
+ private void updateProgramTextView(boolean dimText, String title,
String episodeDisplayTitle) {
mProgramTextView.setVisibility(View.VISIBLE);
if (dimText) {
@@ -639,7 +618,7 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
updateTextView(mProgramTextView,
R.dimen.channel_banner_program_large_text_size,
R.dimen.channel_banner_program_large_margin_top);
- if (TextUtils.isEmpty(episodeTitle)) {
+ if (TextUtils.isEmpty(episodeDisplayTitle)) {
mProgramTextView.setText(title);
} else {
String fullTitle = title + " " + episodeDisplayTitle;
@@ -675,61 +654,119 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
: R.dimen.channel_banner_anchor_two_line_y);
}
+ private void updateProgramRatings(Program program) {
+ if (mBlockingContentRating != null) {
+ mContentRatingsTextViews[0].setText(
+ mContentRatingsManager.getDisplayNameForRating(mBlockingContentRating));
+ mContentRatingsTextViews[0].setVisibility(View.VISIBLE);
+ for (int i = 1; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) {
+ mContentRatingsTextViews[i].setVisibility(View.GONE);
+ }
+ return;
+ }
+ TvContentRating[] ratings = (program == null) ? null : program.getContentRatings();
+ for (int i = 0; i < DISPLAYED_CONTENT_RATINGS_COUNT; i++) {
+ if (ratings == null || ratings.length <= i) {
+ mContentRatingsTextViews[i].setVisibility(View.GONE);
+ } else {
+ mContentRatingsTextViews[i].setText(
+ mContentRatingsManager.getDisplayNameForRating(ratings[i]));
+ mContentRatingsTextViews[i].setVisibility(View.VISIBLE);
+ }
+ }
+ }
+
private void updateProgramTimeInfo(Program program) {
- long startTime = program.getStartTimeUtcMillis();
- long endTime = program.getEndTimeUtcMillis();
- if (mLockType != LOCK_CHANNEL_INFO && startTime > 0 && endTime > startTime) {
+ long durationMs = program.getDurationMillis();
+ long startTimeMs = program.getStartTimeUtcMillis();
+ long endTimeMs = program.getEndTimeUtcMillis();
+
+ if (mLockType != LOCK_CHANNEL_INFO && durationMs > 0 && startTimeMs > 0) {
mProgramTimeTextView.setVisibility(View.VISIBLE);
mRemainingTimeView.setVisibility(View.VISIBLE);
-
mProgramTimeTextView.setText(Utils.getDurationString(
- getContext(), startTime, endTime, true));
-
- long currTime = mMainActivity.getCurrentPlayingPosition();
- if (currTime <= startTime) {
- mRemainingTimeView.setProgress(0);
- } else if (currTime >= endTime) {
- mRemainingTimeView.setProgress(100);
- } else {
- mRemainingTimeView.setProgress(
- (int) (100 * (currTime - startTime) / (endTime - startTime)));
- }
+ getContext(), startTimeMs, endTimeMs, true));
} else {
mProgramTimeTextView.setVisibility(View.GONE);
mRemainingTimeView.setVisibility(View.GONE);
}
}
- private void updateProgramTimeInfo(RecordedProgram recordedProgram) {
- long durationMs = recordedProgram.getDurationMillis();
- if (mLockType != LOCK_CHANNEL_INFO && durationMs > 0) {
- mProgramTimeTextView.setVisibility(View.VISIBLE);
- mRemainingTimeView.setVisibility(View.VISIBLE);
+ private int getProgressPercent(long currTime, long startTime, long endTime) {
+ if (currTime <= startTime) {
+ return 0;
+ } else if (currTime >= endTime) {
+ return 100;
+ } else {
+ return (int) (100 * (currTime - startTime) / (endTime - startTime));
+ }
+ }
- mProgramTimeTextView.setText(DateUtils.formatElapsedTime(durationMs / 1000));
+ private void updateRecordingStatus(Program program) {
+ if (mDvrManager == null) {
+ updateProgressBarAndRecIcon(program, null);
+ return;
+ }
+ ScheduledRecording currentRecording = (mCurrentChannel == null) ? null
+ : mDvrManager.getCurrentRecording(mCurrentChannel.getId());
+ if (DEBUG) {
+ Log.d(TAG, currentRecording == null ? "No Recording" : "Recording:" + currentRecording);
+ }
+ if (currentRecording != null && isCurrentProgram(currentRecording, program)) {
+ updateProgressBarAndRecIcon(program, currentRecording);
+ } else {
+ updateProgressBarAndRecIcon(program, null);
+ }
+ }
+
+ private void updateProgressBarAndRecIcon(Program program,
+ @Nullable ScheduledRecording recording) {
+ long programStartTime = program.getStartTimeUtcMillis();
+ long programEndTime = program.getEndTimeUtcMillis();
+ long currentPosition = mMainActivity.getCurrentPlayingPosition();
+ updateRecordingIndicator(recording);
+ if (recording != null) {
+ // Recording now. Use recording-style progress bar.
+ mRemainingTimeView.setProgress(getProgressPercent(recording.getStartTimeMs(),
+ programStartTime, programEndTime));
+ mRemainingTimeView.setSecondaryProgress(getProgressPercent(currentPosition,
+ programStartTime, programEndTime));
+ } else {
+ // No recording is going now. Recover progress bar.
+ mRemainingTimeView.setProgress(getProgressPercent(currentPosition,
+ programStartTime, programEndTime));
+ mRemainingTimeView.setSecondaryProgress(0);
+ }
+ }
- long currTimeMs = mMainActivity.getCurrentPlayingPosition();
- if (currTimeMs <= 0) {
- mRemainingTimeView.setProgress(0);
- } else if (currTimeMs >= durationMs) {
- mRemainingTimeView.setProgress(100);
+ private void updateRecordingIndicator(@Nullable ScheduledRecording recording) {
+ if (recording != null) {
+ if (mRemainingTimeView.getVisibility() == View.GONE) {
+ mRecordingIndicatorView.setText(mMainActivity.getResources().getString(
+ R.string.dvr_recording_till_format, DateUtils.formatDateTime(mMainActivity,
+ recording.getEndTimeMs(), DateUtils.FORMAT_SHOW_TIME)));
+ mRecordingIndicatorView.setCompoundDrawablePadding(mRecordingIconPadding);
} else {
- mRemainingTimeView.setProgress((int) (100 * currTimeMs / durationMs));
+ mRecordingIndicatorView.setText("");
+ mRecordingIndicatorView.setCompoundDrawablePadding(0);
}
+ mRecordingIndicatorView.setVisibility(View.VISIBLE);
} else {
- mProgramTimeTextView.setVisibility(View.GONE);
- mRemainingTimeView.setVisibility(View.GONE);
+ mRecordingIndicatorView.setVisibility(View.GONE);
}
}
- private void setLastUpdatedProgram(Program program) {
- mLastUpdatedProgram = program;
- mLastUpdatedRecordedProgram = null;
+ private boolean isCurrentProgram(ScheduledRecording recording, Program program) {
+ long currentPosition = mMainActivity.getCurrentPlayingPosition();
+ return (recording.getType() == ScheduledRecording.TYPE_PROGRAM
+ && recording.getProgramId() == program.getId())
+ || (recording.getType() == ScheduledRecording.TYPE_TIMED
+ && currentPosition >= recording.getStartTimeMs()
+ && currentPosition <= recording.getEndTimeMs());
}
- private void setLastUpdatedRecordedProgram(RecordedProgram recordedProgram) {
- mLastUpdatedProgram = null;
- mLastUpdatedRecordedProgram = recordedProgram;
+ private void setLastUpdatedProgram(Program program) {
+ mLastUpdatedProgram = program;
}
private void updateBannerHeight(boolean needFadeAnimation) {
@@ -788,4 +825,4 @@ public class ChannelBannerView extends FrameLayout implements TvTransitionManage
animator.addListener(mResizeAnimatorListener);
return animator;
}
-}
+} \ No newline at end of file
diff --git a/src/com/android/tv/ui/GuidedActionsStylistWithDivider.java b/src/com/android/tv/ui/GuidedActionsStylistWithDivider.java
new file mode 100644
index 00000000..39ec1279
--- /dev/null
+++ b/src/com/android/tv/ui/GuidedActionsStylistWithDivider.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.ui;
+
+import android.content.Context;
+import android.support.v17.leanback.app.GuidedStepFragment;
+import android.support.v17.leanback.widget.GuidedAction;
+import android.support.v17.leanback.widget.GuidedActionsStylist;
+
+import com.android.tv.R;
+
+/**
+ * Extended stylist class used for {@link GuidedStepFragment} with divider support.
+ */
+public class GuidedActionsStylistWithDivider extends GuidedActionsStylist {
+ /**
+ * ID used mark a divider.
+ */
+ public static final int ACTION_DIVIDER = -100;
+ private static final int VIEW_TYPE_DIVIDER = 1;
+
+ @Override
+ public int getItemViewType(GuidedAction action) {
+ if (action.getId() == ACTION_DIVIDER) {
+ return VIEW_TYPE_DIVIDER;
+ }
+ return super.getItemViewType(action);
+ }
+
+ @Override
+ public int onProvideItemLayoutId(int viewType) {
+ if (viewType == VIEW_TYPE_DIVIDER) {
+ return R.layout.guided_action_divider;
+ }
+ return super.onProvideItemLayoutId(viewType);
+ }
+
+ /**
+ * Creates a divider for {@link GuidedStepFragment}, targeted fragments must use
+ * {@link GuidedActionsStylistWithDivider} as its actions' stylist for divider to work.
+ */
+ public static GuidedAction createDividerAction(Context context) {
+ return new GuidedAction.Builder(context)
+ .id(ACTION_DIVIDER)
+ .title(null)
+ .description(null)
+ .focusable(false)
+ .infoOnly(true)
+ .build();
+ }
+}
diff --git a/src/com/android/tv/ui/OverlayRootView.java b/src/com/android/tv/ui/OverlayRootView.java
deleted file mode 100644
index f6dc2537..00000000
--- a/src/com/android/tv/ui/OverlayRootView.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.ui;
-
-import android.content.Context;
-import android.util.AttributeSet;
-import android.view.KeyEvent;
-import android.widget.FrameLayout;
-
-import com.android.tv.MainActivity;
-
-public class OverlayRootView extends FrameLayout {
-
- private final MainActivity mMainActivity;
-
- public OverlayRootView(Context context) {
- this(context, null, 0, 0);
- }
-
- public OverlayRootView(Context context, AttributeSet attrs) {
- this(context, attrs, 0, 0);
- }
-
- public OverlayRootView(Context context, AttributeSet attrs, int defStyleAttr) {
- this(context, attrs, defStyleAttr, 0);
- }
-
- public OverlayRootView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
- mMainActivity = (MainActivity) context;
- }
-
- @Override
- public boolean dispatchKeyEvent(KeyEvent event) {
- return mMainActivity.dispatchKeyEvent(event) || super.dispatchKeyEvent(event);
- }
-}
diff --git a/src/com/android/tv/ui/SelectInputView.java b/src/com/android/tv/ui/SelectInputView.java
index 646f9159..5e25ae43 100644
--- a/src/com/android/tv/ui/SelectInputView.java
+++ b/src/com/android/tv/ui/SelectInputView.java
@@ -34,14 +34,13 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
-import com.android.tv.R;
import com.android.tv.ApplicationSingletons;
+import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.analytics.DurationTimer;
import com.android.tv.analytics.Tracker;
import com.android.tv.data.Channel;
import com.android.tv.util.TvInputManagerHelper;
-import com.android.tv.util.Utils;
import java.util.ArrayList;
import java.util.Collections;
@@ -156,9 +155,9 @@ public class SelectInputView extends VerticalGridView implements
mShowDurationMillis = resources.getInteger(R.integer.select_input_show_duration);
mRippleAnimDurationMillis = resources.getInteger(
R.integer.select_input_ripple_anim_duration);
- mTextColorPrimary = Utils.getColor(resources, R.color.select_input_text_color_primary);
- mTextColorSecondary = Utils.getColor(resources, R.color.select_input_text_color_secondary);
- mTextColorDisabled = Utils.getColor(resources, R.color.select_input_text_color_disabled);
+ mTextColorPrimary = resources.getColor(R.color.select_input_text_color_primary, null);
+ mTextColorSecondary = resources.getColor(R.color.select_input_text_color_secondary, null);
+ mTextColorDisabled = resources.getColor(R.color.select_input_text_color_disabled, null);
mItemViewForMeasure = LayoutInflater.from(context).inflate(
R.layout.select_input_item, this, false);
diff --git a/src/com/android/tv/ui/TunableTvView.java b/src/com/android/tv/ui/TunableTvView.java
index 6d3d62aa..cbe459fb 100644
--- a/src/com/android/tv/ui/TunableTvView.java
+++ b/src/com/android/tv/ui/TunableTvView.java
@@ -20,8 +20,6 @@ import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.TimeInterpolator;
import android.annotation.SuppressLint;
-import android.annotation.TargetApi;
-import android.content.ContentUris;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.PlaybackParams;
@@ -35,13 +33,14 @@ import android.media.tv.TvView.TvInputCallback;
import android.net.ConnectivityManager;
import android.net.Uri;
import android.os.AsyncTask;
-import android.os.Build;
import android.os.Bundle;
import android.support.annotation.IntDef;
+import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
import android.support.v4.os.BuildCompat;
import android.text.TextUtils;
+import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
@@ -53,17 +52,16 @@ import android.widget.FrameLayout;
import android.widget.ImageView;
import com.android.tv.ApplicationSingletons;
+import com.android.tv.InputSessionManager;
+import com.android.tv.InputSessionManager.TvViewSession;
import com.android.tv.R;
import com.android.tv.TvApplication;
import com.android.tv.analytics.DurationTimer;
import com.android.tv.analytics.Tracker;
import com.android.tv.common.feature.CommonFeatures;
-import com.android.tv.common.recording.RecordedProgram;
import com.android.tv.data.Channel;
-import com.android.tv.data.ChannelDataManager;
import com.android.tv.data.StreamInfo;
import com.android.tv.data.WatchedHistoryManager;
-import com.android.tv.dvr.DvrDataManager;
import com.android.tv.parental.ContentRatingsManager;
import com.android.tv.recommendation.NotificationService;
import com.android.tv.util.NetworkUtils;
@@ -73,8 +71,6 @@ import com.android.tv.util.Utils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
import java.util.List;
public class TunableTvView extends FrameLayout implements StreamInfo {
@@ -82,6 +78,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
private static final String TAG = "TunableTvView";
public static final int VIDEO_UNAVAILABLE_REASON_NOT_TUNED = -1;
+ public static final int VIDEO_UNAVAILABLE_REASON_NO_RESOURCE = -2;
@Retention(RetentionPolicy.SOURCE)
@IntDef({BLOCK_SCREEN_TYPE_NO_UI, BLOCK_SCREEN_TYPE_SHRUNKEN_TV_VIEW, BLOCK_SCREEN_TYPE_NORMAL})
@@ -108,14 +105,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
private static final int FADING_IN = 2;
private static final int FADING_OUT = 3;
- private static final long INVALID_TIME = -1;
-
// It is too small to see the description text without PIP_BLOCK_SCREEN_SCALE_FACTOR.
private static final float PIP_BLOCK_SCREEN_SCALE_FACTOR = 1.2f;
private AppLayerTvView mTvView;
+ private TvViewSession mTvViewSession;
private Channel mCurrentChannel;
- private RecordedProgram mRecordedProgram;
private TvInputManagerHelper mInputManagerHelper;
private ContentRatingsManager mContentRatingsManager;
@Nullable
@@ -149,7 +144,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
@TimeShiftState private int mTimeShiftState = TIME_SHIFT_STATE_NONE;
private TimeShiftListener mTimeShiftListener;
private boolean mTimeShiftAvailable;
- private long mTimeShiftCurrentPositionMs = INVALID_TIME;
+ private long mTimeShiftCurrentPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
private final Tracker mTracker;
private final DurationTimer mChannelViewTimer = new DurationTimer();
@@ -175,173 +170,167 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
@BlockScreenType private int mBlockScreenType;
- private final DvrDataManager mDvrDataManager;
- private final ChannelDataManager mChannelDataManager;
+ private final TvInputManagerHelper mInputManager;
private final ConnectivityManager mConnectivityManager;
+ private final InputSessionManager mInputSessionManager;
- private final TvInputCallback mCallback =
- new TvInputCallback() {
- @Override
- public void onConnectionFailed(String inputId) {
- Log.w(TAG, "Failed to bind an input");
- mTracker.sendInputConnectionFailure(inputId);
- Channel channel = mCurrentChannel;
- mCurrentChannel = null;
- mInputInfo = null;
- mCanReceiveInputEvent = false;
- if (mOnTuneListener != null) {
- // If tune is called inside onTuneFailed, mOnTuneListener will be set to
- // a new instance. In order to avoid to clear the new mOnTuneListener,
- // we copy mOnTuneListener to l and clear mOnTuneListener before
- // calling onTuneFailed.
- OnTuneListener listener = mOnTuneListener;
- mOnTuneListener = null;
- listener.onTuneFailed(channel);
- }
- }
+ private final TvInputCallback mCallback = new TvInputCallback() {
+ @Override
+ public void onConnectionFailed(String inputId) {
+ Log.w(TAG, "Failed to bind an input");
+ mTracker.sendInputConnectionFailure(inputId);
+ Channel channel = mCurrentChannel;
+ mCurrentChannel = null;
+ mInputInfo = null;
+ mCanReceiveInputEvent = false;
+ if (mOnTuneListener != null) {
+ // If tune is called inside onTuneFailed, mOnTuneListener will be set to
+ // a new instance. In order to avoid to clear the new mOnTuneListener,
+ // we copy mOnTuneListener to l and clear mOnTuneListener before
+ // calling onTuneFailed.
+ OnTuneListener listener = mOnTuneListener;
+ mOnTuneListener = null;
+ listener.onTuneFailed(channel);
+ }
+ }
- @Override
- public void onDisconnected(String inputId) {
- Log.w(TAG, "Session is released by crash");
- mTracker.sendInputDisconnected(inputId);
- Channel channel = mCurrentChannel;
- mCurrentChannel = null;
- mInputInfo = null;
- mCanReceiveInputEvent = false;
- if (mOnTuneListener != null) {
- OnTuneListener listener = mOnTuneListener;
- mOnTuneListener = null;
- listener.onUnexpectedStop(channel);
- }
- }
+ @Override
+ public void onDisconnected(String inputId) {
+ Log.w(TAG, "Session is released by crash");
+ mTracker.sendInputDisconnected(inputId);
+ Channel channel = mCurrentChannel;
+ mCurrentChannel = null;
+ mInputInfo = null;
+ mCanReceiveInputEvent = false;
+ if (mOnTuneListener != null) {
+ OnTuneListener listener = mOnTuneListener;
+ mOnTuneListener = null;
+ listener.onUnexpectedStop(channel);
+ }
+ }
- @Override
- public void onChannelRetuned(String inputId, Uri channelUri) {
- if (DEBUG) {
- Log.d(TAG, "onChannelRetuned(inputId=" + inputId + ", channelUri="
- + channelUri + ")");
- }
- if (mOnTuneListener != null) {
- mOnTuneListener.onChannelRetuned(channelUri);
- }
- }
+ @Override
+ public void onChannelRetuned(String inputId, Uri channelUri) {
+ if (DEBUG) {
+ Log.d(TAG, "onChannelRetuned(inputId=" + inputId + ", channelUri="
+ + channelUri + ")");
+ }
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onChannelRetuned(channelUri);
+ }
+ }
- @Override
- public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) {
- mHasClosedCaption = false;
- for (TvTrackInfo track : tracks) {
- if (track.getType() == TvTrackInfo.TYPE_SUBTITLE) {
- mHasClosedCaption = true;
- break;
- }
- }
- if (mOnTuneListener != null) {
- mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
- }
+ @Override
+ public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) {
+ mHasClosedCaption = false;
+ for (TvTrackInfo track : tracks) {
+ if (track.getType() == TvTrackInfo.TYPE_SUBTITLE) {
+ mHasClosedCaption = true;
+ break;
}
+ }
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
+ }
+ }
- @Override
- public void onTrackSelected(String inputId, int type, String trackId) {
- if (trackId == null) {
- // A track is unselected.
- if (type == TvTrackInfo.TYPE_VIDEO) {
- mVideoWidth = 0;
- mVideoHeight = 0;
- mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN;
- mVideoFrameRate = 0f;
- mVideoDisplayAspectRatio = 0f;
- } else if (type == TvTrackInfo.TYPE_AUDIO) {
- mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN;
- }
- } else {
- List<TvTrackInfo> tracks = getTracks(type);
- boolean trackFound = false;
- if (tracks != null) {
- for (TvTrackInfo track : tracks) {
- if (track.getId().equals(trackId)) {
- if (type == TvTrackInfo.TYPE_VIDEO) {
- mVideoWidth = track.getVideoWidth();
- mVideoHeight = track.getVideoHeight();
- mVideoFormat = Utils.getVideoDefinitionLevelFromSize(
- mVideoWidth, mVideoHeight);
- mVideoFrameRate = track.getVideoFrameRate();
- if (mVideoWidth <= 0 || mVideoHeight <= 0) {
- mVideoDisplayAspectRatio = 0.0f;
- } else if (android.os.Build.VERSION.SDK_INT >=
- android.os.Build.VERSION_CODES.M) {
- float VideoPixelAspectRatio =
- track.getVideoPixelAspectRatio();
- mVideoDisplayAspectRatio = VideoPixelAspectRatio
- * mVideoWidth / mVideoHeight;
- } else {
- mVideoDisplayAspectRatio = mVideoWidth
- / (float) mVideoHeight;
- }
- } else if (type == TvTrackInfo.TYPE_AUDIO) {
- mAudioChannelCount = track.getAudioChannelCount();
- }
- trackFound = true;
- break;
+ @Override
+ public void onTrackSelected(String inputId, int type, String trackId) {
+ if (trackId == null) {
+ // A track is unselected.
+ if (type == TvTrackInfo.TYPE_VIDEO) {
+ mVideoWidth = 0;
+ mVideoHeight = 0;
+ mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN;
+ mVideoFrameRate = 0f;
+ mVideoDisplayAspectRatio = 0f;
+ } else if (type == TvTrackInfo.TYPE_AUDIO) {
+ mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN;
+ }
+ } else {
+ List<TvTrackInfo> tracks = getTracks(type);
+ boolean trackFound = false;
+ if (tracks != null) {
+ for (TvTrackInfo track : tracks) {
+ if (track.getId().equals(trackId)) {
+ if (type == TvTrackInfo.TYPE_VIDEO) {
+ mVideoWidth = track.getVideoWidth();
+ mVideoHeight = track.getVideoHeight();
+ mVideoFormat = Utils.getVideoDefinitionLevelFromSize(
+ mVideoWidth, mVideoHeight);
+ mVideoFrameRate = track.getVideoFrameRate();
+ if (mVideoWidth <= 0 || mVideoHeight <= 0) {
+ mVideoDisplayAspectRatio = 0.0f;
+ } else {
+ float VideoPixelAspectRatio =
+ track.getVideoPixelAspectRatio();
+ mVideoDisplayAspectRatio = VideoPixelAspectRatio
+ * mVideoWidth / mVideoHeight;
}
+ } else if (type == TvTrackInfo.TYPE_AUDIO) {
+ mAudioChannelCount = track.getAudioChannelCount();
}
+ trackFound = true;
+ break;
}
- if (!trackFound) {
- Log.w(TAG, "Invalid track ID: " + trackId);
- }
- }
- if (mOnTuneListener != null) {
- mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
}
}
-
- @Override
- public void onVideoAvailable(String inputId) {
- unhideScreenByVideoAvailability();
- if (mOnTuneListener != null) {
- mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
- }
+ if (!trackFound) {
+ Log.w(TAG, "Invalid track ID: " + trackId);
}
+ }
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
+ }
+ }
- @Override
- public void onVideoUnavailable(String inputId, int reason) {
- hideScreenByVideoAvailability(reason);
- if (mOnTuneListener != null) {
- mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
- }
- switch (reason) {
- case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING:
- case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN:
- case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL:
- mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason);
- default:
- // do nothing
- }
- }
+ @Override
+ public void onVideoAvailable(String inputId) {
+ unhideScreenByVideoAvailability();
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
+ }
+ }
- @Override
- public void onContentAllowed(String inputId) {
- mBlockScreenForTuneView.setVisibility(View.GONE);
- unblockScreenByContentRating();
- if (mOnTuneListener != null) {
- mOnTuneListener.onContentAllowed();
- }
- }
+ @Override
+ public void onVideoUnavailable(String inputId, int reason) {
+ hideScreenByVideoAvailability(inputId, reason);
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onStreamInfoChanged(TunableTvView.this);
+ }
+ switch (reason) {
+ case TvInputManager.VIDEO_UNAVAILABLE_REASON_BUFFERING:
+ case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN:
+ case TvInputManager.VIDEO_UNAVAILABLE_REASON_WEAK_SIGNAL:
+ mTracker.sendChannelVideoUnavailable(mCurrentChannel, reason);
+ default:
+ // do nothing
+ }
+ }
- @Override
- public void onContentBlocked(String inputId, TvContentRating rating) {
- blockScreenByContentRating(rating);
- if (mOnTuneListener != null) {
- mOnTuneListener.onContentBlocked();
- }
- }
+ @Override
+ public void onContentAllowed(String inputId) {
+ mBlockScreenForTuneView.setVisibility(View.GONE);
+ unblockScreenByContentRating();
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onContentAllowed();
+ }
+ }
- @Override
- @TargetApi(Build.VERSION_CODES.M)
- public void onTimeShiftStatusChanged(String inputId, int status) {
- boolean available = status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE;
- setTimeShiftAvailable(available);
- }
- };
+ @Override
+ public void onContentBlocked(String inputId, TvContentRating rating) {
+ blockScreenByContentRating(rating);
+ if (mOnTuneListener != null) {
+ mOnTuneListener.onContentBlocked();
+ }
+ }
+
+ @Override
+ public void onTimeShiftStatusChanged(String inputId, int status) {
+ boolean available = status == TvInputManager.TIME_SHIFT_STATUS_AVAILABLE;
+ setTimeShiftAvailable(available);
+ }
+ };
public TunableTvView(Context context) {
this(context, null);
@@ -360,10 +349,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
inflate(getContext(), R.layout.tunable_tv_view, this);
ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
- mDvrDataManager = CommonFeatures.DVR.isEnabled(context) && BuildCompat.isAtLeastN()
- ? appSingletons.getDvrDataManager()
- : null;
- mChannelDataManager = appSingletons.getChannelDataManager();
+ if (CommonFeatures.DVR.isEnabled(context)) {
+ mInputSessionManager = appSingletons.getInputSessionManager();
+ } else {
+ mInputSessionManager = null;
+ }
+ mInputManager = appSingletons.getTvInputManagerHelper();
mConnectivityManager = (ConnectivityManager) context
.getSystemService(Context.CONNECTIVITY_SERVICE);
mCanModifyParentalControls = PermissionUtils.hasModifyParentalControls(context);
@@ -409,6 +400,11 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
public void initialize(AppLayerTvView tvView, boolean isPip, int screenHeight,
int shrunkenTvViewHeight) {
mTvView = tvView;
+ if (mInputSessionManager != null) {
+ mTvViewSession = mInputSessionManager.createTvViewSession(tvView, this, mCallback);
+ } else {
+ mTvView.setCallback(mCallback);
+ }
mIsPip = isPip;
mScreenHeight = screenHeight;
mShrunkenTvViewHeight = shrunkenTvViewHeight;
@@ -425,6 +421,20 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
mStarted = true;
}
+ /**
+ * Warms up the input to reduce the start time.
+ */
+ public void warmUpInput(String inputId, Uri channelUri) {
+ if (!mStarted && inputId != null && channelUri != null) {
+ if (mTvViewSession != null) {
+ mTvViewSession.tune(inputId, channelUri);
+ } else {
+ mTvView.tune(inputId, channelUri);
+ }
+ hideScreenByVideoAvailability(inputId, TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
+ }
+ }
+
public void stop() {
if (!mStarted) {
return;
@@ -441,15 +451,42 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
reset();
}
+ /**
+ * Releases the resources.
+ */
+ public void release() {
+ if (mInputSessionManager != null) {
+ mInputSessionManager.releaseTvViewSession(mTvViewSession);
+ mTvViewSession = null;
+ }
+ }
+
+ /**
+ * Reset TV view.
+ */
public void reset() {
- mTvView.reset();
+ resetInternal();
+ hideScreenByVideoAvailability(null, VIDEO_UNAVAILABLE_REASON_NOT_TUNED);
+ }
+
+ /**
+ * Reset TV view to acquire the recording session.
+ */
+ public void resetByRecording() {
+ resetInternal();
+ }
+
+ private void resetInternal() {
+ if (mTvViewSession != null) {
+ mTvViewSession.reset();
+ } else {
+ mTvView.reset();
+ }
mCurrentChannel = null;
- mRecordedProgram = null;
mInputInfo = null;
mCanReceiveInputEvent = false;
mOnTuneListener = null;
setTimeShiftAvailable(false);
- hideScreenByVideoAvailability(VIDEO_UNAVAILABLE_REASON_NOT_TUNED);
}
public void setMain() {
@@ -475,85 +512,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
/**
- * Returns {@code true}, if this view is the recording playback mode.
- */
- public boolean isRecordingPlayback() {
- return mRecordedProgram != null;
- }
-
- /**
- * Returns the recording which is being played right now.
- */
- public RecordedProgram getPlayingRecordedProgram() {
- return mRecordedProgram;
- }
-
- /**
- * Plays a recording.
- */
- public boolean playRecording(Uri recordingUri, OnTuneListener listener) {
- if (!mStarted) {
- throw new IllegalStateException("TvView isn't started");
- }
- if (!CommonFeatures.DVR.isEnabled(getContext()) || !BuildCompat.isAtLeastN()) {
- return false;
- }
- if (DEBUG) Log.d(TAG, "playRecording " + recordingUri);
- long recordingId = ContentUris.parseId(recordingUri);
- mRecordedProgram = mDvrDataManager.getRecordedProgram(recordingId);
- if (mRecordedProgram == null) {
- Log.w(TAG, "No recorded program (Uri=" + recordingUri + ")");
- return false;
- }
- String inputId = mRecordedProgram.getInputId();
- TvInputInfo inputInfo = mInputManagerHelper.getTvInputInfo(inputId);
- if (inputInfo == null) {
- return false;
- }
- mOnTuneListener = listener;
- // mCurrentChannel can be null.
- mCurrentChannel = mChannelDataManager.getChannel(mRecordedProgram.getChannelId());
- // For recording playback, input event should not be sent.
- mCanReceiveInputEvent = false;
- boolean needSurfaceSizeUpdate = false;
- if (!inputInfo.equals(mInputInfo)) {
- mInputInfo = inputInfo;
- if (DEBUG) {
- Log.d(TAG, "Input \'" + mInputInfo.getId() + "\' can receive input event: "
- + mCanReceiveInputEvent);
- }
- needSurfaceSizeUpdate = true;
- }
- mChannelViewTimer.start();
- mVideoWidth = 0;
- mVideoHeight = 0;
- mVideoFormat = StreamInfo.VIDEO_DEFINITION_LEVEL_UNKNOWN;
- mVideoFrameRate = 0f;
- mVideoDisplayAspectRatio = 0f;
- mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN;
- mHasClosedCaption = false;
- mTvView.setCallback(mCallback);
- mTimeShiftCurrentPositionMs = INVALID_TIME;
- mTvView.setTimeShiftPositionCallback(null);
- setTimeShiftAvailable(false);
- mTvView.timeShiftPlay(inputId, recordingUri);
- if (needSurfaceSizeUpdate && mFixedSurfaceWidth > 0 && mFixedSurfaceHeight > 0) {
- // When the input is changed, TvView recreates its SurfaceView internally.
- // So we need to call SurfaceHolder.setFixedSize for the new SurfaceView.
- getSurfaceView().getHolder().setFixedSize(mFixedSurfaceWidth, mFixedSurfaceHeight);
- }
- hideScreenByVideoAvailability(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
- unblockScreenByContentRating();
- if (mParentControlEnabled) {
- mBlockScreenForTuneView.setVisibility(View.VISIBLE);
- }
- if (mOnTuneListener != null) {
- mOnTuneListener.onStreamInfoChanged(this);
- }
- return true;
- }
-
- /**
* Tunes to a channel with the {@code channelId}.
*
* @param params extra data to send it to TIS and store the data in TIMS.
@@ -579,7 +537,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
mOnTuneListener = listener;
mCurrentChannel = channel;
- mRecordedProgram = null;
boolean tunedByRecommendation = params != null
&& params.getString(NotificationService.TUNE_PARAMS_RECOMMENDATION_TYPE) != null;
boolean needSurfaceSizeUpdate = false;
@@ -603,20 +560,22 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
mVideoDisplayAspectRatio = 0f;
mAudioChannelCount = StreamInfo.AUDIO_CHANNEL_COUNT_UNKNOWN;
mHasClosedCaption = false;
- mTvView.setCallback(mCallback);
- mTimeShiftCurrentPositionMs = INVALID_TIME;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- // To reduce the IPCs, unregister the callback here and register it when necessary.
- mTvView.setTimeShiftPositionCallback(null);
- }
+ mTimeShiftCurrentPositionMs = TvInputManager.TIME_SHIFT_INVALID_TIME;
+ // To reduce the IPCs, unregister the callback here and register it when necessary.
+ mTvView.setTimeShiftPositionCallback(null);
setTimeShiftAvailable(false);
- mTvView.tune(mInputInfo.getId(), mCurrentChannel.getUri(), params);
if (needSurfaceSizeUpdate && mFixedSurfaceWidth > 0 && mFixedSurfaceHeight > 0) {
// When the input is changed, TvView recreates its SurfaceView internally.
// So we need to call SurfaceHolder.setFixedSize for the new SurfaceView.
getSurfaceView().getHolder().setFixedSize(mFixedSurfaceWidth, mFixedSurfaceHeight);
}
- hideScreenByVideoAvailability(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
+ hideScreenByVideoAvailability(mInputInfo.getId(),
+ TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
+ if (mTvViewSession != null) {
+ mTvViewSession.tune(channel, params, listener);
+ } else {
+ mTvView.tune(mInputInfo.getId(), mCurrentChannel.getUri(), params);
+ }
unblockScreenByContentRating();
if (channel.isPassthrough()) {
mBlockScreenForTuneView.setVisibility(View.GONE);
@@ -709,17 +668,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
public void unblockContent(TvContentRating rating) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- try {
- Method method = TvView.class.getMethod("requestUnblockContent",
- TvContentRating.class);
- method.invoke(mTvView, rating);
- } catch (NoSuchMethodException|IllegalAccessException|InvocationTargetException e) {
- e.printStackTrace();
- }
- } else {
- mTvView.unblockContent(rating);
- }
+ mTvView.unblockContent(rating);
}
@Override
@@ -811,6 +760,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
/**
* Returns currently blocked content rating. {@code null} if it's not blocked.
*/
+ @Override
public TvContentRating getBlockedContentRating() {
return mBlockedContentRating;
}
@@ -869,11 +819,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
|| tvViewLp.gravity != lp.gravity
|| tvViewLp.height != lp.height
|| tvViewLp.width != lp.width) {
- if (lp.topMargin == tvViewLp.topMargin && lp.leftMargin == tvViewLp.leftMargin) {
+ if (lp.topMargin == tvViewLp.topMargin && lp.leftMargin == tvViewLp.leftMargin
+ && !BuildCompat.isAtLeastN()) {
// HACK: If top and left position aren't changed and SurfaceHolder.setFixedSize is
// used, SurfaceView doesn't catch the width and height change. It causes a bug that
// PIP size change isn't shown when PIP is located TOP|LEFT. So we adjust 1 px for
// small size PIP as a workaround.
+ // Note: This framework issue has been fixed from NYC.
tvViewLp.leftMargin = lp.leftMargin + 1;
} else {
tvViewLp.leftMargin = lp.leftMargin;
@@ -889,7 +841,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
@Override
- protected void onVisibilityChanged(View changedView, int visibility) {
+ protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
if (mTvView != null) {
mTvView.setVisibility(visibility);
@@ -1024,7 +976,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
@UiThread
- private void hideScreenByVideoAvailability(int reason) {
+ private void hideScreenByVideoAvailability(String inputId, int reason) {
mVideoAvailable = false;
mVideoUnavailableReason = reason;
if (mInternetCheckTask != null) {
@@ -1050,6 +1002,12 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
mute();
break;
case TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING:
+ mHideScreenView.setVisibility(VISIBLE);
+ mHideScreenView.setImageVisibility(false);
+ mHideScreenView.setText(null);
+ mBufferingSpinnerView.setVisibility(VISIBLE);
+ mute();
+ break;
case VIDEO_UNAVAILABLE_REASON_NOT_TUNED:
mHideScreenView.setVisibility(VISIBLE);
mHideScreenView.setImageVisibility(false);
@@ -1057,6 +1015,13 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
mBufferingSpinnerView.setVisibility(GONE);
mute();
break;
+ case VIDEO_UNAVAILABLE_REASON_NO_RESOURCE:
+ mHideScreenView.setVisibility(VISIBLE);
+ mHideScreenView.setImageVisibility(false);
+ mHideScreenView.setText(getTuneConflictMessage(inputId));
+ mBufferingSpinnerView.setVisibility(GONE);
+ mute();
+ break;
case TvInputManager.VIDEO_UNAVAILABLE_REASON_UNKNOWN:
default:
mHideScreenView.setVisibility(VISIBLE);
@@ -1072,6 +1037,19 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
}
+ private String getTuneConflictMessage(String inputId) {
+ if (inputId != null) {
+ TvInputInfo input = mInputManager.getTvInputInfo(inputId);
+ Long timeMs = mInputSessionManager.getEarliestRecordingSessionEndTimeMs(inputId);
+ if (timeMs != null) {
+ return getResources().getQuantityString(R.plurals.tvview_msg_input_no_resource,
+ input.getTunerCount(),
+ DateUtils.formatDateTime(getContext(), timeMs, DateUtils.FORMAT_SHOW_TIME));
+ }
+ }
+ return null;
+ }
+
private void unhideScreenByVideoAvailability() {
mVideoAvailable = true;
mHideScreenView.setVisibility(GONE);
@@ -1166,7 +1144,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
private void setTimeShiftAvailable(boolean isTimeShiftAvailable) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || mTimeShiftAvailable == isTimeShiftAvailable) {
+ if (mTimeShiftAvailable == isTimeShiftAvailable) {
return;
}
mTimeShiftAvailable = isTimeShiftAvailable;
@@ -1201,23 +1179,9 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
}
/**
- * Returns the current time-shift state. It returns one of {@link #TIME_SHIFT_STATE_NONE},
- * {@link #TIME_SHIFT_STATE_PLAY}, {@link #TIME_SHIFT_STATE_PAUSE},
- * {@link #TIME_SHIFT_STATE_REWIND}, {@link #TIME_SHIFT_STATE_FAST_FORWARD}
- * or {@link #TIME_SHIFT_STATE_PAUSE}.
- */
- @TimeShiftState public int getTimeShiftState() {
- return mTimeShiftState;
- }
-
- /**
* Plays the media, if the current input supports time-shifting.
*/
public void timeshiftPlay() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- return;
- }
if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
}
@@ -1231,10 +1195,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
* Pauses the media, if the current input supports time-shifting.
*/
public void timeshiftPause() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- return;
- }
if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
}
@@ -1250,9 +1210,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
* @param speed The speed to rewind the media. e.g. 2 for 2x, 3 for 3x and 4 for 4x.
*/
public void timeshiftRewind(int speed) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- } else if (!isTimeShiftAvailable()) {
+ if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
} else {
if (speed <= 0) {
@@ -1271,9 +1229,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
* @param speed The speed to forward the media. e.g. 2 for 2x, 3 for 3x and 4 for 4x.
*/
public void timeshiftFastForward(int speed) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- } else if (!isTimeShiftAvailable()) {
+ if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
} else {
if (speed <= 0) {
@@ -1292,10 +1248,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
* @param timeMs The time in milliseconds to seek to.
*/
public void timeshiftSeekTo(long timeMs) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- return;
- }
if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
}
@@ -1306,10 +1258,6 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
* Returns the current playback position in milliseconds.
*/
public long timeshiftGetCurrentPositionMs() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- Log.w(TAG, "Time shifting is not supported in this platform.");
- return INVALID_TIME;
- }
if (!isTimeShiftAvailable()) {
throw new IllegalStateException("Time-shift is not supported for the current channel");
}
@@ -1332,6 +1280,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
/**
* Called when the record start time has been changed.
+ * This is not called when the recorded programs is played.
*/
public abstract void onRecordStartTimeChanged(long recordStartTimeMs);
}
@@ -1346,7 +1295,7 @@ public class TunableTvView extends FrameLayout implements StreamInfo {
public abstract void onScreenBlockingChanged(boolean blocked);
}
- public class InternetCheckTask extends AsyncTask<Void, Void, Boolean> {
+ private class InternetCheckTask extends AsyncTask<Void, Void, Boolean> {
@Override
protected Boolean doInBackground(Void... params) {
return NetworkUtils.isNetworkAvailable(mConnectivityManager);
diff --git a/src/com/android/tv/ui/TvOverlayManager.java b/src/com/android/tv/ui/TvOverlayManager.java
index 94f9b0f9..e14b286b 100644
--- a/src/com/android/tv/ui/TvOverlayManager.java
+++ b/src/com/android/tv/ui/TvOverlayManager.java
@@ -21,22 +21,16 @@ import android.app.FragmentManager;
import android.app.FragmentManager.OnBackStackChangedListener;
import android.content.Intent;
import android.media.tv.TvInputInfo;
-import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
-import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
-import android.support.v4.os.BuildCompat;
import android.util.Log;
import android.view.Gravity;
import android.view.KeyEvent;
-import android.view.LayoutInflater;
-import android.view.View;
import android.view.ViewGroup;
-import android.widget.Space;
import com.android.tv.ApplicationSingletons;
import com.android.tv.ChannelTuner;
@@ -66,29 +60,30 @@ import com.android.tv.menu.MenuRowFactory;
import com.android.tv.menu.MenuView;
import com.android.tv.onboarding.NewSourcesFragment;
import com.android.tv.onboarding.SetupSourcesFragment;
-import com.android.tv.onboarding.SetupSourcesFragment.InputSetupRunnable;
import com.android.tv.search.ProgramGuideSearchFragment;
import com.android.tv.ui.TvTransitionManager.SceneType;
import com.android.tv.ui.sidepanel.SettingsFragment;
import com.android.tv.ui.sidepanel.SideFragmentManager;
import com.android.tv.ui.sidepanel.parentalcontrols.RatingsFragment;
+import com.android.tv.util.TvInputManagerHelper;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.HashSet;
+import java.util.LinkedList;
import java.util.List;
+import java.util.Queue;
import java.util.Set;
/**
* A class responsible for the life cycle and event handling of the pop-ups over TV view.
*/
-// TODO: Put TvTransitionManager into this class.
@UiThread
public class TvOverlayManager {
private static final String TAG = "TvOverlayManager";
private static final boolean DEBUG = false;
- public static final String INTRO_TRACKER_LABEL = "Intro dialog";
+ private static final String INTRO_TRACKER_LABEL = "Intro dialog";
@Retention(RetentionPolicy.SOURCE)
@IntDef(flag = true,
@@ -97,7 +92,7 @@ public class TvOverlayManager {
FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANELS, FLAG_HIDE_OVERLAYS_KEEP_SIDE_PANEL_HISTORY,
FLAG_HIDE_OVERLAYS_KEEP_PROGRAM_GUIDE, FLAG_HIDE_OVERLAYS_KEEP_MENU,
FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT})
- public @interface HideOverlayFlag {}
+ private @interface HideOverlayFlag {}
// FLAG_HIDE_OVERLAYs must be bitwise exclusive.
public static final int FLAG_HIDE_OVERLAYS_DEFAULT = 0b000000000;
public static final int FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION = 0b000000010;
@@ -109,7 +104,7 @@ public class TvOverlayManager {
public static final int FLAG_HIDE_OVERLAYS_KEEP_MENU = 0b010000000;
public static final int FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT = 0b100000000;
- public static final int MSG_OVERLAY_CLOSED = 1000;
+ private static final int MSG_OVERLAY_CLOSED = 1000;
@Retention(RetentionPolicy.SOURCE)
@IntDef(flag = true,
@@ -119,16 +114,51 @@ public class TvOverlayManager {
OVERLAY_TYPE_SCENE_SELECT_INPUT, OVERLAY_TYPE_FRAGMENT})
private @interface TvOverlayType {}
// OVERLAY_TYPEs must be bitwise exclusive.
+ /**
+ * The overlay type which indicates that there are no overlays.
+ */
private static final int OVERLAY_TYPE_NONE = 0b000000000;
+ /**
+ * The overlay type for menu.
+ */
private static final int OVERLAY_TYPE_MENU = 0b000000001;
+ /**
+ * The overlay type for the side fragment.
+ */
private static final int OVERLAY_TYPE_SIDE_FRAGMENT = 0b000000010;
+ /**
+ * The overlay type for dialog fragment.
+ */
private static final int OVERLAY_TYPE_DIALOG = 0b000000100;
+ /**
+ * The overlay type for program guide.
+ */
private static final int OVERLAY_TYPE_GUIDE = 0b000001000;
+ /**
+ * The overlay type for channel banner.
+ */
private static final int OVERLAY_TYPE_SCENE_CHANNEL_BANNER = 0b000010000;
+ /**
+ * The overlay type for input banner.
+ */
private static final int OVERLAY_TYPE_SCENE_INPUT_BANNER = 0b000100000;
+ /**
+ * The overlay type for keypad channel switch view.
+ */
private static final int OVERLAY_TYPE_SCENE_KEYPAD_CHANNEL_SWITCH = 0b001000000;
+ /**
+ * The overlay type for select input view.
+ */
private static final int OVERLAY_TYPE_SCENE_SELECT_INPUT = 0b010000000;
+ /**
+ * The overlay type for fragment other than the side fragment and dialog fragment.
+ */
private static final int OVERLAY_TYPE_FRAGMENT = 0b100000000;
+ // Used for the padded print of the overlay type.
+ private static final int NUM_OVERLAY_TYPES = 9;
+
+ private static final String FRAGMENT_TAG_SETUP_SOURCES = "tag_setup_sources";
+ private static final String FRAGMENT_TAG_NEW_SOURCES = "tag_new_sources";
private static final Set<String> AVAILABLE_DIALOG_TAGS = new HashSet<>();
static {
@@ -144,6 +174,7 @@ public class TvOverlayManager {
private final ChannelTuner mChannelTuner;
private final TvTransitionManager mTransitionManager;
private final ChannelDataManager mChannelDataManager;
+ private final TvInputManagerHelper mInputManager;
private final Menu mMenu;
private final SideFragmentManager mSideFragmentManager;
private final ProgramGuide mProgramGuide;
@@ -152,18 +183,19 @@ public class TvOverlayManager {
private final ProgramGuideSearchFragment mSearchFragment;
private final Tracker mTracker;
private SafeDismissDialogFragment mCurrentDialog;
- private final SetupSourcesFragment mSetupFragment;
private boolean mSetupFragmentActive;
- private final NewSourcesFragment mNewSourcesFragment;
private boolean mNewSourcesFragmentActive;
private final Handler mHandler = new TvOverlayHandler(this);
private @TvOverlayType int mOpenedOverlays;
private final List<Runnable> mPendingActions = new ArrayList<>();
+ private final Queue<PendingDialogAction> mPendingDialogActionQueue = new LinkedList<>();
+
+ private OnBackStackChangedListener mOnBackStackChangedListener;
public TvOverlayManager(MainActivity mainActivity, ChannelTuner channelTuner,
- KeypadChannelSwitchView keypadChannelSwitchView,
+ TunableTvView tvView, KeypadChannelSwitchView keypadChannelSwitchView,
ChannelBannerView channelBannerView, InputBannerView inputBannerView,
SelectInputView selectInputView, ViewGroup sceneContainer,
ProgramGuideSearchFragment searchFragment) {
@@ -171,6 +203,7 @@ public class TvOverlayManager {
mChannelTuner = channelTuner;
ApplicationSingletons singletons = TvApplication.getSingletons(mainActivity);
mChannelDataManager = singletons.getChannelDataManager();
+ mInputManager = singletons.getTvInputManagerHelper();
mKeypadChannelSwitchView = keypadChannelSwitchView;
mSelectInputView = selectInputView;
mSearchFragment = searchFragment;
@@ -180,8 +213,8 @@ public class TvOverlayManager {
mTransitionManager.setListener(new TvTransitionManager.Listener() {
@Override
public void onSceneChanged(int fromScene, int toScene) {
- // Call notifyOverlayOpened first so that the listener can know that a new scene
- // will be opened when the notifyOverlayClosed is called.
+ // Call onOverlayOpened first so that the listener can know that a new scene
+ // will be opened when the onOverlayClosed is called.
if (toScene != TvTransitionManager.SCENE_TYPE_EMPTY) {
onOverlayOpened(convertSceneToOverlayType(toScene));
}
@@ -192,7 +225,7 @@ public class TvOverlayManager {
});
// Menu
MenuView menuView = (MenuView) mainActivity.findViewById(R.id.menu);
- mMenu = new Menu(mainActivity, menuView, new MenuRowFactory(mainActivity),
+ mMenu = new Menu(mainActivity, tvView, menuView, new MenuRowFactory(mainActivity, tvView),
new Menu.OnMenuVisibilityChangeListener() {
@Override
public void onMenuVisibilityChange(boolean visible) {
@@ -203,6 +236,7 @@ public class TvOverlayManager {
}
}
});
+ mMenu.setChannelTuner(mChannelTuner);
// Side Fragment
mSideFragmentManager = new SideFragmentManager(mainActivity,
new Runnable() {
@@ -232,49 +266,49 @@ public class TvOverlayManager {
onOverlayClosed(OVERLAY_TYPE_GUIDE);
}
};
- DvrDataManager dvrDataManager =
- CommonFeatures.DVR.isEnabled(mainActivity) && BuildCompat.isAtLeastN() ? singletons
- .getDvrDataManager() : null;
+ DvrDataManager dvrDataManager = CommonFeatures.DVR.isEnabled(mainActivity)
+ ? singletons.getDvrDataManager() : null;
mProgramGuide = new ProgramGuide(mainActivity, channelTuner,
singletons.getTvInputManagerHelper(), mChannelDataManager,
- singletons.getProgramDataManager(), dvrDataManager, singletons.getTracker(),
- preShowRunnable,
+ singletons.getProgramDataManager(), dvrDataManager,
+ singletons.getDvrScheduleManager(), singletons.getTracker(), preShowRunnable,
postHideRunnable);
- mSetupFragment = new SetupSourcesFragment();
- mSetupFragment.setOnActionClickListener(new OnActionClickListener() {
- @Override
- public void onActionClick(String category, int id) {
- switch (id) {
- case SetupMultiPaneFragment.ACTION_DONE:
- closeSetupFragment(true);
- break;
- case SetupSourcesFragment.ACTION_PLAY_STORE:
- mMainActivity.showMerchantCollection();
- break;
- }
- }
- });
- mSetupFragment.setInputSetupRunnable(new InputSetupRunnable() {
- @Override
- public void runInputSetup(TvInputInfo input) {
- mMainActivity.startSetupActivity(input, true);
- }
- });
- mNewSourcesFragment = new NewSourcesFragment();
- mNewSourcesFragment.setOnActionClickListener(new OnActionClickListener() {
+ mMainActivity.addOnActionClickListener(new OnActionClickListener() {
@Override
- public void onActionClick(String category, int id) {
- switch (id) {
- case NewSourcesFragment.ACTION_SETUP:
- closeNewSourcesFragment(false);
- showSetupFragment();
+ public boolean onActionClick(String category, int id, Bundle params) {
+ switch (category) {
+ case SetupSourcesFragment.ACTION_CATEGORY:
+ switch (id) {
+ case SetupMultiPaneFragment.ACTION_DONE:
+ closeSetupFragment(true);
+ return true;
+ case SetupSourcesFragment.ACTION_ONLINE_STORE:
+ mMainActivity.showMerchantCollection();
+ return true;
+ case SetupSourcesFragment.ACTION_SETUP_INPUT: {
+ String inputId = params.getString(
+ SetupSourcesFragment.ACTION_PARAM_KEY_INPUT_ID);
+ TvInputInfo input = mInputManager.getTvInputInfo(inputId);
+ mMainActivity.startSetupActivity(input, true);
+ return true;
+ }
+ }
break;
- case NewSourcesFragment.ACTION_SKIP:
- // Don't remove the fragment because new fragment will be replaced with
- // this fragment.
- closeNewSourcesFragment(true);
+ case NewSourcesFragment.ACTION_CATEOGRY:
+ switch (id) {
+ case NewSourcesFragment.ACTION_SETUP:
+ closeNewSourcesFragment(false);
+ showSetupFragment();
+ return true;
+ case NewSourcesFragment.ACTION_SKIP:
+ // Don't remove the fragment because new fragment will be replaced
+ // with this fragment.
+ closeNewSourcesFragment(true);
+ return true;
+ }
break;
}
+ return false;
}
});
}
@@ -313,16 +347,29 @@ public class TvOverlayManager {
* Checks whether the setup fragment is active or not.
*/
public boolean isSetupFragmentActive() {
+ // "getSetupSourcesFragment() != null" doesn't return the correct state. That's because,
+ // when we call showSetupFragment(), we need to put off showing the fragment until the side
+ // fragment is closed. Until then, getSetupSourcesFragment() returns null. So we need
+ // to keep additional variable which indicates if showSetupFragment() is called.
return mSetupFragmentActive;
}
+ private Fragment getSetupSourcesFragment() {
+ return mMainActivity.getFragmentManager().findFragmentByTag(FRAGMENT_TAG_SETUP_SOURCES);
+ }
+
/**
* Checks whether the new sources fragment is active or not.
*/
public boolean isNewSourcesFragmentActive() {
+ // See the comment in "isSetupFragmentActive".
return mNewSourcesFragmentActive;
}
+ private Fragment getNewSourcesFragment() {
+ return mMainActivity.getFragmentManager().findFragmentByTag(FRAGMENT_TAG_NEW_SOURCES);
+ }
+
/**
* Returns the instance of {@link ProgramGuide}.
*/
@@ -373,9 +420,10 @@ public class TvOverlayManager {
return;
}
- Fragment old = mMainActivity.getFragmentManager().findFragmentByTag(tag);
- // Do not show the dialog if the same kind of dialog is already opened.
- if (old != null) {
+ // Do not open two dialogs at the same time.
+ if (mCurrentDialog != null) {
+ mPendingDialogActionQueue.offer(new PendingDialogAction(tag, dialog,
+ keepSidePanelHistory, keepProgramGuide));
return;
}
@@ -389,43 +437,52 @@ public class TvOverlayManager {
}
private void runAfterSideFragmentsAreClosed(final Runnable runnable) {
- final FragmentManager manager = mMainActivity.getFragmentManager();
if (mSideFragmentManager.isSidePanelVisible()) {
- manager.addOnBackStackChangedListener(new OnBackStackChangedListener() {
+ // When the side panel is closing, it closes all the fragments, so the new fragment
+ // should be opened after the side fragment becomes invisible.
+ final FragmentManager manager = mMainActivity.getFragmentManager();
+ mOnBackStackChangedListener = new OnBackStackChangedListener() {
@Override
public void onBackStackChanged() {
if (manager.getBackStackEntryCount() == 0) {
manager.removeOnBackStackChangedListener(this);
+ mOnBackStackChangedListener = null;
runnable.run();
}
}
- });
+ };
+ manager.addOnBackStackChangedListener(mOnBackStackChangedListener);
} else {
runnable.run();
}
}
- private void showFragment(final Fragment fragment) {
+ private void showFragment(final Fragment fragment, final String tag) {
hideOverlays(FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT);
onOverlayOpened(OVERLAY_TYPE_FRAGMENT);
runAfterSideFragmentsAreClosed(new Runnable() {
@Override
public void run() {
+ if (DEBUG) Log.d(TAG, "showFragment(" + fragment + ")");
mMainActivity.getFragmentManager().beginTransaction()
- .replace(R.id.fragment_container, fragment).commit();
+ .replace(R.id.fragment_container, fragment, tag).commit();
}
});
}
- private void closeFragment(Fragment fragmentToRemove) {
+ private void closeFragment(String fragmentTagToRemove) {
+ if (DEBUG) Log.d(TAG, "closeFragment(" + fragmentTagToRemove + ")");
onOverlayClosed(OVERLAY_TYPE_FRAGMENT);
- if (fragmentToRemove != null) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- // In L, NPE happens if there is no next fragment when removing or hiding a fragment
- // which has an exit transition. b/22631964
- // A workaround is just replacing with a dummy fragment.
- mMainActivity.getFragmentManager().beginTransaction()
- .replace(R.id.fragment_container, new DummyFragment()).commit();
+ if (fragmentTagToRemove != null) {
+ Fragment fragmentToRemove = mMainActivity.getFragmentManager()
+ .findFragmentByTag(fragmentTagToRemove);
+ if (fragmentToRemove == null) {
+ // If the fragment has not been added to the fragment manager yet, just remove the
+ // listener not to add the fragment. This is needed because the side fragment is
+ // closed asynchronously.
+ mMainActivity.getFragmentManager().removeOnBackStackChangedListener(
+ mOnBackStackChangedListener);
+ mOnBackStackChangedListener = null;
} else {
mMainActivity.getFragmentManager().beginTransaction().remove(fragmentToRemove)
.commit();
@@ -439,11 +496,12 @@ public class TvOverlayManager {
public void showSetupFragment() {
if (DEBUG) Log.d(TAG, "showSetupFragment");
mSetupFragmentActive = true;
- mSetupFragment.enableFragmentTransition(SetupFragment.FRAGMENT_ENTER_TRANSITION
+ SetupSourcesFragment setupFragment = new SetupSourcesFragment();
+ setupFragment.enableFragmentTransition(SetupFragment.FRAGMENT_ENTER_TRANSITION
| SetupFragment.FRAGMENT_EXIT_TRANSITION | SetupFragment.FRAGMENT_RETURN_TRANSITION
| SetupFragment.FRAGMENT_REENTER_TRANSITION);
- mSetupFragment.setFragmentTransition(SetupFragment.FRAGMENT_EXIT_TRANSITION, Gravity.END);
- showFragment(mSetupFragment);
+ setupFragment.setFragmentTransition(SetupFragment.FRAGMENT_EXIT_TRANSITION, Gravity.END);
+ showFragment(setupFragment, FRAGMENT_TAG_SETUP_SOURCES);
}
// Set removeFragment to false only when the new fragment is going to be shown.
@@ -453,8 +511,9 @@ public class TvOverlayManager {
return;
}
mSetupFragmentActive = false;
- closeFragment(removeFragment ? mSetupFragment : null);
+ closeFragment(removeFragment ? FRAGMENT_TAG_SETUP_SOURCES : null);
if (mChannelDataManager.getChannelCount() == 0) {
+ if (DEBUG) Log.d(TAG, "Finishing MainActivity because there are no channels.");
mMainActivity.finish();
}
}
@@ -465,14 +524,17 @@ public class TvOverlayManager {
public void showNewSourcesFragment() {
if (DEBUG) Log.d(TAG, "showNewSourcesFragment");
mNewSourcesFragmentActive = true;
- showFragment(mNewSourcesFragment);
+ showFragment(new NewSourcesFragment(), FRAGMENT_TAG_NEW_SOURCES);
}
// Set removeFragment to false only when the new fragment is going to be shown.
private void closeNewSourcesFragment(boolean removeFragment) {
if (DEBUG) Log.d(TAG, "closeNewSourcesFragment");
+ if (!mNewSourcesFragmentActive) {
+ return;
+ }
mNewSourcesFragmentActive = false;
- closeFragment(removeFragment ? mNewSourcesFragment : null);
+ closeFragment(removeFragment ? FRAGMENT_TAG_NEW_SOURCES : null);
}
/**
@@ -536,7 +598,12 @@ public class TvOverlayManager {
*/
public void onDialogDestroyed() {
mCurrentDialog = null;
- onOverlayClosed(OVERLAY_TYPE_DIALOG);
+ PendingDialogAction action = mPendingDialogActionQueue.poll();
+ if (action == null) {
+ onOverlayClosed(OVERLAY_TYPE_DIALOG);
+ } else {
+ action.run();
+ }
}
/**
@@ -571,18 +638,26 @@ public class TvOverlayManager {
}
mCurrentDialog.dismiss();
}
+ mPendingDialogActionQueue.clear();
mCurrentDialog = null;
}
boolean withAnimation = (flags & FLAG_HIDE_OVERLAYS_WITHOUT_ANIMATION) == 0;
if ((flags & FLAG_HIDE_OVERLAYS_KEEP_FRAGMENT) == 0) {
+ Fragment setupSourcesFragment = getSetupSourcesFragment();
+ Fragment newSourcesFragment = getNewSourcesFragment();
if (mSetupFragmentActive) {
- if (!withAnimation) {
- mSetupFragment.setReturnTransition(null);
- mSetupFragment.setExitTransition(null);
+ if (!withAnimation && setupSourcesFragment != null) {
+ setupSourcesFragment.setReturnTransition(null);
+ setupSourcesFragment.setExitTransition(null);
}
closeSetupFragment(true);
- } else if (mNewSourcesFragmentActive) {
+ }
+ if (mNewSourcesFragmentActive) {
+ if (!withAnimation && newSourcesFragment != null) {
+ newSourcesFragment.setReturnTransition(null);
+ newSourcesFragment.setExitTransition(null);
+ }
closeNewSourcesFragment(true);
}
}
@@ -642,20 +717,18 @@ public class TvOverlayManager {
}
}
- @UiThread
private void onOverlayOpened(@TvOverlayType int overlayType) {
- if (DEBUG) Log.d(TAG, "Overlay opened: 0b" + Integer.toBinaryString(overlayType));
+ if (DEBUG) Log.d(TAG, "Overlay opened: " + toBinaryString(overlayType));
mOpenedOverlays |= overlayType;
- if (DEBUG) Log.d(TAG, "Opened overlays: 0b" + Integer.toBinaryString(mOpenedOverlays));
+ if (DEBUG) Log.d(TAG, "Opened overlays: " + toBinaryString(mOpenedOverlays));
mHandler.removeMessages(MSG_OVERLAY_CLOSED);
mMainActivity.updateKeyInputFocus();
}
- @UiThread
private void onOverlayClosed(@TvOverlayType int overlayType) {
- if (DEBUG) Log.d(TAG, "Overlay closed: 0b" + Integer.toBinaryString(overlayType));
+ if (DEBUG) Log.d(TAG, "Overlay closed: " + toBinaryString(overlayType));
mOpenedOverlays &= ~overlayType;
- if (DEBUG) Log.d(TAG, "Opened overlays: 0b" + Integer.toBinaryString(mOpenedOverlays));
+ if (DEBUG) Log.d(TAG, "Opened overlays: " + toBinaryString(mOpenedOverlays));
mHandler.removeMessages(MSG_OVERLAY_CLOSED);
mMainActivity.updateKeyInputFocus();
// Show the main menu again if there are no pop-ups or banners only.
@@ -676,6 +749,11 @@ public class TvOverlayManager {
}
}
+ private String toBinaryString(int value) {
+ return String.format("0b%" + NUM_OVERLAY_TYPES + "s", Integer.toBinaryString(value))
+ .replace(' ', '0');
+ }
+
private boolean canExecuteCloseAction() {
return mMainActivity.isActivityResumed() && isOnlyBannerOrNoneOpened();
}
@@ -688,7 +766,6 @@ public class TvOverlayManager {
/**
* Runs a given {@code action} after all the overlays are closed.
*/
- @UiThread
public void runAfterOverlaysAreClosed(Runnable action) {
if (canExecuteCloseAction()) {
action.run();
@@ -783,10 +860,12 @@ public class TvOverlayManager {
showMenu(Menu.REASON_PLAY_CONTROLS_FAST_FORWARD);
break;
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD:
timeShiftManager.jumpToPrevious();
showMenu(Menu.REASON_PLAY_CONTROLS_JUMP_TO_PREVIOUS);
break;
case KeyEvent.KEYCODE_MEDIA_NEXT:
+ case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD:
timeShiftManager.jumpToNext();
showMenu(Menu.REASON_PLAY_CONTROLS_JUMP_TO_NEXT);
break;
@@ -890,13 +969,15 @@ public class TvOverlayManager {
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ case KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD:
+ case KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD:
return true;
}
return false;
}
private static class TvOverlayHandler extends WeakHandler<TvOverlayManager> {
- public TvOverlayHandler(TvOverlayManager ref) {
+ TvOverlayHandler(TvOverlayManager ref) {
super(ref);
}
@@ -920,16 +1001,22 @@ public class TvOverlayManager {
}
}
- /**
- * Dummny class for the workaround of b/22631964. See {@link #closeFragment}.
- */
- public static class DummyFragment extends Fragment {
- @Override
- public @Nullable View onCreateView(LayoutInflater inflater, ViewGroup container,
- Bundle savedInstanceState) {
- final View v = new Space(inflater.getContext());
- v.setVisibility(View.GONE);
- return v;
+ private class PendingDialogAction {
+ private final String mTag;
+ private final SafeDismissDialogFragment mDialog;
+ private final boolean mKeepSidePanelHistory;
+ private final boolean mKeepProgramGuide;
+
+ PendingDialogAction(String tag, SafeDismissDialogFragment dialog,
+ boolean keepSidePanelHistory, boolean keepProgramGuide) {
+ mTag = tag;
+ mDialog = dialog;
+ mKeepSidePanelHistory = keepSidePanelHistory;
+ mKeepProgramGuide = keepProgramGuide;
+ }
+
+ void run() {
+ showDialogFragment(mTag, mDialog, mKeepSidePanelHistory, mKeepProgramGuide);
}
}
}
diff --git a/src/com/android/tv/ui/TvViewUiManager.java b/src/com/android/tv/ui/TvViewUiManager.java
index 5ad89bfa..bf874fc7 100644
--- a/src/com/android/tv/ui/TvViewUiManager.java
+++ b/src/com/android/tv/ui/TvViewUiManager.java
@@ -25,12 +25,14 @@ import android.animation.TypeEvaluator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.annotation.SuppressLint;
+import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Point;
import android.hardware.display.DisplayManager;
import android.os.Handler;
+import android.os.Message;
import android.preference.PreferenceManager;
import android.util.Log;
import android.util.Property;
@@ -43,11 +45,11 @@ import android.view.ViewGroup.MarginLayoutParams;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
+import com.android.tv.Features;
import com.android.tv.R;
import com.android.tv.TvOptionsManager;
import com.android.tv.data.DisplayMode;
import com.android.tv.util.TvSettings;
-import com.android.tv.util.Utils;
/**
* The TvViewUiManager is responsible for handling UI layouting and animation of main and PIP
@@ -61,6 +63,8 @@ public class TvViewUiManager {
private static final float DISPLAY_MODE_EPSILON = 0.001f;
private static final float DISPLAY_ASPECT_RATIO_EPSILON = 0.01f;
+ private static final int MSG_SET_LAYOUT_PARAMS = 1000;
+
private final Context mContext;
private final Resources mResources;
private final FrameLayout mContentView;
@@ -80,7 +84,27 @@ public class TvViewUiManager {
private final SharedPreferences mSharedPreferences;
private final TimeInterpolator mLinearOutSlowIn;
private final TimeInterpolator mFastOutLinearIn;
- private final Handler mHandler = new Handler();
+ private final Handler mHandler = new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ switch(msg.what) {
+ case MSG_SET_LAYOUT_PARAMS:
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) msg.obj;
+ if (DEBUG) {
+ Log.d(TAG, "setFixedSize: w=" + layoutParams.width + " h="
+ + layoutParams.height);
+ }
+ mTvView.setLayoutParams(layoutParams);
+ // Smooth PIP size change, we don't change surface size when
+ // isInPictureInPictureMode is true.
+ if (!Features.PICTURE_IN_PICTURE.isEnabled(mContext)
+ || !((Activity) mContext).isInPictureInPictureMode()) {
+ mTvView.setFixedSurfaceSize(layoutParams.width, layoutParams.height);
+ }
+ break;
+ }
+ }
+ };
private int mDisplayMode;
// Used to restore the previous state from ShrunkenTvView state.
private int mTvViewStartMarginBeforeShrunken;
@@ -148,21 +172,16 @@ public class TvViewUiManager {
.getDimensionPixelOffset(R.dimen.pipview_margin_horizontal);
mPipViewTopMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_top);
mPipViewBottomMargin = mResources.getDimensionPixelOffset(R.dimen.pipview_margin_bottom);
- mContentView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
- @Override
- public void onLayoutChange(View v, int left, int top, int right, int bottom,
- int oldLeft, int oldTop, int oldRight, int oldBottom) {
- int windowWidth = right - left;
- int windowHeight = bottom - top;
- if (windowWidth > 0 && windowHeight > 0) {
- if (mWindowWidth != windowWidth || mWindowHeight != windowHeight) {
- mWindowWidth = windowWidth;
- mWindowHeight = windowHeight;
- applyDisplayMode(mTvView.getVideoDisplayAspectRatio(), false, true);
- }
- }
+ }
+
+ public void onConfigurationChanged(final int windowWidth, final int windowHeight) {
+ if (windowWidth > 0 && windowHeight > 0) {
+ if (mWindowWidth != windowWidth || mWindowHeight != windowHeight) {
+ mWindowWidth = windowWidth;
+ mWindowHeight = windowHeight;
+ applyDisplayMode(mTvView.getVideoDisplayAspectRatio(), false, true);
}
- });
+ }
}
/**
@@ -514,18 +533,10 @@ public class TvViewUiManager {
// This block is also called when animation ends.
if (isTvViewFullScreen()) {
// When this layout is for full screen, fix the surface size after layout to make
- // resize animation smooth.
- mTvView.post(new Runnable() {
- @Override
- public void run() {
- if (DEBUG) {
- Log.d(TAG, "setFixedSize: w=" + layoutParams.width + " h="
- + layoutParams.height);
- }
- mTvView.setLayoutParams(layoutParams);
- mTvView.setFixedSurfaceSize(layoutParams.width, layoutParams.height);
- }
- });
+ // resize animation smooth. During PIP size change, the multiple messages can be
+ // queued, if we don't remove MSG_SET_LAYOUT_PARAMS.
+ mHandler.removeMessages(MSG_SET_LAYOUT_PARAMS);
+ mHandler.obtainMessage(MSG_SET_LAYOUT_PARAMS, layoutParams).sendToTarget();
} else {
mTvView.setLayoutParams(layoutParams);
}
@@ -715,6 +726,9 @@ public class TvViewUiManager {
private void applyDisplayMode(float videoDisplayAspectRatio, boolean animate,
boolean forceUpdate) {
+ if (videoDisplayAspectRatio <= 0f) {
+ videoDisplayAspectRatio = (float) mWindowWidth / mWindowHeight;
+ }
if (mAppliedDisplayedMode == mDisplayMode
&& mAppliedTvViewStartMargin == mTvViewStartMargin
&& mAppliedTvViewEndMargin == mTvViewEndMargin
@@ -743,11 +757,7 @@ public class TvViewUiManager {
+ availableAreaHeight + ")");
} else {
availableAreaRatio = (double) availableAreaWidth / availableAreaHeight;
- if (videoDisplayAspectRatio <= 0f) {
- videoRatio = (double) mWindowWidth / mWindowHeight;
- } else {
- videoRatio = videoDisplayAspectRatio;
- }
+ videoRatio = videoDisplayAspectRatio;
}
int tvViewFrameTop = (mWindowHeight - availableAreaHeight) / 2;
@@ -764,22 +774,22 @@ public class TvViewUiManager {
if (videoRatio < availableAreaRatio) {
// Y axis will be clipped.
layoutParams.width = availableAreaWidth;
- layoutParams.height = (int) (availableAreaWidth / videoRatio);
+ layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio);
} else {
// X axis will be clipped.
- layoutParams.width = (int) (availableAreaHeight * videoRatio);
+ layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio);
layoutParams.height = availableAreaHeight;
}
break;
case DisplayMode.MODE_NORMAL:
if (videoRatio < availableAreaRatio) {
// X axis has black area.
- layoutParams.width = (int) (availableAreaHeight * videoRatio);
+ layoutParams.width = (int) Math.round(availableAreaHeight * videoRatio);
layoutParams.height = availableAreaHeight;
} else {
// Y axis has black area.
layoutParams.width = availableAreaWidth;
- layoutParams.height = (int) (availableAreaWidth / videoRatio);
+ layoutParams.height = (int) Math.round(availableAreaWidth / videoRatio);
}
break;
}
@@ -791,9 +801,9 @@ public class TvViewUiManager {
// Set marginEnd as well because setTvViewPosition uses both start/end margin.
layoutParams.setMarginEnd(mWindowWidth - layoutParams.width - marginStart);
- setBackgroundColor(Utils.getColor(mResources, isTvViewFullScreen()
- ? R.color.tvactivity_background : R.color.tvactivity_background_on_shrunken_tvview),
- layoutParams, animate);
+ setBackgroundColor(mResources.getColor(isTvViewFullScreen()
+ ? R.color.tvactivity_background : R.color.tvactivity_background_on_shrunken_tvview,
+ null), layoutParams, animate);
setTvViewPosition(layoutParams, tvViewFrame, animate);
// Update the current display mode.
diff --git a/src/com/android/tv/ui/ViewUtils.java b/src/com/android/tv/ui/ViewUtils.java
index 5a853dcd..ac181752 100644
--- a/src/com/android/tv/ui/ViewUtils.java
+++ b/src/com/android/tv/ui/ViewUtils.java
@@ -16,8 +16,11 @@
package com.android.tv.ui;
+import android.animation.Animator;
+import android.animation.ValueAnimator;
import android.util.Log;
import android.view.View;
+import android.view.ViewGroup.LayoutParams;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -42,4 +45,49 @@ public class ViewUtils {
Log.e(TAG, "Fail to call View.setTransitionAlpha", e);
}
}
-}
+
+ /**
+ * Creates an animator in view's height
+ * @param target the {@link view} animator performs on.
+ */
+ public static Animator createHeightAnimator(
+ final View target, int initialHeight, int targetHeight) {
+ ValueAnimator animator = ValueAnimator.ofInt(initialHeight, targetHeight);
+ animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ int value = (Integer) animation.getAnimatedValue();
+ if (value == 0) {
+ if (target.getVisibility() != View.GONE) {
+ target.setVisibility(View.GONE);
+ }
+ } else {
+ if (target.getVisibility() != View.VISIBLE) {
+ target.setVisibility(View.VISIBLE);
+ }
+ setLayoutHeight(target, value);
+ }
+ }
+ });
+ return animator;
+ }
+
+ /**
+ * Gets view's layout height.
+ */
+ public static int getLayoutHeight(View view) {
+ LayoutParams layoutParams = view.getLayoutParams();
+ return layoutParams.height;
+ }
+
+ /**
+ * Sets view's layout height.
+ */
+ public static void setLayoutHeight(View view, int height) {
+ LayoutParams layoutParams = view.getLayoutParams();
+ if (height != layoutParams.height) {
+ layoutParams.height = height;
+ view.setLayoutParams(layoutParams);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java
index b52302b6..9cc54ed2 100644
--- a/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java
+++ b/src/com/android/tv/ui/sidepanel/CustomizeChannelListFragment.java
@@ -165,8 +165,7 @@ public class CustomizeChannelListFragment extends SideFragment {
if (item instanceof SelectGroupItem) {
SelectGroupItem selectGroupItem = (SelectGroupItem) item;
if (selectGroupItem.mChannelItemsInGroup.size() == 1) {
- ((ChannelItem) selectGroupItem.mChannelItemsInGroup.get(0))
- .mSelectGroupItem = null;
+ selectGroupItem.mChannelItemsInGroup.get(0).mSelectGroupItem = null;
iter.remove();
}
}
diff --git a/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java b/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java
deleted file mode 100644
index 35cc18c2..00000000
--- a/src/com/android/tv/ui/sidepanel/DebugOptionFragment.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.tv.ui.sidepanel;
-
-import com.android.tv.R;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class DebugOptionFragment extends SideFragment {
- private static final String TRACKER_LABEL = "debug options";
-
- @Override
- protected String getTitle() {
- return getString(R.string.menu_debug_options);
- }
-
- @Override
- public String getTrackerLabel() {
- return TRACKER_LABEL;
- }
-
- @Override
- protected List<Item> getItemList() {
- List<Item> items = new ArrayList<>();
- items.add(new ActionItem(getString(R.string.item_watch_history)) {
- @Override
- protected void onSelected() {
- getMainActivity().getOverlayManager().showRecentlyWatchedDialog();
- }
- });
- return items;
- }
-}
diff --git a/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java
new file mode 100644
index 00000000..0d189cca
--- /dev/null
+++ b/src/com/android/tv/ui/sidepanel/DeveloperOptionFragment.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.ui.sidepanel;
+
+import android.accounts.Account;
+import android.app.Activity;
+import android.app.ApplicationErrorReport;
+import android.content.Intent;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.android.tv.R;
+import com.android.tv.TvApplication;
+import com.android.tv.common.BuildConfig;
+import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.experiments.Experiments;
+import com.android.tv.tuner.TunerPreferences;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Options for developers only
+ */
+public class DeveloperOptionFragment extends SideFragment {
+ private static final String TAG = "DeveloperOptionFragment";
+ private static final String TRACKER_LABEL = "debug options";
+
+ @Override
+ protected String getTitle() {
+ return getString(R.string.menu_developer_options);
+ }
+
+ @Override
+ public String getTrackerLabel() {
+ return TRACKER_LABEL;
+ }
+
+ @Override
+ protected List<Item> getItemList() {
+ List<Item> items = new ArrayList<>();
+ if (BuildConfig.ENG) {
+ items.add(new ActionItem(getString(R.string.dev_item_watch_history)) {
+ @Override
+ protected void onSelected() {
+ getMainActivity().getOverlayManager().showRecentlyWatchedDialog();
+ }
+ });
+ }
+ items.add(new ActionItem(getString(R.string.dev_item_send_feedback)) {
+ @Override
+ protected void onSelected() {
+ Intent intent = new Intent(Intent.ACTION_APP_ERROR);
+ ApplicationErrorReport report = new ApplicationErrorReport();
+ report.packageName = report.processName = getContext().getPackageName();
+ report.time = System.currentTimeMillis();
+ report.type = ApplicationErrorReport.TYPE_NONE;
+ intent.putExtra(Intent.EXTRA_BUG_REPORT, report);
+ startActivityForResult(intent, 0);
+ }
+ });
+ items.add(new SwitchItem(getString(R.string.dev_item_store_ts_on),
+ getString(R.string.dev_item_store_ts_off),
+ getString(R.string.dev_item_store_ts_description)) {
+ @Override
+ protected void onUpdate() {
+ super.onUpdate();
+ setChecked(TunerPreferences.getStoreTsStream(getContext()));
+ }
+
+ @Override
+ protected void onSelected() {
+ super.onSelected();
+ TunerPreferences.setStoreTsStream(getContext(), isChecked());
+ }
+ });
+ return items;
+ }
+
+
+ /** True if there is the dev options menu */
+ public static boolean shouldShow() {
+ return Experiments.ENABLE_DEVELOPER_FEATURES.get() || BuildConfig.ENG;
+ }
+
+}
diff --git a/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java b/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java
index b084a115..29792757 100644
--- a/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java
+++ b/src/com/android/tv/ui/sidepanel/DisplayModeFragment.java
@@ -16,7 +16,7 @@
package com.android.tv.ui.sidepanel;
-import android.app.Activity;
+import android.content.Context;
import com.android.tv.R;
import com.android.tv.data.DisplayMode;
@@ -49,8 +49,8 @@ public class DisplayModeFragment extends SideFragment {
}
@Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
+ public void onAttach(Context context) {
+ super.onAttach(context);
mTvViewUiManager = getMainActivity().getTvViewUiManager();
}
diff --git a/src/com/android/tv/ui/sidepanel/SettingsFragment.java b/src/com/android/tv/ui/sidepanel/SettingsFragment.java
index 6d606014..e8033a22 100644
--- a/src/com/android/tv/ui/sidepanel/SettingsFragment.java
+++ b/src/com/android/tv/ui/sidepanel/SettingsFragment.java
@@ -65,7 +65,7 @@ public class SettingsFragment extends SideFragment {
}
}
- @Override
+ @Override
protected String getTitle() {
return getResources().getString(R.string.side_panel_title_settings);
}
@@ -80,8 +80,8 @@ public class SettingsFragment extends SideFragment {
List<Item> items = new ArrayList<>();
final Item customizeChannelListItem = new SubMenuItem(
getString(R.string.settings_channel_source_item_customize_channels),
- getString(R.string.settings_channel_source_item_customize_channels_description),
- 0, getMainActivity().getOverlayManager().getSideFragmentManager()) {
+ getString(R.string.settings_channel_source_item_customize_channels_description), 0,
+ getMainActivity().getOverlayManager().getSideFragmentManager()) {
@Override
protected SideFragment getFragment() {
return new CustomizeChannelListFragment(mCurrentChannelId);
@@ -102,8 +102,8 @@ public class SettingsFragment extends SideFragment {
customizeChannelListItem.setEnabled(false);
items.add(customizeChannelListItem);
final MainActivity activity = getMainActivity();
- boolean hasNewInput = SetupUtils.getInstance(activity).hasNewInput(
- activity.getTvInputManagerHelper());
+ boolean hasNewInput = SetupUtils.getInstance(activity)
+ .hasNewInput(activity.getTvInputManagerHelper());
items.add(new ActionItem(
getString(R.string.settings_channel_source_item_setup),
hasNewInput ? getString(R.string.settings_channel_source_item_setup_new_inputs)
@@ -115,8 +115,9 @@ public class SettingsFragment extends SideFragment {
}
});
if (PermissionUtils.hasModifyParentalControls(getMainActivity())) {
- items.add(new ActionItem(getString(R.string.settings_parental_controls),
- getString(activity.getParentalControlSettings().isParentalControlsEnabled()
+ items.add(new ActionItem(
+ getString(R.string.settings_parental_controls), getString(
+ activity.getParentalControlSettings().isParentalControlsEnabled()
? R.string.option_toggle_parental_controls_on
: R.string.option_toggle_parental_controls_off)) {
@Override
@@ -131,16 +132,16 @@ public class SettingsFragment extends SideFragment {
@Override
public void done(boolean success) {
if (success) {
- sideFragmentManager.show(new ParentalControlsFragment(),
- false);
+ sideFragmentManager
+ .show(new ParentalControlsFragment(), false);
sideFragmentManager.showSidePanel(true);
} else {
sideFragmentManager.hideAll(false);
}
}
});
- tvActivity.getOverlayManager().showDialogFragment(PinDialogFragment.DIALOG_TAG,
- fragment, true);
+ tvActivity.getOverlayManager()
+ .showDialogFragment(PinDialogFragment.DIALOG_TAG, fragment, true);
}
});
} else {
diff --git a/src/com/android/tv/ui/sidepanel/SideFragment.java b/src/com/android/tv/ui/sidepanel/SideFragment.java
index 8c37f40f..8df56cd2 100644
--- a/src/com/android/tv/ui/sidepanel/SideFragment.java
+++ b/src/com/android/tv/ui/sidepanel/SideFragment.java
@@ -16,7 +16,6 @@
package com.android.tv.ui.sidepanel;
-import android.app.Activity;
import android.app.Fragment;
import android.content.Context;
import android.graphics.drawable.RippleDrawable;
@@ -80,11 +79,11 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel {
}
@Override
- public void onAttach(Activity activity) {
- super.onAttach(activity);
+ public void onAttach(Context context) {
+ super.onAttach(context);
mChannelDataManager = getMainActivity().getChannelDataManager();
mProgramDataManager = getMainActivity().getProgramDataManager();
- mTracker = TvApplication.getSingletons(activity).getTracker();
+ mTracker = TvApplication.getSingletons(context).getTracker();
}
@Override
@@ -236,6 +235,9 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel {
void onSideFragmentViewDestroyed();
}
+ /**
+ * Preloads the view holders.
+ */
public static void preloadRecycledViews(Context context) {
if (sRecycledViewPool != null) {
return;
@@ -253,6 +255,13 @@ public abstract class SideFragment extends Fragment implements HasTrackerLabel {
}
}
+ /**
+ * Releases the pre-loaded view holders.
+ */
+ public static void releasePreloadedRecycledViews() {
+ sRecycledViewPool = null;
+ }
+
private static class ItemAdapter extends RecyclerView.Adapter<ItemAdapter.ViewHolder> {
private final LayoutInflater mLayoutInflater;
private List<Item> mItems;
diff --git a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java
index faccbc66..553cd9d7 100644
--- a/src/com/android/tv/ui/sidepanel/SideFragmentManager.java
+++ b/src/com/android/tv/ui/sidepanel/SideFragmentManager.java
@@ -22,6 +22,7 @@ import android.animation.AnimatorListenerAdapter;
import android.app.Activity;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
+import android.os.Handler;
import android.view.View;
import com.android.tv.R;
@@ -42,6 +43,7 @@ public class SideFragmentManager {
private final Animator mShowAnimator;
private final Animator mHideAnimator;
+ private final Handler mHandler = new Handler();
private final Runnable mHideAllRunnable = new Runnable() {
@Override
public void run() {
@@ -154,6 +156,7 @@ public class SideFragmentManager {
}
private void hideAllInternal() {
+ mHandler.removeCallbacksAndMessages(null);
if (mFragmentCount == 0) {
return;
}
@@ -192,8 +195,8 @@ public class SideFragmentManager {
* stack. If you want to empty the back stack, call {@link #hideAll}.
*/
public void hideSidePanel(boolean withAnimation) {
+ mHandler.removeCallbacks(mHideAllRunnable);
if (withAnimation) {
- mPanel.removeCallbacks(mHideAllRunnable);
Animator hideAnimator =
AnimatorInflater.loadAnimator(mActivity, R.animator.side_panel_exit);
hideAnimator.setTarget(mPanel);
@@ -213,9 +216,12 @@ public class SideFragmentManager {
return mPanel.getVisibility() == View.VISIBLE;
}
+ /**
+ * Resets the timer for hiding side fragment.
+ */
public void scheduleHideAll() {
- mPanel.removeCallbacks(mHideAllRunnable);
- mPanel.postDelayed(mHideAllRunnable, mShowDurationMillis);
+ mHandler.removeCallbacks(mHideAllRunnable);
+ mHandler.postDelayed(mHideAllRunnable, mShowDurationMillis);
}
/**
diff --git a/src/com/android/tv/util/AccountHelper.java b/src/com/android/tv/util/AccountHelper.java
new file mode 100644
index 00000000..ece13de1
--- /dev/null
+++ b/src/com/android/tv/util/AccountHelper.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.RemoteException;
+import android.preference.PreferenceManager;
+import android.support.annotation.Nullable;
+import android.util.Log;
+
+
+import java.util.Arrays;
+
+/**
+ * Helper methods for getting and selecting a user account.
+ */
+public class AccountHelper {
+ private static final String TAG = "AccountHelper";
+ private static final boolean DEBUG = false;
+ private static final String SELECTED_ACCOUNT = "android.tv.livechannels.selected_account";
+
+ private final Context mContext;
+ private final SharedPreferences mDefaultPreferences;
+
+ @Nullable
+ private Account mSelectedAccount;
+
+ public AccountHelper(Context context) {
+ mContext = context.getApplicationContext();
+ mDefaultPreferences = PreferenceManager.getDefaultSharedPreferences(mContext);
+ }
+
+ /**
+ * Returns the currently selected account or {@code null} if none is selected.
+ */
+ @Nullable
+ public Account getSelectedAccount() {
+ String accountId = mDefaultPreferences.getString(SELECTED_ACCOUNT, null);
+ if (accountId == null) {
+ return null;
+ }
+ if (mSelectedAccount == null || !mSelectedAccount.name.equals((accountId))) {
+ mSelectedAccount = null;
+ for (Account account : getEligibleAccounts()) {
+ if (account.name.equals(accountId)) {
+ mSelectedAccount = account;
+ break;
+ }
+ }
+ }
+ return mSelectedAccount;
+ }
+
+ /**
+ * Returns all eligible accounts .
+ */
+ private Account[] getEligibleAccounts() {
+ return new Account[0];
+ }
+
+ /**
+ * Selects the first account available.
+ *
+ * @return selected account or {@code null} if none is selected.
+ */
+ @Nullable
+ public Account selectFirstAccount() {
+ Account account = getFirstEligibleAccount();
+ if (account != null) {
+ selectAccount(account);
+ }
+ return account;
+ }
+
+ /**
+ * Gets the first account eligible.
+ *
+ * @return first account or {@code null} if none is eligible.
+ */
+ @Nullable
+ public Account getFirstEligibleAccount() {
+ Account[] accounts = getEligibleAccounts();
+ return accounts.length == 0 ? null : accounts[0];
+ }
+
+ /**
+ * Sets the given account as the selected account.
+ */
+ private void selectAccount(Account account) {
+ SharedPreferences defaultPreferences = PreferenceManager
+ .getDefaultSharedPreferences(mContext);
+ defaultPreferences.edit().putString(SELECTED_ACCOUNT, account.name).commit();
+ }
+}
+
diff --git a/src/com/android/tv/util/AsyncDbTask.java b/src/com/android/tv/util/AsyncDbTask.java
index 7ac293fc..78243642 100644
--- a/src/com/android/tv/util/AsyncDbTask.java
+++ b/src/com/android/tv/util/AsyncDbTask.java
@@ -19,6 +19,7 @@ package com.android.tv.util;
import android.content.ContentResolver;
import android.database.Cursor;
import android.media.tv.TvContract;
+import android.media.tv.TvContract.Programs;
import android.net.Uri;
import android.os.AsyncTask;
import android.support.annotation.MainThread;
@@ -30,6 +31,7 @@ import android.util.Range;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.Channel;
import com.android.tv.data.Program;
+import com.android.tv.dvr.RecordedProgram;
import java.util.ArrayList;
import java.util.List;
@@ -52,7 +54,7 @@ public abstract class AsyncDbTask<Params, Progress, Result>
private static final String TAG = "AsyncDbTask";
private static final boolean DEBUG = false;
- public static final NamedThreadFactory THREAD_FACTORY = new NamedThreadFactory(
+ private static final NamedThreadFactory THREAD_FACTORY = new NamedThreadFactory(
AsyncDbTask.class.getSimpleName());
private static final ExecutorService DB_EXECUTOR = Executors
.newSingleThreadExecutor(THREAD_FACTORY);
@@ -160,7 +162,7 @@ public abstract class AsyncDbTask<Params, Progress, Result>
@Override
public String toString() {
- return this.getClass().getSimpleName() + "(" + mUri + ")";
+ return this.getClass().getName() + "(" + mUri + ")";
}
}
@@ -172,10 +174,17 @@ public abstract class AsyncDbTask<Params, Progress, Result>
* @param <T> the type of result returned in a list by {@link #onQuery(Cursor)}
*/
public abstract static class AsyncQueryListTask<T> extends AsyncQueryTask<List<T>> {
+ private final CursorFilter mFilter;
public AsyncQueryListTask(ContentResolver contentResolver, Uri uri, String[] projection,
String selection, String[] selectionArgs, String orderBy) {
+ this(contentResolver, uri, projection, selection, selectionArgs, orderBy, null);
+ }
+
+ public AsyncQueryListTask(ContentResolver contentResolver, Uri uri, String[] projection,
+ String selection, String[] selectionArgs, String orderBy, CursorFilter filter) {
super(contentResolver, uri, projection, selection, selectionArgs, orderBy);
+ mFilter = filter;
}
@Override
@@ -186,6 +195,9 @@ public abstract class AsyncDbTask<Params, Progress, Result>
// This is guaranteed to never call onPostExecute because the task is canceled.
return null;
}
+ if (mFilter != null && !mFilter.filter(c)) {
+ continue;
+ }
T t = fromCursor(c);
result.add(t);
}
@@ -273,6 +285,41 @@ public abstract class AsyncDbTask<Params, Progress, Result>
}
/**
+ * Gets an {@link List} of {@link Program}s from {@link TvContract.Programs#CONTENT_URI}.
+ */
+ public abstract static class AsyncProgramQueryTask extends AsyncQueryListTask<Program> {
+ public AsyncProgramQueryTask(ContentResolver contentResolver) {
+ super(contentResolver, Programs.CONTENT_URI, Program.PROJECTION, null, null, null);
+ }
+
+ public AsyncProgramQueryTask(ContentResolver contentResolver, Uri uri, String selection,
+ String[] selectionArgs, String sortOrder, CursorFilter filter) {
+ super(contentResolver, uri, Program.PROJECTION, selection, selectionArgs, sortOrder,
+ filter);
+ }
+
+ @Override
+ protected final Program fromCursor(Cursor c) {
+ return Program.fromCursor(c);
+ }
+ }
+
+ /**
+ * Gets an {@link List} of {@link TvContract.RecordedPrograms}s.
+ */
+ public abstract static class AsyncRecordedProgramQueryTask
+ extends AsyncQueryListTask<RecordedProgram> {
+ public AsyncRecordedProgramQueryTask(ContentResolver contentResolver, Uri uri) {
+ super(contentResolver, uri, RecordedProgram.PROJECTION, null, null, null);
+ }
+
+ @Override
+ protected final RecordedProgram fromCursor(Cursor c) {
+ return RecordedProgram.fromCursor(c);
+ }
+ }
+
+ /**
* Execute the task on the {@link #DB_EXECUTOR} thread.
*/
@SafeVarargs
@@ -286,7 +333,7 @@ public abstract class AsyncDbTask<Params, Progress, Result>
* TvContract#buildProgramsUriForChannel(long, long, long)}. If the {@code period} is
* {@code null}, then all the programs is queried.
*/
- public static class LoadProgramsForChannelTask extends AsyncQueryListTask<Program> {
+ public static class LoadProgramsForChannelTask extends AsyncProgramQueryTask {
protected final Range<Long> mPeriod;
protected final long mChannelId;
@@ -296,16 +343,11 @@ public abstract class AsyncDbTask<Params, Progress, Result>
? TvContract.buildProgramsUriForChannel(channelId)
: TvContract.buildProgramsUriForChannel(channelId, period.getLower(),
period.getUpper()),
- Program.PROJECTION, null, null, null);
+ null, null, null, null);
mPeriod = period;
mChannelId = channelId;
}
- @Override
- protected final Program fromCursor(Cursor c) {
- return Program.fromCursor(c);
- }
-
public long getChannelId() {
return mChannelId;
}
@@ -314,4 +356,25 @@ public abstract class AsyncDbTask<Params, Progress, Result>
return mPeriod;
}
}
+
+ /**
+ * Gets a single {@link Program} from {@link TvContract.Programs#CONTENT_URI}.
+ */
+ public static class AsyncQueryProgramTask extends AsyncQueryItemTask<Program> {
+
+ public AsyncQueryProgramTask(ContentResolver contentResolver, long programId) {
+ super(contentResolver, TvContract.buildProgramUri(programId), Program.PROJECTION, null,
+ null, null);
+ }
+
+ @Override
+ protected Program fromCursor(Cursor c) {
+ return Program.fromCursor(c);
+ }
+ }
+
+ /**
+ * An interface which filters the row.
+ */
+ public interface CursorFilter extends Filter<Cursor> { }
}
diff --git a/src/com/android/tv/util/BitmapUtils.java b/src/com/android/tv/util/BitmapUtils.java
index 78b77e65..d45a8dce 100644
--- a/src/com/android/tv/util/BitmapUtils.java
+++ b/src/com/android/tv/util/BitmapUtils.java
@@ -33,6 +33,7 @@ import java.io.BufferedInputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
+import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
@@ -84,9 +85,20 @@ public final class BitmapUtils {
return null;
}
+ Uri uri = Uri.parse(uriString).normalizeScheme();
+ boolean isResourceUri = isContentResolverUri(uri);
+ URLConnection urlConnection = null;
InputStream inputStream = null;
try {
- inputStream = new BufferedInputStream(getInputStream(context, uriString));
+ if (isResourceUri) {
+ inputStream = context.getContentResolver().openInputStream(uri);
+ } else {
+ // If the URLConnection is HttpURLConnection, disconnect() should be called
+ // explicitly.
+ urlConnection = getUrlConnection(uriString);
+ inputStream = urlConnection.getInputStream();
+ }
+ inputStream = new BufferedInputStream(inputStream);
inputStream.mark(MARK_READ_LIMIT);
// Check the bitmap dimensions.
@@ -98,13 +110,16 @@ public final class BitmapUtils {
try {
inputStream.reset();
} catch (IOException e) {
- if (DEBUG) {
- Log.i(TAG, "Failed to rewind stream: " + uriString, e);
- }
+ if (DEBUG) Log.i(TAG, "Failed to rewind stream: " + uriString, e);
// Failed to rewind the stream, try to reopen it.
- close(inputStream);
- inputStream = getInputStream(context, uriString);
+ close(inputStream, urlConnection);
+ if (isResourceUri) {
+ inputStream = context.getContentResolver().openInputStream(uri);
+ } else {
+ urlConnection = getUrlConnection(uriString);
+ inputStream = urlConnection.getInputStream();
+ }
}
// Decode the bitmap possibly resizing it.
@@ -126,10 +141,17 @@ public final class BitmapUtils {
Log.e(TAG, "Failed to open stream: " + uriString, e);
return null;
} finally {
- close(inputStream);
+ close(inputStream, urlConnection);
}
}
+ private static URLConnection getUrlConnection(String uriString) throws IOException {
+ URLConnection urlConnection = new URL(uriString).openConnection();
+ urlConnection.setConnectTimeout(CONNECTION_TIMEOUT_MS_FOR_URLCONNECTION);
+ urlConnection.setReadTimeout(READ_TIMEOUT_MS_FOR_URLCONNECTION);
+ return urlConnection;
+ }
+
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth,
int reqHeight) {
return calculateInSampleSize(options.outWidth, options.outHeight, reqWidth, reqHeight);
@@ -142,20 +164,6 @@ public final class BitmapUtils {
return Math.max(1, Integer.highestOneBit(ratio));
}
- private static InputStream getInputStream(Context context, String uriString)
- throws IOException {
- Uri uri = Uri.parse(uriString).normalizeScheme();
- if (isContentResolverUri(uri)) {
- return context.getContentResolver().openInputStream(uri);
- } else {
- // TODO We should disconnect() the URLConnection in order to allow connection reuse.
- URLConnection urlConnection = new URL(uriString).openConnection();
- urlConnection.setConnectTimeout(CONNECTION_TIMEOUT_MS_FOR_URLCONNECTION);
- urlConnection.setReadTimeout(READ_TIMEOUT_MS_FOR_URLCONNECTION);
- return urlConnection.getInputStream();
- }
- }
-
private static boolean isContentResolverUri(Uri uri) {
String scheme = uri.getScheme();
return ContentResolver.SCHEME_CONTENT.equals(scheme)
@@ -163,7 +171,7 @@ public final class BitmapUtils {
|| ContentResolver.SCHEME_FILE.equals(scheme);
}
- private static void close(Closeable closeable) {
+ private static void close(Closeable closeable, URLConnection urlConnection) {
if (closeable != null) {
try {
closeable.close();
@@ -172,6 +180,9 @@ public final class BitmapUtils {
Log.w(TAG,"Error closing " + closeable, e);
}
}
+ if (urlConnection instanceof HttpURLConnection) {
+ ((HttpURLConnection) urlConnection).disconnect();
+ }
}
/**
diff --git a/src/com/android/tv/util/CompositeComparator.java b/src/com/android/tv/util/CompositeComparator.java
new file mode 100644
index 00000000..47cf50fe
--- /dev/null
+++ b/src/com/android/tv/util/CompositeComparator.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import java.util.Comparator;
+
+/**
+ * A comparator which runs multiple comparators sequentially.
+ */
+public class CompositeComparator<T> implements Comparator<T> {
+ private final Comparator<T>[] mComparators;
+
+ @SafeVarargs
+ public CompositeComparator(Comparator<T>... comparators) {
+ mComparators = comparators;
+ }
+
+ @Override
+ public int compare(T lhs, T rhs) {
+ for (Comparator<T> comparator : mComparators) {
+ int result = comparator.compare(lhs, rhs);
+ if (result != 0) {
+ return result;
+ }
+ }
+ return 0;
+ }
+}
diff --git a/src/com/android/tv/util/Filter.java b/src/com/android/tv/util/Filter.java
new file mode 100644
index 00000000..d5b356e4
--- /dev/null
+++ b/src/com/android/tv/util/Filter.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+/**
+ * Interface to decide whether an input is filtered out or not.
+ */
+public interface Filter<T> {
+ /**
+ * Returns true, if {@code input} is acceptable.
+ */
+ boolean filter(T input);
+}
diff --git a/src/com/android/tv/util/ImageCache.java b/src/com/android/tv/util/ImageCache.java
index db64d4c9..b413c364 100644
--- a/src/com/android/tv/util/ImageCache.java
+++ b/src/com/android/tv/util/ImageCache.java
@@ -145,6 +145,16 @@ public class ImageCache implements MemoryManageable {
}
/**
+ * Remove from memory cache.
+ *
+ * @param key Unique identifier for which item to remove
+ * @return The previous bitmap mapped by key
+ */
+ public ScaledBitmapInfo remove(String key) {
+ return mMemoryCache.remove(key);
+ }
+
+ /**
* Calculates the memory cache size based on a percentage of the max available VM memory. Eg.
* setting percent to 0.2 would set the memory cache to one fifth of the available memory.
* Throws {@link IllegalArgumentException} if percent is < 0.05 or > .8. memCacheSize is stored
diff --git a/src/com/android/tv/util/ImageLoader.java b/src/com/android/tv/util/ImageLoader.java
index ed0fd54d..04bb478a 100644
--- a/src/com/android/tv/util/ImageLoader.java
+++ b/src/com/android/tv/util/ImageLoader.java
@@ -64,8 +64,7 @@ public final class ImageLoader {
private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader");
- private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<Runnable>(
- 128);
+ private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>(128);
/**
* An private {@link Executor} that can be used to execute tasks in parallel.
@@ -380,7 +379,7 @@ public final class ImageLoader {
public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
super(context,
cache,
- info.getId() + "-logo",
+ getTvInputLogoKey(info.getId()),
context.getResources()
.getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
context.getResources()
@@ -402,6 +401,13 @@ public final class ImageLoader {
}
return BitmapUtils.createScaledBitmapInfo(getKey(), original, mMaxWidth, mMaxHeight);
}
+
+ /**
+ * Returns key of TV input logo.
+ */
+ public static String getTvInputLogoKey(String inputId) {
+ return inputId + "-logo";
+ }
}
private static synchronized Handler getMainHandler() {
diff --git a/src/com/android/tv/util/LocationUtils.java b/src/com/android/tv/util/LocationUtils.java
new file mode 100644
index 00000000..8e3b59e9
--- /dev/null
+++ b/src/com/android/tv/util/LocationUtils.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import android.content.Context;
+import android.location.Address;
+import android.location.Geocoder;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.os.Bundle;
+import android.util.Log;
+
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * A utility class to get the current location.
+ */
+public class LocationUtils {
+ private static final String TAG = "LocationUtils";
+ private static final boolean DEBUG = false;
+
+ private static Context sApplicationContext;
+ private static Address sAddress;
+ private static IOException sError;
+
+ /**
+ * Checks the current location.
+ */
+ public static synchronized Address getCurrentAddress(Context context) throws IOException,
+ SecurityException {
+ if (sAddress != null) {
+ return sAddress;
+ }
+ if (sError != null) {
+ throw sError;
+ }
+ if (sApplicationContext == null) {
+ sApplicationContext = context.getApplicationContext();
+ }
+ LocationUtilsHelper.startLocationUpdates();
+ return null;
+ }
+
+ private static void updateAddress(Location location) {
+ if (DEBUG) Log.d(TAG, "Updating address with " + location);
+ if (location == null) {
+ return;
+ }
+ Geocoder geocoder = new Geocoder(sApplicationContext, Locale.getDefault());
+ try {
+ List<Address> addresses = geocoder.getFromLocation(
+ location.getLatitude(), location.getLongitude(), 1);
+ if (addresses != null) {
+ sAddress = addresses.get(0);
+ if (DEBUG) Log.d(TAG, "Got " + sAddress);
+ } else {
+ if (DEBUG) Log.d(TAG, "No address returned");
+ }
+ sError = null;
+ } catch (IOException e) {
+ Log.w(TAG, "Error in updating address", e);
+ sError = e;
+ }
+ }
+
+ private LocationUtils() { }
+
+ private static class LocationUtilsHelper {
+ private static final LocationListener LOCATION_LISTENER = new LocationListener() {
+ @Override
+ public void onLocationChanged(Location location) {
+ updateAddress(location);
+ }
+
+ @Override
+ public void onStatusChanged(String provider, int status, Bundle extras) { }
+
+ @Override
+ public void onProviderEnabled(String provider) { }
+
+ @Override
+ public void onProviderDisabled(String provider) { }
+ };
+
+ private static LocationManager sLocationManager;
+
+ public static void startLocationUpdates() {
+ if (sLocationManager == null) {
+ sLocationManager = (LocationManager) sApplicationContext.getSystemService(
+ Context.LOCATION_SERVICE);
+ try {
+ sLocationManager.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER, 1000, 10, LOCATION_LISTENER, null);
+ } catch (SecurityException e) {
+ // Enables requesting the location updates again.
+ sLocationManager = null;
+ throw e;
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/tv/util/OnboardingUtils.java b/src/com/android/tv/util/OnboardingUtils.java
index 3dcc324d..3040020e 100644
--- a/src/com/android/tv/util/OnboardingUtils.java
+++ b/src/com/android/tv/util/OnboardingUtils.java
@@ -36,12 +36,12 @@ public final class OnboardingUtils {
private static final String PREF_KEY_ONBOARDING_VERSION_CODE = "pref_onbaording_versionCode";
private static final int ONBOARDING_VERSION = 1;
- private static final String MERCHANT_COLLECTION_URL_STRING =
- "TODO: put a market link to show TV input apps";
+ private static final String MERCHANT_COLLECTION_URL_STRING = getMerchantCollectionUrl();
+
/**
- * Intent to show merchant collection in play store.
+ * Intent to show merchant collection in online store.
*/
- public static final Intent PLAY_STORE_INTENT = new Intent(Intent.ACTION_VIEW,
+ public static final Intent ONLINE_STORE_INTENT = new Intent(Intent.ACTION_VIEW,
Uri.parse(MERCHANT_COLLECTION_URL_STRING));
/**
@@ -112,4 +112,11 @@ public final class OnboardingUtils {
return TvApplication.getSingletons(context).getTvInputManagerHelper()
.getTvInputInfos(true, false).size() > 0;
}
+
+ /**
+ * Returns merchant collection URL.
+ */
+ private static String getMerchantCollectionUrl() {
+ return "TODO: add a merchant collection url";
+ }
}
diff --git a/src/com/android/tv/util/PermissionUtils.java b/src/com/android/tv/util/PermissionUtils.java
index f39dba81..453885a4 100644
--- a/src/com/android/tv/util/PermissionUtils.java
+++ b/src/com/android/tv/util/PermissionUtils.java
@@ -2,69 +2,49 @@ package com.android.tv.util;
import android.content.Context;
import android.content.pm.PackageManager;
-import android.os.Build;
/**
* Util class to handle permissions.
*/
public class PermissionUtils {
+ /**
+ * Permission to read the TV listings.
+ */
+ public static final String PERMISSION_READ_TV_LISTINGS = "android.permission.READ_TV_LISTINGS";
+
private static Boolean sHasAccessAllEpgPermission;
private static Boolean sHasAccessWatchedHistoryPermission;
private static Boolean sHasModifyParentalControlsPermission;
public static boolean hasAccessAllEpg(Context context) {
if (sHasAccessAllEpgPermission == null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- sHasAccessAllEpgPermission = context.checkSelfPermission(
- "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA")
- == PackageManager.PERMISSION_GRANTED;
- } else {
- sHasAccessAllEpgPermission = context.getPackageManager().checkPermission(
- "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA",
- context.getPackageName()) == PackageManager.PERMISSION_GRANTED;
- }
+ sHasAccessAllEpgPermission = context.checkSelfPermission(
+ "com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA")
+ == PackageManager.PERMISSION_GRANTED;
}
return sHasAccessAllEpgPermission;
}
public static boolean hasAccessWatchedHistory(Context context) {
if (sHasAccessWatchedHistoryPermission == null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- sHasAccessWatchedHistoryPermission = context.checkSelfPermission(
- "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS")
- == PackageManager.PERMISSION_GRANTED;
- } else {
- sHasAccessWatchedHistoryPermission = context.getPackageManager().checkPermission(
- "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS",
- context.getPackageName()) == PackageManager.PERMISSION_GRANTED;
- }
+ sHasAccessWatchedHistoryPermission = context.checkSelfPermission(
+ "com.android.providers.tv.permission.ACCESS_WATCHED_PROGRAMS")
+ == PackageManager.PERMISSION_GRANTED;
}
return sHasAccessWatchedHistoryPermission;
}
public static boolean hasModifyParentalControls(Context context) {
if (sHasModifyParentalControlsPermission == null) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- sHasModifyParentalControlsPermission = context.checkSelfPermission(
- "android.permission.MODIFY_PARENTAL_CONTROLS")
- == PackageManager.PERMISSION_GRANTED;
- } else {
- sHasModifyParentalControlsPermission = context.getPackageManager().checkPermission(
- "android.permission.MODIFY_PARENTAL_CONTROLS",
- context.getPackageName()) == PackageManager.PERMISSION_GRANTED;
- }
+ sHasModifyParentalControlsPermission = context.checkSelfPermission(
+ "android.permission.MODIFY_PARENTAL_CONTROLS")
+ == PackageManager.PERMISSION_GRANTED;
}
return sHasModifyParentalControlsPermission;
}
public static boolean hasReadTvListings(Context context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- return context.checkSelfPermission("android.permission.READ_TV_LISTINGS")
- == PackageManager.PERMISSION_GRANTED;
- } else {
- return context.getPackageManager().checkPermission(
- "android.permission.MODIFY_PARENTAL_CONTROLS",
- context.getPackageName()) == PackageManager.PERMISSION_GRANTED;
- }
+ return context.checkSelfPermission(PERMISSION_READ_TV_LISTINGS)
+ == PackageManager.PERMISSION_GRANTED;
}
}
diff --git a/src/com/android/tv/util/PipInputManager.java b/src/com/android/tv/util/PipInputManager.java
index 03bdc681..2c51d5a0 100644
--- a/src/com/android/tv/util/PipInputManager.java
+++ b/src/com/android/tv/util/PipInputManager.java
@@ -149,6 +149,7 @@ public class PipInputManager {
if (mStarted) {
return;
}
+ mStarted = true;
mInputManager.addCallback(mTvInputCallback);
mChannelTuner.addListener(mChannelTunerListener);
initializePipInputList();
@@ -161,6 +162,7 @@ public class PipInputManager {
if (!mStarted) {
return;
}
+ mStarted = false;
mInputManager.removeCallback(mTvInputCallback);
mChannelTuner.removeListener(mChannelTunerListener);
mPipInputMap.clear();
diff --git a/src/com/android/tv/util/RecurringRunner.java b/src/com/android/tv/util/RecurringRunner.java
index 5e65715e..4135bd4e 100644
--- a/src/com/android/tv/util/RecurringRunner.java
+++ b/src/com/android/tv/util/RecurringRunner.java
@@ -52,13 +52,13 @@ public final class RecurringRunner {
mRunnable = runnable;
mOnStopRunnable = onStopRunnable;
mIntervalMs = intervalMs;
- if (DEBUG) Log.i(TAG, "Delaying " + (intervalMs / 1000.0) + " seconds");
mName = runnable.getClass().getCanonicalName();
+ if (DEBUG) Log.i(TAG, " Delaying " + mName + " " + (intervalMs / 1000.0) + " seconds");
mHandler = new Handler(mContext.getMainLooper());
}
public void start() {
- SoftPreconditions.checkState(!mRunning, TAG, "start is called twice.");
+ SoftPreconditions.checkState(!mRunning, TAG, mName + " start is called twice.");
if (mRunning) {
return;
}
@@ -107,7 +107,7 @@ public final class RecurringRunner {
if (!posted) {
Log.w(TAG, "Scheduling a future run of " + mName + " at " + new Date(next) + "failed");
}
- if (DEBUG) Log.i(TAG, "Actual delay is " + (delay / 1000.0) + " seconds.");
+ if (DEBUG) Log.i(TAG, "Actual delay of " + mName + " is " + (delay / 1000.0) + " seconds.");
}
private SharedPreferences getSharedPreferences() {
diff --git a/src/com/android/tv/util/SearchManagerHelper.java b/src/com/android/tv/util/SearchManagerHelper.java
index 5ec1b455..b6e34d7a 100644
--- a/src/com/android/tv/util/SearchManagerHelper.java
+++ b/src/com/android/tv/util/SearchManagerHelper.java
@@ -18,7 +18,6 @@ package com.android.tv.util;
import android.app.SearchManager;
import android.content.Context;
-import android.os.Build;
import android.os.Bundle;
import android.os.UserHandle;
import android.util.Log;
@@ -52,15 +51,8 @@ public final class SearchManagerHelper {
public void launchAssistAction() {
try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- SearchManager.class.getDeclaredMethod(
- "launchLegacyAssist", String.class, Integer.TYPE, Bundle.class).invoke(
- mSearchManager, null, UserHandle.myUserId(), null);
- } else {
- SearchManager.class.getDeclaredMethod(
- "launchAssistAction", Integer.TYPE, String.class, Integer.TYPE).invoke(
- mSearchManager, 0, null, UserHandle.myUserId());
- }
+ SearchManager.class.getDeclaredMethod("launchLegacyAssist", String.class, Integer.TYPE,
+ Bundle.class).invoke(mSearchManager, null, UserHandle.myUserId(), null);
} catch (NoSuchMethodException | IllegalArgumentException | IllegalAccessException
| InvocationTargetException e) {
Log.e(TAG, "Fail to call SearchManager.launchAssistAction", e);
diff --git a/src/com/android/tv/util/SetupUtils.java b/src/com/android/tv/util/SetupUtils.java
index a7d9c423..8223a81c 100644
--- a/src/com/android/tv/util/SetupUtils.java
+++ b/src/com/android/tv/util/SetupUtils.java
@@ -20,10 +20,11 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
import android.media.tv.TvContract;
import android.media.tv.TvInputInfo;
import android.media.tv.TvInputManager;
-import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
@@ -36,6 +37,9 @@ import com.android.tv.TvApplication;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.Channel;
import com.android.tv.data.ChannelDataManager;
+import com.android.tv.data.epg.EpgFetcher;
+import com.android.tv.experiments.Experiments;
+import com.android.tv.tuner.tvinput.TunerTvInputService;
import java.util.Collections;
import java.util.HashSet;
@@ -64,23 +68,23 @@ public class SetupUtils {
private final Set<String> mSetUpInputs;
private final Set<String> mRecognizedInputs;
private boolean mIsFirstTune;
- private final String mUsbTunerInputId;
+ private final String mTunerInputId;
private SetupUtils(TvApplication tvApplication) {
mTvApplication = tvApplication;
mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(tvApplication);
mSetUpInputs = new ArraySet<>();
mSetUpInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_SET_UP_INPUTS,
- Collections.<String>emptySet()));
+ Collections.emptySet()));
mKnownInputs = new ArraySet<>();
mKnownInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_KNOWN_INPUTS,
- Collections.<String>emptySet()));
+ Collections.emptySet()));
mRecognizedInputs = new ArraySet<>();
mRecognizedInputs.addAll(mSharedPreferences.getStringSet(PREF_KEY_RECOGNIZED_INPUTS,
mKnownInputs));
mIsFirstTune = mSharedPreferences.getBoolean(PREF_KEY_IS_FIRST_TUNE, true);
- mUsbTunerInputId = TvContract.buildInputId(new ComponentName(tvApplication,
- com.android.usbtuner.tvinput.UsbTunerTvInputService.class));
+ mTunerInputId = TvContract.buildInputId(new ComponentName(tvApplication,
+ TunerTvInputService.class));
}
/**
@@ -264,15 +268,11 @@ public class SetupUtils {
* @param context The Context used for granting permission.
*/
public static void grantEpgPermissionToSetUpPackages(Context context) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- // Can't grant permission.
- return;
- }
-
// Find all already-verified packages.
Set<String> setUpPackages = new HashSet<>();
SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
- for (String input : sp.getStringSet(PREF_KEY_SET_UP_INPUTS, Collections.<String>emptySet())) {
+ for (String input : sp.getStringSet(PREF_KEY_SET_UP_INPUTS,
+ Collections.<String>emptySet())) {
if (!TextUtils.isEmpty(input)) {
ComponentName componentName = ComponentName.unflattenFromString(input);
if (componentName != null) {
@@ -293,21 +293,18 @@ public class SetupUtils {
* @param packageName The name of the package to give permission.
*/
public static void grantEpgPermission(Context context, String packageName) {
- // TvProvider allows granting of Uri permissions starting from MNC.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (DEBUG) {
- Log.d(TAG, "grantEpgPermission(context=" + context + ", packageName=" + packageName
- + ")");
- }
- try {
- int modeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
- context.grantUriPermission(packageName, TvContract.Channels.CONTENT_URI, modeFlags);
- context.grantUriPermission(packageName, TvContract.Programs.CONTENT_URI, modeFlags);
- } catch (SecurityException e) {
- Log.e(TAG, "Either TvProvider does not allow granting of Uri permissions or the app"
- + " does not have permission.", e);
- }
+ if (DEBUG) {
+ Log.d(TAG, "grantEpgPermission(context=" + context + ", packageName=" + packageName
+ + ")");
+ }
+ try {
+ int modeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
+ context.grantUriPermission(packageName, TvContract.Channels.CONTENT_URI, modeFlags);
+ context.grantUriPermission(packageName, TvContract.Programs.CONTENT_URI, modeFlags);
+ } catch (SecurityException e) {
+ Log.e(TAG, "Either TvProvider does not allow granting of Uri permissions or the app"
+ + " does not have permission.", e);
}
}
@@ -335,17 +332,31 @@ public class SetupUtils {
// A USB tuner device can be temporarily unplugged. We do not remove the USB tuner input
// from the known inputs so that the input won't appear as a new input whenever the user
// plugs in the USB tuner device again.
- removedInputList.remove(mUsbTunerInputId);
+ removedInputList.remove(mTunerInputId);
if (!removedInputList.isEmpty()) {
+ boolean inputPackageDeleted = false;
for (String input : removedInputList) {
- mRecognizedInputs.remove(input);
- mSetUpInputs.remove(input);
- mKnownInputs.remove(input);
+ try {
+ // Just after booting, input list from TvInputManager are not reliable.
+ // So we need to double-check package existence. b/29034900
+ mTvApplication.getPackageManager().getPackageInfo(
+ ComponentName.unflattenFromString(input)
+ .getPackageName(), PackageManager.GET_ACTIVITIES);
+ Log.i(TAG, "TV input (" + input + ") is removed but package is not deleted");
+ } catch (NameNotFoundException e) {
+ Log.i(TAG, "TV input (" + input + ") and its package are removed");
+ mRecognizedInputs.remove(input);
+ mSetUpInputs.remove(input);
+ mKnownInputs.remove(input);
+ inputPackageDeleted = true;
+ }
+ }
+ if (inputPackageDeleted) {
+ mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs)
+ .putStringSet(PREF_KEY_KNOWN_INPUTS, mKnownInputs)
+ .putStringSet(PREF_KEY_RECOGNIZED_INPUTS, mRecognizedInputs).apply();
}
- mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs)
- .putStringSet(PREF_KEY_KNOWN_INPUTS, mKnownInputs)
- .putStringSet(PREF_KEY_RECOGNIZED_INPUTS, mRecognizedInputs).apply();
}
}
@@ -353,7 +364,7 @@ public class SetupUtils {
* Called when an setup is done. Once it is called, {@link #isSetupDone} returns {@code true}
* for {@code inputId}.
*/
- public void onSetupDone(String inputId) {
+ private void onSetupDone(String inputId) {
SoftPreconditions.checkState(inputId != null);
if (DEBUG) Log.d(TAG, "onSetupDone: input=" + inputId);
if (!mRecognizedInputs.contains(inputId)) {
@@ -371,5 +382,13 @@ public class SetupUtils {
mSetUpInputs.add(inputId);
mSharedPreferences.edit().putStringSet(PREF_KEY_SET_UP_INPUTS, mSetUpInputs).apply();
}
+ // Start fetching program guide data for internal tuners.
+ Context context = mTvApplication.getApplicationContext();
+ if (Utils.isInternalTvInput(context, inputId)) {
+ if (context.checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
+ == PackageManager.PERMISSION_GRANTED && Experiments.CLOUD_EPG.get()) {
+ EpgFetcher.getInstance(context).startImmediately();
+ }
+ }
}
}
diff --git a/src/com/android/tv/util/SystemProperties.java b/src/com/android/tv/util/SystemProperties.java
index 235161b6..e737f233 100644
--- a/src/com/android/tv/util/SystemProperties.java
+++ b/src/com/android/tv/util/SystemProperties.java
@@ -36,12 +36,6 @@ public final class SystemProperties {
"tv_allow_strict_mode", true);
/**
- * Allow Strict death penalty for eng builds.
- */
- public static final BooleanSystemProperty ALLOW_DEATH_PENALTY = new BooleanSystemProperty(
- "tv_allow_death_penalty", true);
-
- /**
* When true {@link android.view.KeyEvent}s are logged. Defaults to false.
*/
public static final BooleanSystemProperty LOG_KEYEVENT = new BooleanSystemProperty(
diff --git a/src/com/android/tv/util/TimeShiftUtils.java b/src/com/android/tv/util/TimeShiftUtils.java
new file mode 100644
index 00000000..238d0e74
--- /dev/null
+++ b/src/com/android/tv/util/TimeShiftUtils.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import java.util.concurrent.TimeUnit;
+
+// TODO: move related functions in TimeShiftManger here.
+/**
+ * A class that includes convenience methods for time shift plays.
+ */
+public class TimeShiftUtils {
+ private static final String TAG = "TimeShiftUtils";
+ private static final boolean DEBUG = false;
+
+ private static final long SHORT_PROGRAM_THRESHOLD_MILLIS = TimeUnit.MINUTES.toMillis(46);
+ private static final int[] SHORT_PROGRAM_SPEED_FACTORS = new int[] {2, 4, 12, 48};
+ private static final int[] LONG_PROGRAM_SPEED_FACTORS = new int[] {2, 8, 32, 128};
+
+ /**
+ * The maximum play speed level support by time shift play. In other words, the valid
+ * speed levels are ranged from 0 to MAX_SPEED_LEVEL (included).
+ */
+ public static final int MAX_SPEED_LEVEL = SHORT_PROGRAM_SPEED_FACTORS.length - 1;
+
+ /**
+ * Returns real speeds used in time shift play. This method is only for fast-forwarding and
+ * rewinding. The normal play speed is not addressed here.
+ *
+ * @param speedLevel the valid value is ranged from 0 to {@link MAX_SPPED_LEVEL}.
+ * @param programDurationMillis the length of program under playing.
+ * @throws IndexOutOfBoundsException if speed level is out of its range.
+ */
+ public static int getPlaybackSpeed(int speedLevel, long programDurationMillis)
+ throws IndexOutOfBoundsException {
+ return (programDurationMillis > SHORT_PROGRAM_THRESHOLD_MILLIS) ?
+ LONG_PROGRAM_SPEED_FACTORS[speedLevel] : SHORT_PROGRAM_SPEED_FACTORS[speedLevel];
+ }
+
+ /**
+ * Returns the maxium possible play speed according to the program's length.
+ * @param programDurationMillis the length of program under playing.
+ */
+ public static int getMaxPlaybackSpeed(long programDurationMillis) {
+ return (programDurationMillis > SHORT_PROGRAM_THRESHOLD_MILLIS) ?
+ LONG_PROGRAM_SPEED_FACTORS[MAX_SPEED_LEVEL]
+ : SHORT_PROGRAM_SPEED_FACTORS[MAX_SPEED_LEVEL];
+ }
+}
+
diff --git a/src/com/android/tv/util/ToastUtils.java b/src/com/android/tv/util/ToastUtils.java
new file mode 100644
index 00000000..34346b2a
--- /dev/null
+++ b/src/com/android/tv/util/ToastUtils.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import android.content.Context;
+import android.support.annotation.MainThread;
+import android.widget.Toast;
+
+import java.lang.ref.WeakReference;
+
+/**
+ * A utility class for the toast message.
+ */
+public class ToastUtils {
+ private static WeakReference<Toast> sToast;
+
+ /**
+ * Shows the toast message after canceling the previous one.
+ */
+ @MainThread
+ public static void show(Context context, CharSequence text, int duration) {
+ if (sToast != null && sToast.get() != null) {
+ sToast.get().cancel();
+ }
+ Toast toast = Toast.makeText(context, text, duration);
+ toast.show();
+ sToast = new WeakReference<>(toast);
+ }
+}
diff --git a/src/com/android/tv/util/TvInputManagerHelper.java b/src/com/android/tv/util/TvInputManagerHelper.java
index b4149637..121f56ed 100644
--- a/src/com/android/tv/util/TvInputManagerHelper.java
+++ b/src/com/android/tv/util/TvInputManagerHelper.java
@@ -26,6 +26,7 @@ import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
+import com.android.tv.Features;
import com.android.tv.common.SoftPreconditions;
import com.android.tv.parental.ContentRatingsManager;
import com.android.tv.parental.ParentalControlSettings;
@@ -37,21 +38,12 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
public class TvInputManagerHelper {
private static final String TAG = "TvInputManagerHelper";
private static final boolean DEBUG = false;
-
- // Hardcoded list for known bundled inputs not written by OEM/SOCs.
- // Bundled (system) inputs not in the list will get the high priority
- // so they and their channels come first in the UI.
- private static final Set<String> BUNDLED_PACKAGE_SET = new HashSet<>();
-
- static {
- BUNDLED_PACKAGE_SET.add("com.android.tv");
- BUNDLED_PACKAGE_SET.add("com.android.usbtuner");
- }
+ private static final String[] PARTNER_TUNER_INPUT_PREFIX_BLACKLIST = {
+ };
private final Context mContext;
private final TvInputManager mTvInputManager;
@@ -62,6 +54,9 @@ public class TvInputManagerHelper {
@Override
public void onInputStateChanged(String inputId, int state) {
if (DEBUG) Log.d(TAG, "onInputStateChanged " + inputId + " state=" + state);
+ if (isInBlackList(inputId)) {
+ return;
+ }
mInputStateMap.put(inputId, state);
for (TvInputCallback callback : mCallbacks) {
callback.onInputStateChanged(inputId, state);
@@ -71,6 +66,9 @@ public class TvInputManagerHelper {
@Override
public void onInputAdded(String inputId) {
if (DEBUG) Log.d(TAG, "onInputAdded " + inputId);
+ if (isInBlackList(inputId)) {
+ return;
+ }
TvInputInfo info = mTvInputManager.getTvInputInfo(inputId);
if (info != null) {
mInputMap.put(inputId, info);
@@ -93,16 +91,34 @@ public class TvInputManagerHelper {
for (TvInputCallback callback : mCallbacks) {
callback.onInputRemoved(inputId);
}
+ ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey(
+ inputId));
}
@Override
public void onInputUpdated(String inputId) {
if (DEBUG) Log.d(TAG, "onInputUpdated " + inputId);
+ if (isInBlackList(inputId)) {
+ return;
+ }
TvInputInfo info = mTvInputManager.getTvInputInfo(inputId);
mInputMap.put(inputId, info);
for (TvInputCallback callback : mCallbacks) {
callback.onInputUpdated(inputId);
}
+ ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey(
+ inputId));
+ }
+
+ @Override
+ public void onTvInputInfoUpdated(TvInputInfo inputInfo) {
+ if (DEBUG) Log.d(TAG, "onTvInputInfoUpdated " + inputInfo);
+ mInputMap.put(inputInfo.getId(), inputInfo);
+ for (TvInputCallback callback : mCallbacks) {
+ callback.onTvInputInfoUpdated(inputInfo);
+ }
+ ImageCache.getInstance().remove(ImageLoader.LoadTvInputLogoTask.getTvInputLogoKey(
+ inputInfo.getId()));
}
};
@@ -134,6 +150,9 @@ public class TvInputManagerHelper {
for (TvInputInfo input : mTvInputManager.getTvInputList()) {
if (DEBUG) Log.d(TAG, "Input detected " + input);
String inputId = input.getId();
+ if (isInBlackList(inputId)) {
+ continue;
+ }
mInputMap.put(inputId, input);
int state = mTvInputManager.getInputState(inputId);
mInputStateMap.put(inputId, state);
@@ -204,9 +223,8 @@ public class TvInputManagerHelper {
* Is the input one known bundled inputs not written by OEM/SOCs.
*/
public boolean isBundledInput(TvInputInfo inputInfo) {
- return inputInfo != null
- && BUNDLED_PACKAGE_SET.contains(
- inputInfo.getServiceInfo().applicationInfo.packageName);
+ return inputInfo != null && Utils.isInBundledPackageSet(inputInfo.getServiceInfo()
+ .applicationInfo.packageName);
}
/**
@@ -236,10 +254,7 @@ public class TvInputManagerHelper {
public boolean hasTvInputInfo(String inputId) {
SoftPreconditions.checkState(mStarted, TAG,
"hasTvInputInfo() called before TvInputManagerHelper was started.");
- if (!mStarted) {
- return false;
- }
- return !TextUtils.isEmpty(inputId) && mInputMap.get(inputId) != null;
+ return mStarted && !TextUtils.isEmpty(inputId) && mInputMap.get(inputId) != null;
}
public TvInputInfo getTvInputInfo(String inputId) {
@@ -306,6 +321,18 @@ public class TvInputManagerHelper {
return mContentRatingsManager;
}
+ private boolean isInBlackList(String inputId) {
+ if (!Features.USE_PARTNER_INPUT_BLACKLIST.isEnabled(mContext)) {
+ return false;
+ }
+ for (String disabledTunerInputPrefix : PARTNER_TUNER_INPUT_PREFIX_BLACKLIST) {
+ if (inputId.contains(disabledTunerInputPrefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Default comparator for TvInputInfo.
*
diff --git a/src/com/android/tv/util/TvProviderUriMatcher.java b/src/com/android/tv/util/TvProviderUriMatcher.java
new file mode 100644
index 00000000..749e4aa3
--- /dev/null
+++ b/src/com/android/tv/util/TvProviderUriMatcher.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tv.util;
+
+import android.content.UriMatcher;
+import android.media.tv.TvContract;
+import android.net.Uri;
+import android.support.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Utility class to aid in matching URIs in TvProvider.
+ */
+public class TvProviderUriMatcher {
+ private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({MATCH_CHANNEL, MATCH_CHANNEL_ID, MATCH_PROGRAM, MATCH_PROGRAM_ID,
+ MATCH_RECORDED_PROGRAM, MATCH_RECORDED_PROGRAM_ID, MATCH_WATCHED_PROGRAM_ID})
+ private @interface TvProviderUriMatchCode {}
+ /** The code for the channels URI. */
+ public static final int MATCH_CHANNEL = 1;
+ /** The code for the channel URI. */
+ public static final int MATCH_CHANNEL_ID = 2;
+ /** The code for the programs URI. */
+ public static final int MATCH_PROGRAM = 3;
+ /** The code for the program URI. */
+ public static final int MATCH_PROGRAM_ID = 4;
+ /** The code for the recorded programs URI. */
+ public static final int MATCH_RECORDED_PROGRAM = 5;
+ /** The code for the recorded program URI. */
+ public static final int MATCH_RECORDED_PROGRAM_ID = 6;
+ /** The code for the watched program URI. */
+ public static final int MATCH_WATCHED_PROGRAM_ID = 7;
+ static {
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "channel", MATCH_CHANNEL);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "channel/#", MATCH_CHANNEL_ID);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "program", MATCH_PROGRAM);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "program/#", MATCH_PROGRAM_ID);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "recorded_program", MATCH_RECORDED_PROGRAM);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "recorded_program/#", MATCH_RECORDED_PROGRAM_ID);
+ URI_MATCHER.addURI(TvContract.AUTHORITY, "watched_program/#", MATCH_WATCHED_PROGRAM_ID);
+ }
+
+ private TvProviderUriMatcher() { }
+
+ /**
+ * Try to match against the path in a url.
+ *
+ * @see UriMatcher#match
+ */
+ @SuppressWarnings("WrongConstant")
+ @TvProviderUriMatchCode public static int match(Uri uri) {
+ return URI_MATCHER.match(uri);
+ }
+}
diff --git a/src/com/android/tv/util/TvSettings.java b/src/com/android/tv/util/TvSettings.java
index 133fdd72..97ff59d6 100644
--- a/src/com/android/tv/util/TvSettings.java
+++ b/src/com/android/tv/util/TvSettings.java
@@ -34,9 +34,6 @@ import java.util.Set;
public final class TvSettings {
private TvSettings() {}
- public static final String PREFS_FILE = "settings";
- public static final String PREF_TV_WATCH_LOGGING_ENABLED = "tv_watch_logging_enabled";
- public static final String PREF_CLOSED_CAPTION_ENABLED = "is_cc_enabled"; // boolean value
public static final String PREF_DISPLAY_MODE = "display_mode"; // int value
public static final String PREF_PIP_LAYOUT = "pip_layout"; // int value
public static final String PREF_PIP_SIZE = "pip_size"; // int value
@@ -49,7 +46,6 @@ public final class TvSettings {
public @interface PipSound {}
public static final int PIP_SOUND_MAIN = 0;
public static final int PIP_SOUND_PIP_WINDOW = PIP_SOUND_MAIN + 1;
- public static final int PIP_SOUND_LAST = PIP_SOUND_PIP_WINDOW;
// PIP layouts
@Retention(RetentionPolicy.SOURCE)
@@ -225,7 +221,7 @@ public final class TvSettings {
private static Set<String> getContentRatingSystemSet(Context context) {
return new HashSet<>(PreferenceManager.getDefaultSharedPreferences(context)
- .getStringSet(PREF_CONTENT_RATING_SYSTEMS, Collections.<String>emptySet()));
+ .getStringSet(PREF_CONTENT_RATING_SYSTEMS, Collections.emptySet()));
}
@ContentRatingLevel
diff --git a/src/com/android/tv/util/TvTrackInfoUtils.java b/src/com/android/tv/util/TvTrackInfoUtils.java
index 3006f963..c004f001 100644
--- a/src/com/android/tv/util/TvTrackInfoUtils.java
+++ b/src/com/android/tv/util/TvTrackInfoUtils.java
@@ -50,8 +50,12 @@ public class TvTrackInfoUtils {
if (rhs == null) {
return 1;
}
- boolean rhsLangMatch = Utils.isEqualLanguage(rhs.getLanguage(), language);
- boolean lhsLangMatch = Utils.isEqualLanguage(lhs.getLanguage(), language);
+ // Assumes {@code null} language matches to any language since it means user hasn't
+ // selected any track before or selected a track without language information.
+ boolean rhsLangMatch = language == null || Utils.isEqualLanguage(rhs.getLanguage(),
+ language);
+ boolean lhsLangMatch = language == null || Utils.isEqualLanguage(lhs.getLanguage(),
+ language);
if (rhsLangMatch) {
if (lhsLangMatch) {
boolean rhsCountMatch = rhs.getAudioChannelCount() == channelCount;
diff --git a/src/com/android/tv/util/Utils.java b/src/com/android/tv/util/Utils.java
index a763fe58..99d34431 100644
--- a/src/com/android/tv/util/Utils.java
+++ b/src/com/android/tv/util/Utils.java
@@ -23,35 +23,37 @@ import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.content.res.ColorStateList;
import android.content.res.Configuration;
-import android.content.res.Resources;
-import android.content.res.Resources.Theme;
import android.database.Cursor;
import android.media.tv.TvContract;
import android.media.tv.TvContract.Channels;
+import android.media.tv.TvContract.Programs.Genres;
import android.media.tv.TvInputInfo;
import android.media.tv.TvTrackInfo;
import android.net.Uri;
-import android.os.Build;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.text.format.DateUtils;
+import android.util.ArraySet;
import android.util.Log;
import android.view.View;
-import android.widget.Toast;
+import com.android.tv.ApplicationSingletons;
import com.android.tv.R;
import com.android.tv.TvApplication;
+import com.android.tv.common.SoftPreconditions;
import com.android.tv.data.Channel;
+import com.android.tv.data.GenreItems;
import com.android.tv.data.Program;
import com.android.tv.data.StreamInfo;
+import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
@@ -69,13 +71,17 @@ public class Utils {
private static final String TAG = "Utils";
private static final boolean DEBUG = false;
- private static final SimpleDateFormat ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
+ private static final SimpleDateFormat ISO_8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ",
+ Locale.US);
public static final String EXTRA_KEY_KEYCODE = "keycode";
public static final String EXTRA_KEY_ACTION = "action";
public static final String EXTRA_ACTION_SHOW_TV_INPUT ="show_tv_input";
public static final String EXTRA_KEY_FROM_LAUNCHER = "from_launcher";
- public static final String EXTRA_KEY_RECORDING_URI = "recording_uri";
+ public static final String EXTRA_KEY_RECORDED_PROGRAM_ID = "recorded_program_id";
+ public static final String EXTRA_KEY_RECORDED_PROGRAM_SEEK_TIME = "recorded_program_seek_time";
+ public static final String EXTRA_KEY_RECORDED_PROGRAM_PIN_CHECKED =
+ "recorded_program_pin_checked";
// Query parameter in the intent of starting MainActivity.
public static final String PARAM_SOURCE = "source";
@@ -87,6 +93,10 @@ public class Utils {
private static final String PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT =
"last_watched_channel_id_for_input_";
private static final String PREF_KEY_LAST_WATCHED_CHANNEL_URI = "last_watched_channel_uri";
+ private static final String PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID =
+ "last_watched_tuner_input_id";
+ private static final String PREF_KEY_RECORDING_FAILED_REASONS =
+ "recording_failed_reasons";
private static final int VIDEO_SD_WIDTH = 704;
private static final int VIDEO_SD_HEIGHT = 480;
@@ -103,6 +113,18 @@ public class Utils {
private static final int AUDIO_CHANNEL_SURROUND_6 = 6;
private static final int AUDIO_CHANNEL_SURROUND_8 = 8;
+ private static final long RECORDING_FAILED_REASON_NONE = 0;
+ private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1);
+
+ // Hardcoded list for known bundled inputs not written by OEM/SOCs.
+ // Bundled (system) inputs not in the list will get the high priority
+ // so they and their channels come first in the UI.
+ private static final Set<String> BUNDLED_PACKAGE_SET = new ArraySet<>();
+
+ static {
+ BUNDLED_PACKAGE_SET.add("com.android.tv");
+ }
+
private enum AspectRatio {
ASPECT_RATIO_4_3(4, 3),
ASPECT_RATIO_16_9(16, 9),
@@ -166,13 +188,34 @@ public class Utils {
throw new IllegalArgumentException("channelId should be equal to or larger than 0");
}
PreferenceManager.getDefaultSharedPreferences(context).edit()
- .putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, channelId).apply();
- PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, channelId)
.putLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID_FOR_INPUT + channel.getInputId(),
- channelId).apply();
+ channelId)
+ .putString(PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID, channel.getInputId())
+ .apply();
}
}
+ /**
+ * Sets recording failed reason.
+ */
+ public static void setRecordingFailedReason(Context context, int reason) {
+ long reasons = getRecordingFailedReasons(context) | 0x1 << reason;
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putLong(PREF_KEY_RECORDING_FAILED_REASONS, reasons)
+ .apply();
+ }
+
+ /**
+ * Clears recording failed reason.
+ */
+ public static void clearRecordingFailedReason(Context context, int reason) {
+ long reasons = getRecordingFailedReasons(context) & ~(0x1 << reason);
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putLong(PREF_KEY_RECORDING_FAILED_REASONS, reasons)
+ .apply();
+ }
+
public static long getLastWatchedChannelId(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getLong(PREF_KEY_LAST_WATCHED_CHANNEL_ID, Channel.INVALID_ID);
@@ -189,6 +232,28 @@ public class Utils {
}
/**
+ * Returns the last watched tuner input id.
+ */
+ public static String getLastWatchedTunerInputId(Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getString(PREF_KEY_LAST_WATCHED_TUNER_INPUT_ID, null);
+ }
+
+ private static long getRecordingFailedReasons(Context context) {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getLong(PREF_KEY_RECORDING_FAILED_REASONS,
+ RECORDING_FAILED_REASON_NONE);
+ }
+
+ /**
+ * Checks do recording failed reason exist.
+ */
+ public static boolean hasRecordingFailedReason(Context context, int reason) {
+ long reasons = getRecordingFailedReasons(context);
+ return (reasons & 0x1 << reason) != 0;
+ }
+
+ /**
* Returns {@code true}, if {@code uri} specifies an input, which is usually generated
* from {@link TvContract#buildChannelsUriForInput}.
*/
@@ -286,11 +351,25 @@ public class Utils {
}
@VisibleForTesting
- static String getDurationString(Context context, long baseMillis,
- long startUtcMillis, long endUtcMillis, boolean useShortFormat, int flag) {
- flag |= DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_TIME
+ static String getDurationString(Context context, long baseMillis, long startUtcMillis,
+ long endUtcMillis, boolean useShortFormat, int flag) {
+ return getDurationString(context, startUtcMillis, endUtcMillis,
+ useShortFormat, !isInGivenDay(baseMillis, startUtcMillis), true, flag);
+ }
+
+ /**
+ * Returns duration string according to the time format, may not contain date information.
+ * Note: At least one of showDate and showTime should be true.
+ */
+ public static String getDurationString(Context context, long startUtcMillis, long endUtcMillis,
+ boolean useShortFormat, boolean showDate, boolean showTime, int flag) {
+ flag |= DateUtils.FORMAT_ABBREV_MONTH
| ((useShortFormat) ? DateUtils.FORMAT_NUMERIC_DATE : 0);
- if (!isInGivenDay(baseMillis, startUtcMillis)) {
+ SoftPreconditions.checkArgument(showTime || showDate);
+ if (showTime) {
+ flag |= DateUtils.FORMAT_SHOW_TIME;
+ }
+ if (showDate) {
flag |= DateUtils.FORMAT_SHOW_DATE;
}
if (startUtcMillis != endUtcMillis && useShortFormat) {
@@ -300,13 +379,17 @@ public class Utils {
if (!isInGivenDay(startUtcMillis, endUtcMillis - 1)
&& endUtcMillis - startUtcMillis < TimeUnit.HOURS.toMillis(11)) {
// Do not show date for short format.
- // Extracting a day is needed because {@link DateUtils@formatDateRange}
- // adds date if the duration covers multiple days.
+ // Subtracting one day is needed because {@link DateUtils@formatDateRange}
+ // automatically shows date if the duration covers multiple days.
return DateUtils.formatDateRange(context,
startUtcMillis, endUtcMillis - TimeUnit.DAYS.toMillis(1), flag);
}
}
- return DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis, flag);
+ // Workaround of b/28740989.
+ // Add 1 msec to endUtcMillis to avoid DateUtils' bug with a duration of 12:00AM~12:00AM.
+ String dateRange = DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis, flag);
+ return startUtcMillis == endUtcMillis || dateRange.contains("–") ? dateRange
+ : DateUtils.formatDateRange(context, startUtcMillis, endUtcMillis + 1, flag);
}
@VisibleForTesting
@@ -321,6 +404,39 @@ public class Utils {
== Utils.floorTime(subjectTimeInMillis + offset, DAY_IN_MS);
}
+ /**
+ * Calculate how many days between two milliseconds.
+ */
+ public static int computeDateDifference(long startTimeMs, long endTimeMs) {
+ Calendar calFrom = Calendar.getInstance();
+ Calendar calTo = Calendar.getInstance();
+ calFrom.setTime(new Date(startTimeMs));
+ calTo.setTime(new Date(endTimeMs));
+ resetCalendar(calFrom);
+ resetCalendar(calTo);
+ return (int) ((calTo.getTimeInMillis() - calFrom.getTimeInMillis()) / ONE_DAY_MS);
+ }
+
+ private static void resetCalendar(Calendar cal) {
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 0);
+ }
+
+ /**
+ * Returns the last millisecond of a day which the millis belongs to.
+ */
+ public static long getLastMillisecondOfDay(long millis) {
+ Calendar calender = Calendar.getInstance();
+ calender.setTime(new Date(millis));
+ calender.set(Calendar.HOUR_OF_DAY, 23);
+ calender.set(Calendar.MINUTE, 59);
+ calender.set(Calendar.SECOND, 59);
+ calender.set(Calendar.MILLISECOND, 999);
+ return calender.getTimeInMillis();
+ }
+
public static String getAspectRatioString(int width, int height) {
if (width == 0 || height == 0) {
return "";
@@ -510,9 +626,27 @@ public class Utils {
/**
* Converts time in milliseconds to a String.
+ *
+ * @param fullFormat {@code true} for returning date string with a full format
+ * (e.g., Mon Aug 15 20:08:35 GMT 2016). {@code false} for a short format,
+ * {e.g., [8/15/16] 8:08 AM}, in which date information would only appears
+ * when the target time is not today.
+ */
+ public static String toTimeString(long timeMillis, boolean fullFormat) {
+ if (fullFormat) {
+ return new Date(timeMillis).toString();
+ } else {
+ long currentTime = System.currentTimeMillis();
+ return (String) DateUtils.formatSameDayTime(timeMillis, System.currentTimeMillis(),
+ SimpleDateFormat.SHORT, SimpleDateFormat.SHORT);
+ }
+ }
+
+ /**
+ * Converts time in milliseconds to a String.
*/
public static String toTimeString(long timeMillis) {
- return new Date(timeMillis).toString();
+ return toTimeString(timeMillis, true);
}
/**
@@ -566,57 +700,7 @@ public class Utils {
* @return index >= 0 && index < collection.size().
*/
public static boolean isIndexValid(@Nullable Collection<?> collection, int index) {
- return collection == null ? false : index >= 0 && index < collection.size();
- }
-
- /**
- * Returns a color integer associated with a particular resource ID.
- *
- * @see #getColor(android.content.res.Resources,int,Theme)
- */
- public static int getColor(Resources res, int id) {
- return getColor(res, id, null);
- }
-
- /**
- * Returns a color integer associated with a particular resource ID.
- *
- * <p>In M version, {@link android.content.res.Resources#getColor(int)} was deprecated and
- * {@link android.content.res.Resources#getColor(int,Theme)} was newly added.
- *
- * @see android.content.res.Resources#getColor(int)
- */
- public static int getColor(Resources res, int id, @Nullable Theme theme) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- return res.getColor(id, theme);
- } else {
- return res.getColor(id);
- }
- }
-
- /**
- * Returns a color state list associated with a particular resource ID.
- *
- * @see #getColorStateList(android.content.res.Resources,int,Theme)
- */
- public static ColorStateList getColorStateList(Resources res, int id) {
- return getColorStateList(res, id, null);
- }
-
- /**
- * Returns a color state list associated with a particular resource ID.
- *
- * <p>In M version, {@link android.content.res.Resources#getColorStateList(int)} was deprecated
- * and {@link android.content.res.Resources#getColorStateList(int,Theme)} was newly added.
- *
- * @see android.content.res.Resources#getColorStateList(int)
- */
- public static ColorStateList getColorStateList(Resources res, int id, @Nullable Theme theme) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- return res.getColorStateList(id, theme);
- } else {
- return res.getColorStateList(id);
- }
+ return collection != null && (index >= 0 && index < collection.size());
}
/**
@@ -632,15 +716,26 @@ public class Utils {
}
/**
+ * Checks where there is any internal TV input.
+ */
+ public static boolean hasInternalTvInputs(Context context, boolean tunerInputOnly) {
+ for (TvInputInfo input : TvApplication.getSingletons(context).getTvInputManagerHelper()
+ .getTvInputInfos(true, tunerInputOnly)) {
+ if (isInternalTvInput(context, input.getId())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
* Returns the internal TV inputs.
*/
public static List<TvInputInfo> getInternalTvInputs(Context context, boolean tunerInputOnly) {
List<TvInputInfo> inputs = new ArrayList<>();
- String contextPackageName = context.getPackageName();
for (TvInputInfo input : TvApplication.getSingletons(context).getTvInputManagerHelper()
.getTvInputInfos(true, tunerInputOnly)) {
- if (contextPackageName.equals(ComponentName.unflattenFromString(input.getId())
- .getPackageName())) {
+ if (isInternalTvInput(context, input.getId())) {
inputs.add(input);
}
}
@@ -656,10 +751,113 @@ public class Utils {
}
/**
- * Shows a toast message to notice that the current feature is a developer feature.
+ * Returns the TV input for the given {@code program}.
+ */
+ @Nullable
+ public static TvInputInfo getTvInputInfoForProgram(Context context, Program program) {
+ if (!Program.isValid(program)) {
+ return null;
+ }
+ return getTvInputInfoForChannelId(context, program.getChannelId());
+ }
+
+ /**
+ * Returns the TV input for the given channel ID.
+ */
+ @Nullable
+ public static TvInputInfo getTvInputInfoForChannelId(Context context, long channelId) {
+ ApplicationSingletons appSingletons = TvApplication.getSingletons(context);
+ Channel channel = appSingletons.getChannelDataManager().getChannel(channelId);
+ if (channel == null) {
+ return null;
+ }
+ return appSingletons.getTvInputManagerHelper().getTvInputInfo(channel.getInputId());
+ }
+
+ /**
+ * Returns the {@link TvInputInfo} for the given input ID.
*/
- public static void showToastMessageForDeveloperFeature(Context context) {
- Toast.makeText(context, "This feature is for developer preview.", Toast.LENGTH_SHORT)
- .show();
+ @Nullable
+ public static TvInputInfo getTvInputInfoForInputId(Context context, String inputId) {
+ return TvApplication.getSingletons(context).getTvInputManagerHelper()
+ .getTvInputInfo(inputId);
+ }
+
+ /**
+ * Deletes a file or a directory.
+ */
+ public static void deleteDirOrFile(File fileOrDirectory) {
+ if (fileOrDirectory.isDirectory()) {
+ for (File child : fileOrDirectory.listFiles()) {
+ deleteDirOrFile(child);
+ }
+ }
+ fileOrDirectory.delete();
+ }
+
+ /**
+ * Checks whether a given package is in our bundled package set.
+ */
+ public static boolean isInBundledPackageSet(String packageName) {
+ return BUNDLED_PACKAGE_SET.contains(packageName);
+ }
+
+ /**
+ * Checks whether a given input is a bundled input.
+ */
+ public static boolean isBundledInput(String inputId) {
+ for (String prefix : BUNDLED_PACKAGE_SET) {
+ if (inputId.startsWith(prefix + "/")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the canonical genre ID's from the {@code genres}.
+ */
+ public static int[] getCanonicalGenreIds(String genres) {
+ if (TextUtils.isEmpty(genres)) {
+ return null;
+ }
+ return getCanonicalGenreIds(Genres.decode(genres));
+ }
+
+ /**
+ * Returns the canonical genre ID's from the {@code genres}.
+ */
+ public static int[] getCanonicalGenreIds(String[] canonicalGenres) {
+ if (canonicalGenres != null && canonicalGenres.length > 0) {
+ int[] results = new int[canonicalGenres.length];
+ int i = 0;
+ for (String canonicalGenre : canonicalGenres) {
+ int genreId = GenreItems.getId(canonicalGenre);
+ if (genreId == GenreItems.ID_ALL_CHANNELS) {
+ // Skip if the genre is unknown.
+ continue;
+ }
+ results[i++] = genreId;
+ }
+ if (i < canonicalGenres.length) {
+ results = Arrays.copyOf(results, i);
+ }
+ return results;
+ }
+ return null;
+ }
+
+ /**
+ * Returns the canonical genres for database.
+ */
+ public static String getCanonicalGenre(int[] canonicalGenreIds) {
+ if (canonicalGenreIds == null || canonicalGenreIds.length == 0) {
+ return null;
+ }
+ String[] genres = new String[canonicalGenreIds.length];
+ for (int i = 0; i < canonicalGenreIds.length; ++i) {
+ genres[i] = GenreItems.getCanonicalGenre(canonicalGenreIds[i]);
+ }
+ return Genres.encode(genres);
}
}