summaryrefslogtreecommitdiff
path: root/app/src/main/java/de
diff options
context:
space:
mode:
authordaniel oeh <daniel.oeh@gmail.com>2014-09-17 20:51:45 +0200
committerdaniel oeh <daniel.oeh@gmail.com>2014-09-17 20:51:45 +0200
commit072639b5b22e816df9f78b5cd8a7d4e5379b6aff (patch)
tree341c574bd6eb64497470e7226b3222b0a7c5a824 /app/src/main/java/de
parent76add8ef68dbc9997e901f4c11c397f581e8eabe (diff)
downloadAntennaPod-072639b5b22e816df9f78b5cd8a7d4e5379b6aff.zip
Changed project structure
Switched from custom layout to standard gradle project structure
Diffstat (limited to 'app/src/main/java/de')
-rw-r--r--app/src/main/java/de/danoeh/antennapod/AppConfig.java7
-rw-r--r--app/src/main/java/de/danoeh/antennapod/PodcastApp.java47
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java32
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java746
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java248
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java370
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java110
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java192
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java125
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java432
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java525
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java428
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java134
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java90
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java38
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java172
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java29
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java513
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java75
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java359
-rw-r--r--app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java372
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java8
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java78
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java57
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java180
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java57
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java112
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java122
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java142
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java306
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java220
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java55
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java229
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java170
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java127
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java110
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java64
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java177
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java74
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java238
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java47
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java95
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java114
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java116
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java37
-rw-r--r--app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java152
-rw-r--r--app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java212
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java89
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java107
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java64
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java30
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java428
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java67
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java138
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java100
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/Chapter.java55
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java140
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/Feed.java445
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java66
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java105
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java77
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java333
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java411
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java89
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java36
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/MediaType.java5
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java34
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java25
-rw-r--r--app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java109
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java76
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java196
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java105
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java121
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java145
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java238
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java476
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java456
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java425
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java288
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java383
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java258
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java131
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java169
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java20
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java80
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java26
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java50
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java146
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java718
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java21
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java12
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java19
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java72
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java65
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java41
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java46
-rw-r--r--app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java56
-rw-r--r--app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java46
-rw-r--r--app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java87
-rw-r--r--app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java21
-rw-r--r--app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java65
-rw-r--r--app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java246
-rw-r--r--app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java146
-rw-r--r--app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java577
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java33
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java46
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java46
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java32
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java50
-rw-r--r--app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java55
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java245
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java54
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java96
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java209
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java1230
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java181
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java10
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java246
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java1080
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java979
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java384
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java14
-rw-r--r--app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java190
-rw-r--r--app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/DBReader.java908
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java895
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java974
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java25
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java367
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java70
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java57
-rw-r--r--app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java1391
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java34
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java19
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java98
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java126
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java112
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java38
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java25
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java51
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java68
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java141
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java42
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java21
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java22
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java46
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java194
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java153
-rw-r--r--app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java42
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java261
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/Converter.java103
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/DownloadError.java52
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/DuckType.java117
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java49
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java15
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java36
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java21
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/LangUtils.java120
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java93
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java34
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java16
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java66
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java22
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/URIUtil.java35
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/URLChecker.java51
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java137
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java20
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java15
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java19
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java19
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java14
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java23
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java25
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java68
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java7
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java305
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java30
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java55
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java118
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java250
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java20
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java17
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java29
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java26
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java191
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java87
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java31
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java9
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java34
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java237
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java69
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java23
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java207
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java784
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java161
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java67
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java78
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java81
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java101
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java26
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java194
-rw-r--r--app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java24
-rw-r--r--app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java97
207 files changed, 33556 insertions, 0 deletions
diff --git a/app/src/main/java/de/danoeh/antennapod/AppConfig.java b/app/src/main/java/de/danoeh/antennapod/AppConfig.java
new file mode 100644
index 000000000..7a75e3a18
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/AppConfig.java
@@ -0,0 +1,7 @@
+package de.danoeh.antennapod;
+
+public final class AppConfig {
+ /** Should be used when setting User-Agent header for HTTP-requests. */
+ public final static String USER_AGENT = "AntennaPod/0.9.9.3";
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/PodcastApp.java b/app/src/main/java/de/danoeh/antennapod/PodcastApp.java
new file mode 100644
index 000000000..74628f3d6
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/PodcastApp.java
@@ -0,0 +1,47 @@
+package de.danoeh.antennapod;
+
+import android.app.Application;
+import android.content.res.Configuration;
+import android.util.Log;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.spa.SPAUtil;
+
+/** Main application class. */
+public class PodcastApp extends Application {
+
+ private static final String TAG = "PodcastApp";
+ public static final String EXPORT_DIR = "export/";
+
+ private static float LOGICAL_DENSITY;
+
+ private static PodcastApp singleton;
+
+ public static PodcastApp getInstance() {
+ return singleton;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ singleton = this;
+ LOGICAL_DENSITY = getResources().getDisplayMetrics().density;
+
+ UserPreferences.createInstance(this);
+ PlaybackPreferences.createInstance(this);
+ EventDistributor.getInstance();
+
+ SPAUtil.sendSPAppsQueryFeedsIntent(this);
+ }
+
+ public static float getLogicalDensity() {
+ return LOGICAL_DENSITY;
+ }
+
+ public boolean isLargeScreen() {
+ return (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_LARGE
+ || (getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE;
+
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java
new file mode 100644
index 000000000..cf7de1709
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java
@@ -0,0 +1,32 @@
+package de.danoeh.antennapod.activity;
+
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import de.danoeh.antennapod.R;
+
+/** Displays the 'about' screen */
+public class AboutActivity extends ActionBarActivity {
+
+ private WebView webview;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().hide();
+ setContentView(R.layout.about);
+ webview = (WebView) findViewById(R.id.webvAbout);
+ webview.setWebViewClient(new WebViewClient() {
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ view.loadUrl(url);
+ return false;
+ }
+
+ });
+ webview.loadUrl("file:///android_asset/about.html");
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java
new file mode 100644
index 000000000..18d27ddda
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java
@@ -0,0 +1,746 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.content.res.TypedArray;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.ActionBarDrawerToggle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v4.app.ListFragment;
+import android.support.v4.widget.DrawerLayout;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.View.OnLongClickListener;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.ImageView.ScaleType;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.adapter.ChapterListAdapter;
+import de.danoeh.antennapod.adapter.NavListAdapter;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.dialog.VariableSpeedDialog;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.feed.SimpleChapter;
+import de.danoeh.antennapod.fragment.CoverFragment;
+import de.danoeh.antennapod.fragment.ItemDescriptionFragment;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+import de.danoeh.antennapod.util.playback.ExternalMedia;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.playback.PlaybackController;
+
+/**
+ * Activity for playing audio files.
+ */
+public class AudioplayerActivity extends MediaplayerActivity implements ItemDescriptionFragment.ItemDescriptionFragmentCallback,
+ NavDrawerActivity {
+ private static final int POS_COVER = 0;
+ private static final int POS_DESCR = 1;
+ private static final int POS_CHAPTERS = 2;
+ private static final int NUM_CONTENT_FRAGMENTS = 3;
+
+ final String TAG = "AudioplayerActivity";
+ private static final String PREFS = "AudioPlayerActivityPreferences";
+ private static final String PREF_KEY_SELECTED_FRAGMENT_POSITION = "selectedFragmentPosition";
+ private static final String PREF_PLAYABLE_ID = "playableId";
+
+ private DrawerLayout drawerLayout;
+ private NavListAdapter navAdapter;
+ private ListView navList;
+ private ActionBarDrawerToggle drawerToggle;
+
+ private Fragment[] detachedFragments;
+
+ private CoverFragment coverFragment;
+ private ItemDescriptionFragment descriptionFragment;
+ private ListFragment chapterFragment;
+
+ private Fragment currentlyShownFragment;
+ private int currentlyShownPosition = -1;
+ /**
+ * Used if onResume was called without loadMediaInfo.
+ */
+ private int savedPosition = -1;
+
+ private TextView txtvTitle;
+ private Button butPlaybackSpeed;
+ private ImageButton butNavLeft;
+ private ImageButton butNavRight;
+
+ private void resetFragmentView() {
+ FragmentTransaction fT = getSupportFragmentManager().beginTransaction();
+
+ if (coverFragment != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing cover fragment");
+ fT.remove(coverFragment);
+ }
+ if (descriptionFragment != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing description fragment");
+ fT.remove(descriptionFragment);
+ }
+ if (chapterFragment != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing chapter fragment");
+ fT.remove(chapterFragment);
+ }
+ if (currentlyShownFragment != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing currently shown fragment");
+ fT.remove(currentlyShownFragment);
+ }
+ for (int i = 0; i < detachedFragments.length; i++) {
+ Fragment f = detachedFragments[i];
+ if (f != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing detached fragment");
+ fT.remove(f);
+ }
+ }
+ fT.commit();
+ currentlyShownFragment = null;
+ coverFragment = null;
+ descriptionFragment = null;
+ chapterFragment = null;
+ currentlyShownPosition = -1;
+ detachedFragments = new Fragment[NUM_CONTENT_FRAGMENTS];
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "onStop");
+ cancelLoadTask();
+ EventDistributor.getInstance().unregister(contentUpdate);
+
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().setDisplayShowTitleEnabled(false);
+ detachedFragments = new Fragment[NUM_CONTENT_FRAGMENTS];
+ }
+
+ private void savePreferences() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Saving preferences");
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ SharedPreferences.Editor editor = prefs.edit();
+ if (currentlyShownPosition >= 0 && controller != null
+ && controller.getMedia() != null) {
+ editor.putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION,
+ currentlyShownPosition);
+ editor.putString(PREF_PLAYABLE_ID, controller.getMedia()
+ .getIdentifier().toString());
+ } else {
+ editor.putInt(PREF_KEY_SELECTED_FRAGMENT_POSITION, -1);
+ editor.putString(PREF_PLAYABLE_ID, "");
+ }
+ editor.commit();
+
+ savedPosition = currentlyShownPosition;
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ drawerToggle.onConfigurationChanged(newConfig);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ // super.onSaveInstanceState(outState); would cause crash
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "onSaveInstanceState");
+
+ }
+
+ @Override
+ protected void onPause() {
+ savePreferences();
+ resetFragmentView();
+ super.onPause();
+ }
+
+ @Override
+ protected void onRestoreInstanceState(Bundle savedInstanceState) {
+ super.onRestoreInstanceState(savedInstanceState);
+ restoreFromPreferences();
+ }
+
+ /**
+ * Tries to restore the selected fragment position from the Activity's
+ * preferences.
+ *
+ * @return true if restoreFromPrefernces changed the activity's state
+ */
+ private boolean restoreFromPreferences() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Restoring instance state");
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ int savedPosition = prefs.getInt(PREF_KEY_SELECTED_FRAGMENT_POSITION,
+ -1);
+ String playableId = prefs.getString(PREF_PLAYABLE_ID, "");
+
+ if (savedPosition != -1
+ && controller != null
+ && controller.getMedia() != null
+ && controller.getMedia().getIdentifier().toString()
+ .equals(playableId)) {
+ switchToFragment(savedPosition);
+ return true;
+ } else if (controller == null || controller.getMedia() == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Couldn't restore from preferences: controller or media was null");
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Couldn't restore from preferences: savedPosition was -1 or saved identifier and playable identifier didn't match.\nsavedPosition: "
+ + savedPosition + ", id: " + playableId
+ );
+
+ }
+ return false;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (StringUtils.equals(getIntent().getAction(), Intent.ACTION_VIEW)) {
+ Intent intent = getIntent();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received VIEW intent: "
+ + intent.getData().getPath());
+ ExternalMedia media = new ExternalMedia(intent.getData().getPath(),
+ MediaType.AUDIO);
+ Intent launchIntent = new Intent(this, PlaybackService.class);
+ launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media);
+ launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED,
+ true);
+ launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false);
+ launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY,
+ true);
+ startService(launchIntent);
+ }
+ if (savedPosition != -1) {
+ switchToFragment(savedPosition);
+ }
+
+ EventDistributor.getInstance().register(contentUpdate);
+ loadData();
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ }
+
+ @Override
+ protected void onAwaitingVideoSurface() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "onAwaitingVideoSurface was called in audio player -> switching to video player");
+ startActivity(new Intent(this, VideoplayerActivity.class));
+ }
+
+ @Override
+ protected void postStatusMsg(int resId) {
+ setSupportProgressBarIndeterminateVisibility(resId == R.string.player_preparing_msg
+ || resId == R.string.player_seeking_msg
+ || resId == R.string.player_buffering_msg);
+ }
+
+ @Override
+ protected void clearStatusMsg() {
+ setSupportProgressBarIndeterminateVisibility(false);
+ }
+
+ /**
+ * Changes the currently displayed fragment.
+ *
+ * @param pos Must be POS_COVER, POS_DESCR, or POS_CHAPTERS
+ */
+ private void switchToFragment(int pos) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Switching contentView to position " + pos);
+ if (currentlyShownPosition != pos && controller != null) {
+ Playable media = controller.getMedia();
+ if (media != null) {
+ FragmentTransaction ft = getSupportFragmentManager()
+ .beginTransaction();
+ if (currentlyShownFragment != null) {
+ detachedFragments[currentlyShownPosition] = currentlyShownFragment;
+ ft.detach(currentlyShownFragment);
+ }
+ switch (pos) {
+ case POS_COVER:
+ if (coverFragment == null) {
+ Log.i(TAG, "Using new coverfragment");
+ coverFragment = CoverFragment.newInstance(media);
+ }
+ currentlyShownFragment = coverFragment;
+ break;
+ case POS_DESCR:
+ if (descriptionFragment == null) {
+ descriptionFragment = ItemDescriptionFragment
+ .newInstance(media, true, true);
+ }
+ currentlyShownFragment = descriptionFragment;
+ break;
+ case POS_CHAPTERS:
+ if (chapterFragment == null) {
+ chapterFragment = new ListFragment() {
+
+ @Override
+ public void onListItemClick(ListView l, View v,
+ int position, long id) {
+ super.onListItemClick(l, v, position, id);
+ Chapter chapter = (Chapter) this
+ .getListAdapter().getItem(position);
+ controller.seekToChapter(chapter);
+ }
+
+ };
+ chapterFragment.setListAdapter(new ChapterListAdapter(
+ AudioplayerActivity.this, 0, media
+ .getChapters(), media
+ ));
+ }
+ currentlyShownFragment = chapterFragment;
+ break;
+ }
+ if (currentlyShownFragment != null) {
+ currentlyShownPosition = pos;
+ if (detachedFragments[pos] != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Reattaching fragment at position "
+ + pos);
+ ft.attach(detachedFragments[pos]);
+ } else {
+ ft.add(R.id.contentView, currentlyShownFragment);
+ }
+ ft.disallowAddToBackStack();
+ ft.commit();
+ updateNavButtonDrawable();
+ }
+ }
+ }
+ }
+
+ private void updateNavButtonDrawable() {
+
+ final int[] buttonTexts = new int[]{R.string.show_shownotes_label,
+ R.string.show_chapters_label, R.string.show_cover_label};
+
+ final TypedArray drawables = obtainStyledAttributes(new int[]{
+ R.attr.navigation_shownotes, R.attr.navigation_chapters});
+ final Playable media = controller.getMedia();
+ if (butNavLeft != null && butNavRight != null && media != null) {
+
+ butNavRight.setTag(R.id.imageloader_key, null);
+ butNavLeft.setTag(R.id.imageloader_key, null);
+
+ switch (currentlyShownPosition) {
+ case POS_COVER:
+ butNavLeft.setScaleType(ScaleType.CENTER);
+ butNavLeft.setImageDrawable(drawables.getDrawable(0));
+ butNavLeft.setContentDescription(getString(buttonTexts[0]));
+
+ butNavRight.setImageDrawable(drawables.getDrawable(1));
+ butNavRight.setContentDescription(getString(buttonTexts[1]));
+
+ break;
+ case POS_DESCR:
+ butNavLeft.setScaleType(ScaleType.CENTER_CROP);
+ butNavLeft.post(new Runnable() {
+
+ @Override
+ public void run() {
+ PicassoProvider.getMediaMetadataPicassoInstance(AudioplayerActivity.this)
+ .load(media.getImageUri())
+ .fit()
+ .into(butNavLeft);
+ }
+ });
+ butNavLeft.setContentDescription(getString(buttonTexts[2]));
+
+ butNavRight.setImageDrawable(drawables.getDrawable(1));
+ butNavRight.setContentDescription(getString(buttonTexts[1]));
+ break;
+ case POS_CHAPTERS:
+ butNavLeft.setScaleType(ScaleType.CENTER_CROP);
+ butNavLeft.post(new Runnable() {
+
+ @Override
+ public void run() {
+ PicassoProvider.getMediaMetadataPicassoInstance(AudioplayerActivity.this)
+ .load(media.getImageUri())
+ .fit()
+ .into(butNavLeft);
+ }
+
+ });
+ butNavLeft.setContentDescription(getString(buttonTexts[2]));
+
+ butNavRight.setImageDrawable(drawables.getDrawable(0));
+ butNavRight.setContentDescription(getString(buttonTexts[0]));
+ break;
+ }
+ }
+ }
+
+ @Override
+ protected void setupGUI() {
+ super.setupGUI();
+ resetFragmentView();
+ drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
+ navList = (ListView) findViewById(R.id.nav_list);
+ txtvTitle = (TextView) findViewById(R.id.txtvTitle);
+ butNavLeft = (ImageButton) findViewById(R.id.butNavLeft);
+ butNavRight = (ImageButton) findViewById(R.id.butNavRight);
+ butPlaybackSpeed = (Button) findViewById(R.id.butPlaybackSpeed);
+
+ TypedArray typedArray = obtainStyledAttributes(new int[]{R.attr.nav_drawer_toggle});
+ drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, typedArray.getResourceId(0, 0), R.string.drawer_open, R.string.drawer_close) {
+ String currentTitle = getSupportActionBar().getTitle().toString();
+
+ @Override
+ public void onDrawerOpened(View drawerView) {
+ super.onDrawerOpened(drawerView);
+ currentTitle = getSupportActionBar().getTitle().toString();
+ getSupportActionBar().setTitle(R.string.app_name);
+ supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public void onDrawerClosed(View drawerView) {
+ super.onDrawerClosed(drawerView);
+ getSupportActionBar().setTitle(currentTitle);
+ supportInvalidateOptionsMenu();
+ }
+ };
+ typedArray.recycle();
+ drawerToggle.setDrawerIndicatorEnabled(false);
+ drawerLayout.setDrawerListener(drawerToggle);
+
+ navAdapter = new NavListAdapter(itemAccess, this);
+ navList.setAdapter(navAdapter);
+ navList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ int viewType = parent.getAdapter().getItemViewType(position);
+ if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER) {
+ int relPos = (viewType == NavListAdapter.VIEW_TYPE_NAV) ? position : position - NavListAdapter.SUBSCRIPTION_OFFSET;
+ Intent intent = new Intent(AudioplayerActivity.this, MainActivity.class);
+ intent.putExtra(MainActivity.EXTRA_NAV_TYPE, viewType);
+ intent.putExtra(MainActivity.EXTRA_NAV_INDEX, relPos);
+ startActivity(intent);
+ }
+ drawerLayout.closeDrawer(navList);
+ }
+ });
+ drawerToggle.syncState();
+
+ butNavLeft.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (currentlyShownFragment == null
+ || currentlyShownPosition == POS_DESCR) {
+ switchToFragment(POS_COVER);
+ } else if (currentlyShownPosition == POS_COVER) {
+ switchToFragment(POS_DESCR);
+ } else if (currentlyShownPosition == POS_CHAPTERS) {
+ switchToFragment(POS_COVER);
+ }
+ }
+ });
+
+ butNavRight.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (currentlyShownPosition == POS_CHAPTERS) {
+ switchToFragment(POS_DESCR);
+ } else {
+ switchToFragment(POS_CHAPTERS);
+ }
+ }
+ });
+
+ butPlaybackSpeed.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (controller != null && controller.canSetPlaybackSpeed()) {
+ String[] availableSpeeds = UserPreferences
+ .getPlaybackSpeedArray();
+ String currentSpeed = UserPreferences.getPlaybackSpeed();
+
+ // Provide initial value in case the speed list has changed
+ // out from under us
+ // and our current speed isn't in the new list
+ String newSpeed;
+ if (availableSpeeds.length > 0) {
+ newSpeed = availableSpeeds[0];
+ } else {
+ newSpeed = "1.0";
+ }
+
+ for (int i = 0; i < availableSpeeds.length; i++) {
+ if (availableSpeeds[i].equals(currentSpeed)) {
+ if (i == availableSpeeds.length - 1) {
+ newSpeed = availableSpeeds[0];
+ } else {
+ newSpeed = availableSpeeds[i + 1];
+ }
+ break;
+ }
+ }
+ UserPreferences.setPlaybackSpeed(newSpeed);
+ controller.setPlaybackSpeed(Float.parseFloat(newSpeed));
+ }
+ }
+ });
+
+ butPlaybackSpeed.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ VariableSpeedDialog.showDialog(AudioplayerActivity.this);
+ return true;
+ }
+ });
+ }
+
+ @Override
+ protected void onPlaybackSpeedChange() {
+ super.onPlaybackSpeedChange();
+ updateButPlaybackSpeed();
+ }
+
+ private void updateButPlaybackSpeed() {
+ if (controller != null && controller.canSetPlaybackSpeed()) {
+ butPlaybackSpeed.setText(UserPreferences.getPlaybackSpeed());
+ }
+ }
+
+ @Override
+ protected void onPositionObserverUpdate() {
+ super.onPositionObserverUpdate();
+ notifyMediaPositionChanged();
+ }
+
+ @Override
+ protected boolean loadMediaInfo() {
+ if (!super.loadMediaInfo()) {
+ return false;
+ }
+ final Playable media = controller.getMedia();
+ if (media == null) {
+ return false;
+ }
+ txtvTitle.setText(media.getEpisodeTitle());
+ if (media.getChapters() != null) {
+ butNavRight.setVisibility(View.VISIBLE);
+ } else {
+ butNavRight.setVisibility(View.INVISIBLE);
+ }
+
+
+ if (currentlyShownPosition == -1) {
+ if (!restoreFromPreferences()) {
+ switchToFragment(POS_COVER);
+ }
+ }
+ if (currentlyShownFragment instanceof AudioplayerContentFragment) {
+ ((AudioplayerContentFragment) currentlyShownFragment)
+ .onDataSetChanged(media);
+ }
+
+ if (controller == null
+ || !controller.canSetPlaybackSpeed()) {
+ butPlaybackSpeed.setVisibility(View.GONE);
+ } else {
+ butPlaybackSpeed.setVisibility(View.VISIBLE);
+ }
+
+ updateButPlaybackSpeed();
+ return true;
+ }
+
+ public void notifyMediaPositionChanged() {
+ if (chapterFragment != null) {
+ ArrayAdapter<SimpleChapter> adapter = (ArrayAdapter<SimpleChapter>) chapterFragment
+ .getListAdapter();
+ adapter.notifyDataSetChanged();
+ }
+ }
+
+ @Override
+ protected void onReloadNotification(int notificationCode) {
+ if (notificationCode == PlaybackService.EXTRA_CODE_VIDEO) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "ReloadNotification received, switching to Videoplayer now");
+ finish();
+ startActivity(new Intent(this, VideoplayerActivity.class));
+
+ }
+ }
+
+ @Override
+ protected void onBufferStart() {
+ postStatusMsg(R.string.player_buffering_msg);
+ }
+
+ @Override
+ protected void onBufferEnd() {
+ clearStatusMsg();
+ }
+
+ @Override
+ public PlaybackController getPlaybackController() {
+ return controller;
+ }
+
+ @Override
+ public boolean isDrawerOpen() {
+ return drawerLayout != null && navList != null && drawerLayout.isDrawerOpen(navList);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ if (!MenuItemUtils.isActivityDrawerOpen(this)) {
+ return super.onCreateOptionsMenu(menu);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ if (!MenuItemUtils.isActivityDrawerOpen(this)) {
+ return super.onPrepareOptionsMenu(menu);
+ } else {
+ return false;
+ }
+ }
+
+ public interface AudioplayerContentFragment {
+ public void onDataSetChanged(Playable media);
+ }
+
+ @Override
+ protected int getContentViewResourceId() {
+ return R.layout.audioplayer_activity;
+ }
+
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (drawerToggle != null && drawerToggle.onOptionsItemSelected(item)) {
+ return true;
+ } else {
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ private DBReader.NavDrawerData navDrawerData;
+ private AsyncTask<Void, Void, DBReader.NavDrawerData> loadTask;
+
+ private void loadData() {
+ loadTask = new AsyncTask<Void, Void, DBReader.NavDrawerData>() {
+ @Override
+ protected DBReader.NavDrawerData doInBackground(Void... params) {
+ return DBReader.getNavDrawerData(AudioplayerActivity.this);
+ }
+
+ @Override
+ protected void onPostExecute(DBReader.NavDrawerData result) {
+ super.onPostExecute(result);
+ navDrawerData = result;
+ if (navAdapter != null) {
+ navAdapter.notifyDataSetChanged();
+ }
+ }
+ };
+ loadTask.execute();
+ }
+
+ private void cancelLoadTask() {
+ if (loadTask != null) {
+ loadTask.cancel(true);
+ }
+ }
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((EventDistributor.FEED_LIST_UPDATE & arg) != 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received contentUpdate Intent.");
+ loadData();
+ }
+ }
+ };
+
+ private final NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ if (navDrawerData != null) {
+ return navDrawerData.feeds.size();
+ } else {
+ return 0;
+ }
+ }
+
+ @Override
+ public Feed getItem(int position) {
+ if (navDrawerData != null && position < navDrawerData.feeds.size()) {
+ return navDrawerData.feeds.get(position);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public int getSelectedItemIndex() {
+ return -1;
+ }
+
+ @Override
+ public int getQueueSize() {
+ return (navDrawerData != null) ? navDrawerData.queueSize : 0;
+ }
+
+ @Override
+ public int getNumberOfUnreadItems() {
+ return (navDrawerData != null) ? navDrawerData.numUnreadItems : 0;
+ }
+ };
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java
new file mode 100644
index 000000000..a03fa7949
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/DefaultOnlineFeedViewActivity.java
@@ -0,0 +1,248 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.NavUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.StringUtils;
+import org.jsoup.Jsoup;
+import org.jsoup.examples.HtmlToPlainText;
+import org.jsoup.nodes.Document;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.adapter.FeedItemlistDescriptionAdapter;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+/**
+ * Default implementation of OnlineFeedViewActivity. Shows the downloaded feed's items with their descriptions,
+ * a subscribe button and a spinner for choosing alternate feed URLs.
+ */
+public class DefaultOnlineFeedViewActivity extends OnlineFeedViewActivity {
+ private static final String TAG = "DefaultOnlineFeedViewActivity";
+
+ private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED | EventDistributor.DOWNLOAD_QUEUED | EventDistributor.FEED_LIST_UPDATE;
+ private volatile List<Feed> feeds;
+ private Feed feed;
+ private String selectedDownloadUrl;
+
+ private Button subscribeButton;
+
+ @Override
+ protected void onCreate(Bundle arg0) {
+ super.onCreate(arg0);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ Intent destIntent = new Intent(this, MainActivity.class);
+ if (NavUtils.shouldUpRecreateTask(this, destIntent)) {
+ startActivity(destIntent);
+ } else {
+ NavUtils.navigateUpFromSameTask(this);
+ }
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void loadData() {
+ super.loadData();
+ feeds = DBReader.getFeedList(this);
+ }
+
+ @Override
+ protected void beforeShowFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) {
+ super.beforeShowFeedInformation(feed, alternateFeedUrls);
+
+ // remove HTML tags from descriptions
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Removing HTML from shownotes");
+ if (feed.getItems() != null) {
+ HtmlToPlainText formatter = new HtmlToPlainText();
+ for (FeedItem item : feed.getItems()) {
+ if (item.getDescription() != null) {
+ Document description = Jsoup.parse(item.getDescription());
+ item.setDescription(StringUtils.trim(formatter.getPlainText(description)));
+ }
+ }
+ }
+ }
+
+ @Override
+ protected void showFeedInformation(final Feed feed, final Map<String, String> alternateFeedUrls) {
+ super.showFeedInformation(feed, alternateFeedUrls);
+ setContentView(R.layout.listview_activity);
+
+ this.feed = feed;
+ this.selectedDownloadUrl = feed.getDownload_url();
+ EventDistributor.getInstance().register(listener);
+ ListView listView = (ListView) findViewById(R.id.listview);
+ LayoutInflater inflater = (LayoutInflater)
+ getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View header = inflater.inflate(R.layout.onlinefeedview_header, listView, false);
+ listView.addHeaderView(header);
+
+ listView.setAdapter(new FeedItemlistDescriptionAdapter(this, 0, feed.getItems()));
+
+ ImageView cover = (ImageView) header.findViewById(R.id.imgvCover);
+ TextView title = (TextView) header.findViewById(R.id.txtvTitle);
+ TextView author = (TextView) header.findViewById(R.id.txtvAuthor);
+ TextView description = (TextView) header.findViewById(R.id.txtvDescription);
+ Spinner spAlternateUrls = (Spinner) header.findViewById(R.id.spinnerAlternateUrls);
+
+ subscribeButton = (Button) header.findViewById(R.id.butSubscribe);
+
+ if (feed.getImage() != null) {
+ PicassoProvider.getDefaultPicassoInstance(this)
+ .load(feed.getImage().getDownload_url())
+ .fit()
+ .into(cover);
+ }
+
+ title.setText(feed.getTitle());
+ author.setText(feed.getAuthor());
+ description.setText(feed.getDescription());
+
+ subscribeButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ try {
+ Feed f = new Feed(selectedDownloadUrl, new Date(), feed.getTitle());
+ f.setPreferences(feed.getPreferences());
+ DefaultOnlineFeedViewActivity.this.feed = f;
+
+ DownloadRequester.getInstance().downloadFeed(
+ DefaultOnlineFeedViewActivity.this,
+ f);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DownloadRequestErrorDialogCreator.newRequestErrorDialog(DefaultOnlineFeedViewActivity.this,
+ e.getMessage());
+ }
+ setSubscribeButtonState(feed);
+ }
+ });
+
+ if (alternateFeedUrls.isEmpty()) {
+ spAlternateUrls.setVisibility(View.GONE);
+ } else {
+ spAlternateUrls.setVisibility(View.VISIBLE);
+
+ final List<String> alternateUrlsList = new ArrayList<String>();
+ final List<String> alternateUrlsTitleList = new ArrayList<String>();
+
+ alternateUrlsList.add(feed.getDownload_url());
+ alternateUrlsTitleList.add(feed.getTitle());
+
+
+ alternateUrlsList.addAll(alternateFeedUrls.keySet());
+ for (String url : alternateFeedUrls.keySet()) {
+ alternateUrlsTitleList.add(alternateFeedUrls.get(url));
+ }
+ ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, alternateUrlsTitleList);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ spAlternateUrls.setAdapter(adapter);
+ spAlternateUrls.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ selectedDownloadUrl = alternateUrlsList.get(position);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+
+ }
+ });
+
+
+ }
+ setSubscribeButtonState(feed);
+
+ }
+
+ private boolean feedInFeedlist(Feed feed) {
+ if (feeds == null || feed == null)
+ return false;
+ for (Feed f : feeds) {
+ if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void setSubscribeButtonState(Feed feed) {
+ if (subscribeButton != null && feed != null) {
+ if (DownloadRequester.getInstance().isDownloadingFile(feed.getDownload_url())) {
+ subscribeButton.setEnabled(false);
+ subscribeButton.setText(R.string.downloading_label);
+ } else if (feedInFeedlist(feed)) {
+ subscribeButton.setEnabled(false);
+ subscribeButton.setText(R.string.subscribed_label);
+ } else {
+ subscribeButton.setEnabled(true);
+ subscribeButton.setText(R.string.subscribe_label);
+ }
+ }
+ }
+
+ EventDistributor.EventListener listener = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EventDistributor.FEED_LIST_UPDATE) != 0) {
+ new AsyncTask<Void, Void, List<Feed>>() {
+ @Override
+ protected List<Feed> doInBackground(Void... params) {
+ return DBReader.getFeedList(DefaultOnlineFeedViewActivity.this);
+ }
+
+ @Override
+ protected void onPostExecute(List<Feed> feeds) {
+ super.onPostExecute(feeds);
+ DefaultOnlineFeedViewActivity.this.feeds = feeds;
+ setSubscribeButtonState(feed);
+ }
+ }.execute();
+ } else if ((arg & EVENTS) != 0) {
+ setSubscribeButtonState(feed);
+ }
+ }
+ };
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(listener);
+ }
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java
new file mode 100644
index 000000000..06a11c775
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/DirectoryChooserActivity.java
@@ -0,0 +1,370 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.FileObserver;
+import android.support.v4.app.NavUtils;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.*;
+import android.widget.AdapterView.OnItemClickListener;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * Let's the user choose a directory on the storage device. The selected folder
+ * will be sent back to the starting activity as an activity result.
+ */
+public class DirectoryChooserActivity extends ActionBarActivity {
+ private static final String TAG = "DirectoryChooserActivity";
+
+ private static final String CREATE_DIRECTORY_NAME = "AntennaPod";
+
+ public static final String RESULT_SELECTED_DIR = "selected_dir";
+ public static final int RESULT_CODE_DIR_SELECTED = 1;
+
+ private Button butConfirm;
+ private Button butCancel;
+ private ImageButton butNavUp;
+ private TextView txtvSelectedFolder;
+ private ListView listDirectories;
+
+ private ArrayAdapter<String> listDirectoriesAdapter;
+ private ArrayList<String> filenames;
+ /** The directory that is currently being shown. */
+ private File selectedDir;
+ private File[] filesInDir;
+
+ private FileObserver fileObserver;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ setContentView(R.layout.directory_chooser);
+ butConfirm = (Button) findViewById(R.id.butConfirm);
+ butCancel = (Button) findViewById(R.id.butCancel);
+ butNavUp = (ImageButton) findViewById(R.id.butNavUp);
+ txtvSelectedFolder = (TextView) findViewById(R.id.txtvSelectedFolder);
+ listDirectories = (ListView) findViewById(R.id.directory_list);
+
+ butConfirm.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (isValidFile(selectedDir)) {
+ if (selectedDir.list().length == 0) {
+ returnSelectedFolder();
+ } else {
+ showNonEmptyDirectoryWarning();
+ }
+ }
+ }
+
+ private void showNonEmptyDirectoryWarning() {
+ AlertDialog.Builder adb = new AlertDialog.Builder(
+ DirectoryChooserActivity.this);
+ adb.setTitle(R.string.folder_not_empty_dialog_title);
+ adb.setMessage(R.string.folder_not_empty_dialog_msg);
+ adb.setNegativeButton(R.string.cancel_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ dialog.dismiss();
+ }
+ });
+ adb.setPositiveButton(R.string.confirm_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ dialog.dismiss();
+ returnSelectedFolder();
+ }
+ });
+ adb.create().show();
+ }
+ });
+
+ butCancel.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ setResult(Activity.RESULT_CANCELED);
+ finish();
+ }
+ });
+
+ listDirectories.setOnItemClickListener(new OnItemClickListener() {
+
+ @Override
+ public void onItemClick(AdapterView<?> adapter, View view,
+ int position, long id) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Selected index: " + position);
+ if (filesInDir != null && position >= 0
+ && position < filesInDir.length) {
+ changeDirectory(filesInDir[position]);
+ }
+ }
+ });
+
+ butNavUp.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ File parent = null;
+ if (selectedDir != null
+ && (parent = selectedDir.getParentFile()) != null) {
+ changeDirectory(parent);
+ }
+ }
+ });
+
+ filenames = new ArrayList<String>();
+ listDirectoriesAdapter = new ArrayAdapter<String>(this,
+ android.R.layout.simple_list_item_1, filenames);
+ listDirectories.setAdapter(listDirectoriesAdapter);
+ changeDirectory(Environment.getExternalStorageDirectory());
+ }
+
+ /**
+ * Finishes the activity and returns the selected folder as a result. The
+ * selected folder can also be null.
+ */
+ private void returnSelectedFolder() {
+ if (selectedDir != null && BuildConfig.DEBUG)
+ Log.d(TAG, "Returning " + selectedDir.getAbsolutePath()
+ + " as result");
+ Intent resultData = new Intent();
+ if (selectedDir != null) {
+ resultData.putExtra(RESULT_SELECTED_DIR,
+ selectedDir.getAbsolutePath());
+ }
+ setResult(RESULT_CODE_DIR_SELECTED, resultData);
+ finish();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (fileObserver != null) {
+ fileObserver.stopWatching();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (fileObserver != null) {
+ fileObserver.startWatching();
+ }
+ }
+
+ /**
+ * Change the directory that is currently being displayed.
+ *
+ * @param dir
+ * The file the activity should switch to. This File must be
+ * non-null and a directory, otherwise the displayed directory
+ * will not be changed
+ */
+ private void changeDirectory(File dir) {
+ if (dir != null && dir.isDirectory()) {
+ File[] contents = dir.listFiles();
+ if (contents != null) {
+ int numDirectories = 0;
+ for (File f : contents) {
+ if (f.isDirectory()) {
+ numDirectories++;
+ }
+ }
+ filesInDir = new File[numDirectories];
+ filenames.clear();
+ for (int i = 0, counter = 0; i < numDirectories; counter++) {
+ if (contents[counter].isDirectory()) {
+ filesInDir[i] = contents[counter];
+ filenames.add(contents[counter].getName());
+ i++;
+ }
+ }
+ Arrays.sort(filesInDir);
+ Collections.sort(filenames);
+ selectedDir = dir;
+ txtvSelectedFolder.setText(dir.getAbsolutePath());
+ listDirectoriesAdapter.notifyDataSetChanged();
+ fileObserver = createFileObserver(dir.getAbsolutePath());
+ fileObserver.startWatching();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Changed directory to " + dir.getAbsolutePath());
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Could not change folder: contents of dir were null");
+ }
+ } else {
+ if (dir == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Could not change folder: dir was null");
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Could not change folder: dir is no directory");
+ }
+ }
+ refreshButtonState();
+ }
+
+ /**
+ * Changes the state of the buttons depending on the currently selected file
+ * or folder.
+ */
+ private void refreshButtonState() {
+ if (selectedDir != null) {
+ butConfirm.setEnabled(isValidFile(selectedDir));
+ supportInvalidateOptionsMenu();
+ }
+ }
+
+ /** Refresh the contents of the directory that is currently shown. */
+ private void refreshDirectory() {
+ if (selectedDir != null) {
+ changeDirectory(selectedDir);
+ }
+ }
+
+ /** Sets up a FileObserver to watch the current directory. */
+ private FileObserver createFileObserver(String path) {
+ return new FileObserver(path, FileObserver.CREATE | FileObserver.DELETE
+ | FileObserver.MOVED_FROM | FileObserver.MOVED_TO) {
+
+ @Override
+ public void onEvent(int event, String path) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "FileObserver received event " + event);
+ runOnUiThread(new Runnable() {
+
+ @Override
+ public void run() {
+ refreshDirectory();
+ }
+ });
+ }
+ };
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.new_folder_item)
+ .setVisible(isValidFile(selectedDir));
+ return true;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.directory_chooser, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ case R.id.new_folder_item:
+ openNewFolderDialog();
+ return true;
+ case R.id.set_to_default_folder_item:
+ selectedDir = null;
+ returnSelectedFolder();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Shows a confirmation dialog that asks the user if he wants to create a
+ * new folder.
+ */
+ private void openNewFolderDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.create_folder_label);
+ builder.setMessage(String.format(getString(R.string.create_folder_msg),
+ CREATE_DIRECTORY_NAME));
+ builder.setNegativeButton(R.string.cancel_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+ });
+ builder.setPositiveButton(R.string.confirm_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ int msg = createFolder();
+ Toast t = Toast.makeText(DirectoryChooserActivity.this,
+ msg, Toast.LENGTH_SHORT);
+ t.show();
+ }
+ });
+ builder.create().show();
+ }
+
+ /**
+ * Creates a new folder in the current directory with the name
+ * CREATE_DIRECTORY_NAME.
+ */
+ private int createFolder() {
+ if (selectedDir == null) {
+ return R.string.create_folder_error;
+ } else if (selectedDir.canWrite()) {
+ File newDir = new File(selectedDir, CREATE_DIRECTORY_NAME);
+ if (!newDir.exists()) {
+ boolean result = newDir.mkdir();
+ if (result) {
+ return R.string.create_folder_success;
+ } else {
+ return R.string.create_folder_error;
+ }
+ } else {
+ return R.string.create_folder_error_already_exists;
+ }
+ } else {
+ return R.string.create_folder_error_no_write_access;
+ }
+ }
+
+ /** Returns true if the selected file or directory would be valid selection. */
+ private boolean isValidFile(File file) {
+ return (file != null && file.isDirectory() && file.canRead() && file
+ .canWrite());
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java
new file mode 100644
index 000000000..c5f25d813
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/DownloadAuthenticationActivity.java
@@ -0,0 +1,110 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.download.DownloadRequest;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+/**
+ * Shows a username and a password text field.
+ * The activity MUST be started with the ARG_DOWNlOAD_REQUEST argument set to a non-null value.
+ * Other arguments are optional.
+ * The activity's result will be the same DownloadRequest with the entered username and password.
+ */
+public class DownloadAuthenticationActivity extends ActionBarActivity {
+ private static final String TAG = "DownloadAuthenticationActivity";
+
+ /**
+ * The download request object that contains information about the resource that requires a username and a password
+ */
+ public static final String ARG_DOWNLOAD_REQUEST = "request";
+ /**
+ * True if the request should be sent to the DownloadRequester when this activity is finished, false otherwise.
+ * The default value is false.
+ */
+ public static final String ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL = "send_to_downloadrequester";
+
+ public static final String RESULT_REQUEST = "request";
+
+ private EditText etxtUsername;
+ private EditText etxtPassword;
+ private Button butConfirm;
+ private Button butCancel;
+ private TextView txtvDescription;
+
+ private DownloadRequest request;
+ private boolean sendToDownloadRequester;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().hide();
+ setContentView(R.layout.download_authentication_activity);
+
+ etxtUsername = (EditText) findViewById(R.id.etxtUsername);
+ etxtPassword = (EditText) findViewById(R.id.etxtPassword);
+ butConfirm = (Button) findViewById(R.id.butConfirm);
+ butCancel = (Button) findViewById(R.id.butCancel);
+ txtvDescription = (TextView) findViewById(R.id.txtvDescription);
+
+ Validate.isTrue(getIntent().hasExtra(ARG_DOWNLOAD_REQUEST), "Download request missing");
+
+ request = getIntent().getParcelableExtra(ARG_DOWNLOAD_REQUEST);
+ sendToDownloadRequester = getIntent().getBooleanExtra(ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL, false);
+
+ if (savedInstanceState != null) {
+ etxtUsername.setText(savedInstanceState.getString("username"));
+ etxtPassword.setText(savedInstanceState.getString("password"));
+ }
+
+ txtvDescription.setText(txtvDescription.getText() + ":\n\n" + request.getTitle());
+
+ butCancel.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setResult(Activity.RESULT_CANCELED);
+ finish();
+ }
+ });
+
+ butConfirm.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String username = etxtUsername.getText().toString();
+ String password = etxtPassword.getText().toString();
+ request.setUsername(username);
+ request.setPassword(password);
+ Intent result = new Intent();
+ result.putExtra(RESULT_REQUEST, request);
+ setResult(Activity.RESULT_OK, result);
+
+ if (sendToDownloadRequester) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Sending request to DownloadRequester");
+ DownloadRequester.getInstance().download(DownloadAuthenticationActivity.this, request);
+ }
+ finish();
+ }
+ });
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString("username", etxtUsername.getText().toString());
+ outState.putString("password", etxtPassword.getText().toString());
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java
new file mode 100644
index 000000000..5cf187eb6
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/FeedInfoActivity.java
@@ -0,0 +1,192 @@
+package de.danoeh.antennapod.activity;
+
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.*;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.util.LangUtils;
+import de.danoeh.antennapod.util.menuhandler.FeedMenuHandler;
+
+/**
+ * Displays information about a feed.
+ */
+public class FeedInfoActivity extends ActionBarActivity {
+ private static final String TAG = "FeedInfoActivity";
+
+ public static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId";
+
+ private Feed feed;
+
+ private ImageView imgvCover;
+ private TextView txtvTitle;
+ private TextView txtvDescription;
+ private TextView txtvLanguage;
+ private TextView txtvAuthor;
+ private EditText etxtUsername;
+ private EditText etxtPassword;
+ private CheckBox cbxAutoDownload;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.feedinfo);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ long feedId = getIntent().getLongExtra(EXTRA_FEED_ID, -1);
+
+ imgvCover = (ImageView) findViewById(R.id.imgvCover);
+ txtvTitle = (TextView) findViewById(R.id.txtvTitle);
+ txtvDescription = (TextView) findViewById(R.id.txtvDescription);
+ txtvLanguage = (TextView) findViewById(R.id.txtvLanguage);
+ txtvAuthor = (TextView) findViewById(R.id.txtvAuthor);
+ cbxAutoDownload = (CheckBox) findViewById(R.id.cbxAutoDownload);
+ etxtUsername = (EditText) findViewById(R.id.etxtUsername);
+ etxtPassword = (EditText) findViewById(R.id.etxtPassword);
+
+ AsyncTask<Long, Void, Feed> loadTask = new AsyncTask<Long, Void, Feed>() {
+
+ @Override
+ protected Feed doInBackground(Long... params) {
+ return DBReader.getFeed(FeedInfoActivity.this, params[0]);
+ }
+
+ @Override
+ protected void onPostExecute(Feed result) {
+ if (result != null) {
+ feed = result;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Language is " + feed.getLanguage());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Author is " + feed.getAuthor());
+ imgvCover.post(new Runnable() {
+
+ @Override
+ public void run() {
+ PicassoProvider.getDefaultPicassoInstance(FeedInfoActivity.this)
+ .load(feed.getImageUri())
+ .fit()
+ .into(imgvCover);
+ }
+ });
+
+ txtvTitle.setText(feed.getTitle());
+ txtvDescription.setText(feed.getDescription());
+ if (feed.getAuthor() != null) {
+ txtvAuthor.setText(feed.getAuthor());
+ }
+ if (feed.getLanguage() != null) {
+ txtvLanguage.setText(LangUtils
+ .getLanguageString(feed.getLanguage()));
+ }
+
+ cbxAutoDownload.setEnabled(UserPreferences.isEnableAutodownload());
+ cbxAutoDownload.setChecked(feed.getPreferences().getAutoDownload());
+ cbxAutoDownload.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+ @Override
+ public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
+ feed.getPreferences().setAutoDownload(checked);
+ feed.savePreferences(FeedInfoActivity.this);
+ }
+ });
+
+ etxtUsername.setText(feed.getPreferences().getUsername());
+ etxtPassword.setText(feed.getPreferences().getPassword());
+
+ etxtUsername.addTextChangedListener(authTextWatcher);
+ etxtPassword.addTextChangedListener(authTextWatcher);
+
+ supportInvalidateOptionsMenu();
+
+ } else {
+ Log.e(TAG, "Activity was started with invalid arguments");
+ }
+ }
+ };
+ loadTask.execute(feedId);
+ }
+
+
+ private boolean authInfoChanged = false;
+
+ private TextWatcher authTextWatcher = new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ authInfoChanged = true;
+ }
+ };
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (feed != null && authInfoChanged) {
+ Log.d(TAG, "Auth info changed, saving credentials");
+ FeedPreferences prefs = feed.getPreferences();
+ prefs.setUsername(etxtUsername.getText().toString());
+ prefs.setPassword(etxtPassword.getText().toString());
+ DBWriter.setFeedPreferences(this, prefs);
+ authInfoChanged = false;
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.feedinfo, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.support_item).setVisible(
+ feed != null && feed.getPaymentLink() != null);
+ menu.findItem(R.id.share_link_item).setVisible(feed != null &&feed.getLink() != null);
+ menu.findItem(R.id.visit_website_item).setVisible(feed != null && feed.getLink() != null);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ default:
+ try {
+ return FeedMenuHandler.onOptionsItemClicked(this, item, feed);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DownloadRequestErrorDialogCreator.newRequestErrorDialog(this,
+ e.getMessage());
+ }
+ return super.onOptionsItemSelected(item);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java
new file mode 100644
index 000000000..8dde14d3b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/FlattrAuthActivity.java
@@ -0,0 +1,125 @@
+package de.danoeh.antennapod.activity;
+
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.TextView;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+import org.shredzone.flattr4j.exception.FlattrException;
+
+/** Guides the user through the authentication process */
+
+public class FlattrAuthActivity extends ActionBarActivity {
+ private static final String TAG = "FlattrAuthActivity";
+
+ private TextView txtvExplanation;
+ private Button butAuthenticate;
+ private Button butReturn;
+
+ private boolean authSuccessful;
+
+ private static FlattrAuthActivity singleton;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ singleton = this;
+ authSuccessful = false;
+ if (BuildConfig.DEBUG) Log.d(TAG, "Activity created");
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ setContentView(R.layout.flattr_auth);
+ txtvExplanation = (TextView) findViewById(R.id.txtvExplanation);
+ butAuthenticate = (Button) findViewById(R.id.but_authenticate);
+ butReturn = (Button) findViewById(R.id.but_return_home);
+
+ butReturn.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(FlattrAuthActivity.this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ }
+ });
+
+ butAuthenticate.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ try {
+ FlattrUtils.startAuthProcess(FlattrAuthActivity.this);
+ } catch (FlattrException e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ }
+
+ public static FlattrAuthActivity getInstance() {
+ return singleton;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (BuildConfig.DEBUG) Log.d(TAG, "Activity resumed");
+ Uri uri = getIntent().getData();
+ if (uri != null) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Received uri");
+ FlattrUtils.handleCallback(this, uri);
+ }
+ }
+
+ public void handleAuthenticationSuccess() {
+ authSuccessful = true;
+ txtvExplanation.setText(R.string.flattr_auth_success);
+ butAuthenticate.setEnabled(false);
+ butReturn.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (authSuccessful) {
+ finish();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ if (authSuccessful) {
+ Intent intent = new Intent(this, PreferenceActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ } else {
+ finish();
+ }
+ break;
+ default:
+ return false;
+ }
+ return true;
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java
new file mode 100644
index 000000000..b7014dab2
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java
@@ -0,0 +1,432 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.content.res.TypedArray;
+import android.media.AudioManager;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.ActionBarDrawerToggle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v4.widget.DrawerLayout;
+import android.support.v7.app.ActionBar;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.*;
+import android.widget.AdapterView;
+import android.widget.ListView;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.adapter.NavListAdapter;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.fragment.*;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.StorageUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+import java.util.List;
+
+/**
+ * The activity that is shown when the user launches the app.
+ */
+public class MainActivity extends ActionBarActivity implements NavDrawerActivity{
+ private static final String TAG = "MainActivity";
+ private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED
+ | EventDistributor.DOWNLOAD_QUEUED
+ | EventDistributor.FEED_LIST_UPDATE
+ | EventDistributor.UNREAD_ITEMS_UPDATE
+ | EventDistributor.QUEUE_UPDATE;
+
+ public static final String PREF_NAME = "MainActivityPrefs";
+ public static final String PREF_IS_FIRST_LAUNCH = "prefMainActivityIsFirstLaunch";
+
+ public static final String EXTRA_NAV_INDEX = "nav_index";
+ public static final String EXTRA_NAV_TYPE = "nav_type";
+ public static final String EXTRA_FRAGMENT_ARGS = "fragment_args";
+
+ public static final int POS_NEW = 0,
+ POS_QUEUE = 1,
+ POS_DOWNLOADS = 2,
+ POS_HISTORY = 3,
+ POS_ADD = 4;
+
+ private ExternalPlayerFragment externalPlayerFragment;
+ private DrawerLayout drawerLayout;
+
+ private ListView navList;
+ private NavListAdapter navAdapter;
+
+ private ActionBarDrawerToggle drawerToggle;
+
+ private CharSequence drawerTitle;
+ private CharSequence currentTitle;
+
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+ StorageUtils.checkStorageAvailability(this);
+ setContentView(R.layout.main);
+ setVolumeControlStream(AudioManager.STREAM_MUSIC);
+
+ drawerTitle = currentTitle = getTitle();
+
+ drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
+ navList = (ListView) findViewById(R.id.nav_list);
+
+ TypedArray typedArray = obtainStyledAttributes(new int[]{R.attr.nav_drawer_toggle});
+ drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, typedArray.getResourceId(0, 0), R.string.drawer_open, R.string.drawer_close) {
+ @Override
+ public void onDrawerOpened(View drawerView) {
+ super.onDrawerOpened(drawerView);
+ currentTitle = getSupportActionBar().getTitle();
+ getSupportActionBar().setTitle(drawerTitle);
+ supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public void onDrawerClosed(View drawerView) {
+ super.onDrawerClosed(drawerView);
+ getSupportActionBar().setTitle(currentTitle);
+ supportInvalidateOptionsMenu();
+
+ }
+ };
+ typedArray.recycle();
+
+ drawerLayout.setDrawerListener(drawerToggle);
+ FragmentManager fm = getSupportFragmentManager();
+
+ FragmentTransaction transaction = fm.beginTransaction();
+
+ Fragment mainFragment = fm.findFragmentByTag("main");
+ if (mainFragment != null) {
+ transaction.replace(R.id.main_view, mainFragment);
+ } else {
+ loadFragment(NavListAdapter.VIEW_TYPE_NAV, POS_NEW, null);
+ }
+
+ externalPlayerFragment = new ExternalPlayerFragment();
+ transaction.replace(R.id.playerFragment, externalPlayerFragment);
+ transaction.commit();
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setHomeButtonEnabled(true);
+
+ navAdapter = new NavListAdapter(itemAccess, this);
+ navList.setAdapter(navAdapter);
+ navList.setOnItemClickListener(navListClickListener);
+
+ checkFirstLaunch();
+ }
+
+ private void checkFirstLaunch() {
+ SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE);
+ if (prefs.getBoolean(PREF_IS_FIRST_LAUNCH, true)) {
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ drawerLayout.openDrawer(navList);
+ }
+ }, 1500);
+
+ SharedPreferences.Editor edit = prefs.edit();
+ edit.putBoolean(PREF_IS_FIRST_LAUNCH, false);
+ edit.commit();
+ }
+ }
+
+ public ActionBar getMainActivtyActionBar() {
+ return getSupportActionBar();
+ }
+
+ public boolean isDrawerOpen() {
+ return drawerLayout != null && navList != null && drawerLayout.isDrawerOpen(navList);
+ }
+
+ public List<Feed> getFeeds() {
+ return (navDrawerData != null) ? navDrawerData.feeds : null;
+ }
+
+ private void loadFragment(int viewType, int relPos, Bundle args) {
+ FragmentManager fragmentManager = getSupportFragmentManager();
+ // clear back stack
+ for (int i = 0; i < fragmentManager.getBackStackEntryCount(); i++) {
+ fragmentManager.popBackStack();
+ }
+
+ FragmentTransaction fT = fragmentManager.beginTransaction();
+ Fragment fragment = null;
+ if (viewType == NavListAdapter.VIEW_TYPE_NAV) {
+ switch (relPos) {
+ case POS_NEW:
+ fragment = new NewEpisodesFragment();
+ break;
+ case POS_QUEUE:
+ fragment = new QueueFragment();
+ break;
+ case POS_DOWNLOADS:
+ fragment = new DownloadsFragment();
+ break;
+ case POS_HISTORY:
+ fragment = new PlaybackHistoryFragment();
+ break;
+ case POS_ADD:
+ fragment = new AddFeedFragment();
+ break;
+
+ }
+ currentTitle = getString(NavListAdapter.NAV_TITLES[relPos]);
+ selectedNavListIndex = relPos;
+
+ } else if (viewType == NavListAdapter.VIEW_TYPE_SUBSCRIPTION) {
+ Feed feed = itemAccess.getItem(relPos);
+ currentTitle = "";
+ fragment = ItemlistFragment.newInstance(feed.getId());
+ selectedNavListIndex = NavListAdapter.SUBSCRIPTION_OFFSET + relPos;
+
+ }
+ if (fragment != null) {
+ if (args != null) {
+ fragment.setArguments(args);
+ }
+ fT.replace(R.id.main_view, fragment, "main");
+ fragmentManager.popBackStack();
+ }
+ fT.commit();
+ getSupportActionBar().setTitle(currentTitle);
+ if (navAdapter != null) {
+ navAdapter.notifyDataSetChanged();
+ }
+ }
+
+ public void loadNavFragment(int position, Bundle args) {
+ loadFragment(NavListAdapter.VIEW_TYPE_NAV, position, args);
+ }
+
+ public void loadFeedFragment(long feedID) {
+ if (navDrawerData != null) {
+ for (int i = 0; i < navDrawerData.feeds.size(); i++) {
+ if (navDrawerData.feeds.get(i).getId() == feedID) {
+ loadFragment(NavListAdapter.VIEW_TYPE_SUBSCRIPTION, i, null);
+ break;
+ }
+ }
+ }
+ }
+
+ public void loadChildFragment(Fragment fragment) {
+ Validate.notNull(fragment);
+ FragmentManager fm = getSupportFragmentManager();
+ fm.beginTransaction()
+ .replace(R.id.main_view, fragment, "main")
+ .addToBackStack(null)
+ .commit();
+ }
+
+ private AdapterView.OnItemClickListener navListClickListener = new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ int viewType = parent.getAdapter().getItemViewType(position);
+ if (viewType != NavListAdapter.VIEW_TYPE_SECTION_DIVIDER && position != selectedNavListIndex) {
+ int relPos = (viewType == NavListAdapter.VIEW_TYPE_NAV) ? position : position - NavListAdapter.SUBSCRIPTION_OFFSET;
+ loadFragment(viewType, relPos, null);
+ selectedNavListIndex = position;
+ navAdapter.notifyDataSetChanged();
+ }
+ drawerLayout.closeDrawer(navList);
+ }
+ };
+
+ @Override
+ protected void onPostCreate(Bundle savedInstanceState) {
+ super.onPostCreate(savedInstanceState);
+ drawerToggle.syncState();
+ if (savedInstanceState != null) {
+ currentTitle = savedInstanceState.getString("title");
+ if (!drawerLayout.isDrawerOpen(navList)) {
+ getSupportActionBar().setTitle(currentTitle);
+ }
+ selectedNavListIndex = savedInstanceState.getInt("selectedNavIndex");
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ drawerToggle.onConfigurationChanged(newConfig);
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString("title", getSupportActionBar().getTitle().toString());
+ outState.putInt("selectedNavIndex", selectedNavListIndex);
+
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ StorageUtils.checkStorageAvailability(this);
+ EventDistributor.getInstance().register(contentUpdate);
+
+ Intent intent = getIntent();
+ if (navDrawerData != null && intent.hasExtra(EXTRA_NAV_INDEX) && intent.hasExtra(EXTRA_NAV_TYPE)) {
+ handleNavIntent();
+ }
+
+ loadData();
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ cancelLoadTask();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (drawerToggle.onOptionsItemSelected(item)) {
+ return true;
+ }
+ switch (item.getItemId()) {
+ case R.id.show_preferences:
+ startActivity(new Intent(this, PreferenceActivity.class));
+ return true;
+ default:
+ return super.onOptionsItemSelected(item);
+ }
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.main, menu);
+ return true;
+ }
+
+ private DBReader.NavDrawerData navDrawerData;
+ private AsyncTask<Void, Void, DBReader.NavDrawerData> loadTask;
+ private int selectedNavListIndex = 0;
+
+ private NavListAdapter.ItemAccess itemAccess = new NavListAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ if (navDrawerData != null) {
+ return navDrawerData.feeds.size();
+ } else {
+ return 0;
+ }
+ }
+
+ @Override
+ public Feed getItem(int position) {
+ if (navDrawerData != null && position < navDrawerData.feeds.size()) {
+ return navDrawerData.feeds.get(position);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public int getSelectedItemIndex() {
+ return selectedNavListIndex;
+ }
+
+ @Override
+ public int getQueueSize() {
+ return (navDrawerData != null) ? navDrawerData.queueSize : 0;
+ }
+
+ @Override
+ public int getNumberOfUnreadItems() {
+ return (navDrawerData != null) ? navDrawerData.numUnreadItems : 0;
+ }
+
+
+ };
+
+ private void loadData() {
+ cancelLoadTask();
+ loadTask = new AsyncTask<Void, Void, DBReader.NavDrawerData>() {
+ @Override
+ protected DBReader.NavDrawerData doInBackground(Void... params) {
+ return DBReader.getNavDrawerData(MainActivity.this);
+ }
+
+ @Override
+ protected void onPostExecute(DBReader.NavDrawerData result) {
+ super.onPostExecute(navDrawerData);
+ boolean handleIntent = (navDrawerData == null);
+
+ navDrawerData = result;
+ navAdapter.notifyDataSetChanged();
+
+ if (handleIntent) {
+ handleNavIntent();
+ }
+ }
+ };
+ loadTask.execute();
+ }
+
+ private void cancelLoadTask() {
+ if (loadTask != null) {
+ loadTask.cancel(true);
+ }
+ }
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((EVENTS & arg) != 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received contentUpdate Intent.");
+ loadData();
+ }
+ }
+ };
+
+ private void handleNavIntent() {
+ Intent intent = getIntent();
+ if (intent.hasExtra(EXTRA_NAV_INDEX) && intent.hasExtra(EXTRA_NAV_TYPE)) {
+ int index = intent.getIntExtra(EXTRA_NAV_INDEX, 0);
+ int type = intent.getIntExtra(EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV);
+ Bundle args = intent.getBundleExtra(EXTRA_FRAGMENT_ARGS);
+ loadFragment(type, index, args);
+ }
+ setIntent(new Intent(MainActivity.this, MainActivity.class)); // to avoid handling the intent twice when the configuration changes
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java
new file mode 100644
index 000000000..2e5372b60
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java
@@ -0,0 +1,525 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.TypedArray;
+import android.graphics.PixelFormat;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v4.app.DialogFragment;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.Window;
+import android.widget.ImageButton;
+import android.widget.SeekBar;
+import android.widget.SeekBar.OnSeekBarChangeListener;
+import android.widget.TextView;
+import com.doomonafireball.betterpickers.hmspicker.HmsPickerBuilder;
+import com.doomonafireball.betterpickers.hmspicker.HmsPickerDialogFragment;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.dialog.TimeDialog;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.ShareUtils;
+import de.danoeh.antennapod.util.StorageUtils;
+import de.danoeh.antennapod.util.playback.MediaPlayerError;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.playback.PlaybackController;
+import org.shredzone.flattr4j.model.User;
+
+/**
+ * Provides general features which are both needed for playing audio and video
+ * files.
+ */
+public abstract class MediaplayerActivity extends ActionBarActivity
+ implements OnSeekBarChangeListener {
+ private static final String TAG = "MediaplayerActivity";
+
+ protected PlaybackController controller;
+
+ protected TextView txtvPosition;
+ protected TextView txtvLength;
+ protected SeekBar sbPosition;
+ protected ImageButton butPlay;
+ protected ImageButton butRev;
+ protected ImageButton butFF;
+
+ private PlaybackController newPlaybackController() {
+ return new PlaybackController(this, false) {
+
+ @Override
+ public void setupGUI() {
+ MediaplayerActivity.this.setupGUI();
+ }
+
+ @Override
+ public void onPositionObserverUpdate() {
+ MediaplayerActivity.this.onPositionObserverUpdate();
+ }
+
+ @Override
+ public void onBufferStart() {
+ MediaplayerActivity.this.onBufferStart();
+ }
+
+ @Override
+ public void onBufferEnd() {
+ MediaplayerActivity.this.onBufferEnd();
+ }
+
+ @Override
+ public void onBufferUpdate(float progress) {
+ MediaplayerActivity.this.onBufferUpdate(progress);
+ }
+
+ @Override
+ public void handleError(int code) {
+ MediaplayerActivity.this.handleError(code);
+ }
+
+ @Override
+ public void onReloadNotification(int code) {
+ MediaplayerActivity.this.onReloadNotification(code);
+ }
+
+ @Override
+ public void onSleepTimerUpdate() {
+ supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public ImageButton getPlayButton() {
+ return butPlay;
+ }
+
+ @Override
+ public void postStatusMsg(int msg) {
+ MediaplayerActivity.this.postStatusMsg(msg);
+ }
+
+ @Override
+ public void clearStatusMsg() {
+ MediaplayerActivity.this.clearStatusMsg();
+ }
+
+ @Override
+ public boolean loadMediaInfo() {
+ return MediaplayerActivity.this.loadMediaInfo();
+ }
+
+ @Override
+ public void onAwaitingVideoSurface() {
+ MediaplayerActivity.this.onAwaitingVideoSurface();
+ }
+
+ @Override
+ public void onServiceQueried() {
+ MediaplayerActivity.this.onServiceQueried();
+ }
+
+ @Override
+ public void onShutdownNotification() {
+ finish();
+ }
+
+ @Override
+ public void onPlaybackEnd() {
+ finish();
+ }
+
+ @Override
+ public void onPlaybackSpeedChange() {
+ MediaplayerActivity.this.onPlaybackSpeedChange();
+ }
+
+ @Override
+ protected void setScreenOn(boolean enable) {
+ super.setScreenOn(enable);
+ MediaplayerActivity.this.setScreenOn(enable);
+ }
+ };
+
+ }
+
+ protected void onPlaybackSpeedChange() {
+
+ }
+
+ protected void onServiceQueried() {
+ supportInvalidateOptionsMenu();
+ }
+
+ protected void chooseTheme() {
+ setTheme(UserPreferences.getTheme());
+ }
+
+ protected void setScreenOn(boolean enable) {
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ chooseTheme();
+ super.onCreate(savedInstanceState);
+
+ // subclasses might use this feature
+ supportRequestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating Activity");
+ StorageUtils.checkStorageAvailability(this);
+ setVolumeControlStream(AudioManager.STREAM_MUSIC);
+
+ orientation = getResources().getConfiguration().orientation;
+ getWindow().setFormat(PixelFormat.TRANSPARENT);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ controller.reinitServiceIfPaused();
+ controller.pause();
+ }
+
+ /**
+ * Should be used to switch to another player activity if the mime type is
+ * not the correct one for the current activity.
+ */
+ protected abstract void onReloadNotification(int notificationCode);
+
+ /**
+ * Should be used to inform the user that the PlaybackService is currently
+ * buffering.
+ */
+ protected abstract void onBufferStart();
+
+ /**
+ * Should be used to hide the view that was showing the 'buffering'-message.
+ */
+ protected abstract void onBufferEnd();
+
+ protected void onBufferUpdate(float progress) {
+ if (sbPosition != null) {
+ sbPosition.setSecondaryProgress((int) progress
+ * sbPosition.getMax());
+ }
+ }
+
+ /**
+ * Current screen orientation.
+ */
+ protected int orientation;
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (controller != null) {
+ controller.release();
+ }
+ controller = newPlaybackController();
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Activity stopped");
+ if (controller != null) {
+ controller.release();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Activity destroyed");
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.mediaplayer, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ Playable media = controller.getMedia();
+
+ menu.findItem(R.id.support_item).setVisible(
+ media != null && media.getPaymentLink() != null &&
+ (media instanceof FeedMedia) &&
+ ((FeedMedia) media).getItem().getFlattrStatus().flattrable()
+ );
+ menu.findItem(R.id.share_link_item).setVisible(
+ media != null && media.getWebsiteLink() != null);
+ menu.findItem(R.id.visit_website_item).setVisible(
+ media != null && media.getWebsiteLink() != null);
+ menu.findItem(R.id.skip_episode_item).setVisible(media != null);
+ boolean sleepTimerSet = controller.sleepTimerActive();
+ boolean sleepTimerNotSet = controller.sleepTimerNotActive();
+ menu.findItem(R.id.set_sleeptimer_item).setVisible(sleepTimerNotSet);
+ menu.findItem(R.id.disable_sleeptimer_item).setVisible(sleepTimerSet);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ Playable media = controller.getMedia();
+ if (item.getItemId() == android.R.id.home) {
+ Intent intent = new Intent(MediaplayerActivity.this,
+ MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } else if (media != null) {
+ switch (item.getItemId()) {
+ case R.id.disable_sleeptimer_item:
+ if (controller.serviceAvailable()) {
+ AlertDialog.Builder stDialog = new AlertDialog.Builder(this);
+ stDialog.setTitle(R.string.sleep_timer_label);
+ stDialog.setMessage(getString(R.string.time_left_label)
+ + Converter.getDurationStringLong((int) controller
+ .getSleepTimerTimeLeft()));
+ stDialog.setPositiveButton(
+ R.string.disable_sleeptimer_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ dialog.dismiss();
+ controller.disableSleepTimer();
+ }
+ }
+ );
+ stDialog.setNegativeButton(R.string.cancel_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ dialog.dismiss();
+ }
+ }
+ );
+ stDialog.create().show();
+ }
+ break;
+ case R.id.set_sleeptimer_item:
+ if (controller.serviceAvailable()) {
+ int pickerStyle = (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Light) ?
+ R.style.AntennaPodBetterPickerThemeLight : R.style.AntennaPodBetterPickerThemeDark;
+ if (Build.VERSION.SDK_INT > 10) { // TODO remove this as soon as dialog is shown correctly on 2.3
+ HmsPickerBuilder hpb = new HmsPickerBuilder()
+ .setStyleResId(pickerStyle)
+ .setFragmentManager(getSupportFragmentManager());
+
+ hpb.addHmsPickerDialogHandler(new HmsPickerDialogFragment.HmsPickerDialogHandler() {
+ @Override
+ public void onDialogHmsSet(int ref, int hours, int minutes, int seconds) {
+ if (controller != null && controller.serviceAvailable()) {
+ controller.setSleepTimer((hours * 3600 + minutes * 60 + seconds) * 1000);
+ }
+ }
+ });
+ hpb.show();
+ } else {
+ TimeDialog td = new TimeDialog(this,
+ R.string.set_sleeptimer_label,
+ R.string.set_sleeptimer_label) {
+
+ @Override
+ public void onTimeEntered(long millis) {
+ controller.setSleepTimer(millis);
+ }
+ };
+ td.show();
+ }
+ break;
+
+ }
+ case R.id.visit_website_item:
+ Uri uri = Uri.parse(media.getWebsiteLink());
+ startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ break;
+ case R.id.support_item:
+ if (media instanceof FeedMedia) {
+ FeedItem feedItem = ((FeedMedia) media).getItem();
+ DBTasks.flattrItemIfLoggedIn(this, feedItem);
+ }
+ break;
+ case R.id.share_link_item:
+ ShareUtils.shareLink(this, media.getWebsiteLink());
+ break;
+ case R.id.skip_episode_item:
+ sendBroadcast(new Intent(
+ PlaybackService.ACTION_SKIP_CURRENT_EPISODE));
+ break;
+ default:
+ return false;
+
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resuming Activity");
+ StorageUtils.checkStorageAvailability(this);
+ controller.init();
+ }
+
+ /**
+ * Called by 'handleStatus()' when the PlaybackService is waiting for
+ * a video surface.
+ */
+ protected abstract void onAwaitingVideoSurface();
+
+ protected abstract void postStatusMsg(int resId);
+
+ protected abstract void clearStatusMsg();
+
+ protected void onPositionObserverUpdate() {
+ if (controller != null) {
+ int currentPosition = controller.getPosition();
+ int duration = controller.getDuration();
+ if (currentPosition != PlaybackService.INVALID_TIME
+ && duration != PlaybackService.INVALID_TIME
+ && controller.getMedia() != null) {
+ txtvPosition.setText(Converter
+ .getDurationStringLong(currentPosition));
+ txtvLength.setText(Converter.getDurationStringLong(duration));
+ updateProgressbarPosition(currentPosition, duration);
+ } else {
+ Log.w(TAG,
+ "Could not react to position observer update because of invalid time");
+ }
+ }
+ }
+
+ private void updateProgressbarPosition(int position, int duration) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Updating progressbar info");
+ float progress = ((float) position) / duration;
+ sbPosition.setProgress((int) (progress * sbPosition.getMax()));
+ }
+
+ /**
+ * Load information about the media that is going to be played or currently
+ * being played. This method will be called when the activity is connected
+ * to the PlaybackService to ensure that the activity has the right
+ * FeedMedia object.
+ */
+ protected boolean loadMediaInfo() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading media info");
+ Playable media = controller.getMedia();
+ if (media != null) {
+ txtvPosition.setText(Converter.getDurationStringLong((media
+ .getPosition())));
+
+ if (media.getDuration() != 0) {
+ txtvLength.setText(Converter.getDurationStringLong(media
+ .getDuration()));
+ float progress = ((float) media.getPosition())
+ / media.getDuration();
+ sbPosition.setProgress((int) (progress * sbPosition.getMax()));
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ protected void setupGUI() {
+ setContentView(getContentViewResourceId());
+ sbPosition = (SeekBar) findViewById(R.id.sbPosition);
+ txtvPosition = (TextView) findViewById(R.id.txtvPosition);
+ txtvLength = (TextView) findViewById(R.id.txtvLength);
+ butPlay = (ImageButton) findViewById(R.id.butPlay);
+ butRev = (ImageButton) findViewById(R.id.butRev);
+ butFF = (ImageButton) findViewById(R.id.butFF);
+
+ // SEEKBAR SETUP
+
+ sbPosition.setOnSeekBarChangeListener(this);
+
+ // BUTTON SETUP
+
+ butPlay.setOnClickListener(controller.newOnPlayButtonClickListener());
+
+ if (butFF != null) {
+ butFF.setOnClickListener(controller.newOnFFButtonClickListener());
+ }
+ if (butRev != null) {
+ butRev.setOnClickListener(controller.newOnRevButtonClickListener());
+ }
+
+ }
+
+ protected abstract int getContentViewResourceId();
+
+ void handleError(int errorCode) {
+ final AlertDialog.Builder errorDialog = new AlertDialog.Builder(this);
+ errorDialog.setTitle(R.string.error_label);
+ errorDialog
+ .setMessage(MediaPlayerError.getErrorString(this, errorCode));
+ errorDialog.setNeutralButton("OK",
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ finish();
+ }
+ }
+ );
+ errorDialog.create().show();
+ }
+
+ float prog;
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress,
+ boolean fromUser) {
+ if (controller != null) {
+ prog = controller.onSeekBarProgressChanged(seekBar, progress, fromUser,
+ txtvPosition);
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ if (controller != null) {
+ controller.onSeekBarStartTrackingTouch(seekBar);
+ }
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ if (controller != null) {
+ controller.onSeekBarStopTrackingTouch(seekBar, prog);
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java
new file mode 100644
index 000000000..2c6d75cd8
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java
@@ -0,0 +1,428 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.widget.ArrayAdapter;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.dialog.AuthenticationDialog;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.download.DownloadRequest;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.service.download.HttpDownloader;
+import de.danoeh.antennapod.syndication.handler.FeedHandler;
+import de.danoeh.antennapod.syndication.handler.FeedHandlerResult;
+import de.danoeh.antennapod.syndication.handler.UnsupportedFeedtypeException;
+import de.danoeh.antennapod.util.DownloadError;
+import de.danoeh.antennapod.util.FileNameGenerator;
+import de.danoeh.antennapod.util.StorageUtils;
+import de.danoeh.antennapod.util.URLChecker;
+import de.danoeh.antennapod.util.syndication.FeedDiscoverer;
+import org.apache.commons.lang3.StringUtils;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Downloads a feed from a feed URL and parses it. Subclasses can display the
+ * feed object that was parsed. This activity MUST be started with a given URL
+ * or an Exception will be thrown.
+ * <p/>
+ * If the feed cannot be downloaded or parsed, an error dialog will be displayed
+ * and the activity will finish as soon as the error dialog is closed.
+ */
+public abstract class OnlineFeedViewActivity extends ActionBarActivity {
+ private static final String TAG = "OnlineFeedViewActivity";
+ public static final String ARG_FEEDURL = "arg.feedurl";
+
+ /**
+ * Optional argument: specify a title for the actionbar.
+ */
+ public static final String ARG_TITLE = "title";
+
+ public static final int RESULT_ERROR = 2;
+
+ private Feed feed;
+ private Map<String, String> alternateFeedUrls;
+ private Downloader downloader;
+
+ private boolean isPaused;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ if (getIntent() != null && getIntent().hasExtra(ARG_TITLE)) {
+ getSupportActionBar().setTitle(getIntent().getStringExtra(ARG_TITLE));
+ }
+
+ StorageUtils.checkStorageAvailability(this);
+
+ final String feedUrl;
+ if (getIntent().hasExtra(ARG_FEEDURL)) {
+ feedUrl = getIntent().getStringExtra(ARG_FEEDURL);
+ } else if (StringUtils.equals(getIntent().getAction(), Intent.ACTION_SEND)
+ || StringUtils.equals(getIntent().getAction(), Intent.ACTION_VIEW)) {
+ feedUrl = (StringUtils.equals(getIntent().getAction(), Intent.ACTION_SEND))
+ ? getIntent().getStringExtra(Intent.EXTRA_TEXT) : getIntent().getDataString();
+
+ getSupportActionBar().setTitle(R.string.add_new_feed_label);
+ } else {
+ throw new IllegalArgumentException(
+ "Activity must be started with feedurl argument!");
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Activity was started with url " + feedUrl);
+ setLoadingLayout();
+ if (savedInstanceState == null) {
+ startFeedDownload(feedUrl, null, null);
+ } else {
+ startFeedDownload(feedUrl, savedInstanceState.getString("username"), savedInstanceState.getString("password"));
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ isPaused = false;
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ isPaused = true;
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (feed != null && feed.getPreferences() != null) {
+ outState.putString("username", feed.getPreferences().getUsername());
+ outState.putString("password", feed.getPreferences().getPassword());
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ if (downloader != null && !downloader.isFinished()) {
+ downloader.cancel();
+ }
+ }
+
+ private void resetIntent(String url, String title) {
+ Intent intent = new Intent();
+ intent.putExtra(ARG_FEEDURL, url);
+ intent.putExtra(ARG_TITLE, title);
+ setIntent(intent);
+ }
+
+
+ private void onDownloadCompleted(final Downloader downloader) {
+ runOnUiThread(new Runnable() {
+
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Download was completed");
+ DownloadStatus status = downloader.getResult();
+ if (status != null) {
+ if (!status.isCancelled()) {
+ if (status.isSuccessful()) {
+ parseFeed();
+ } else if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) {
+ if (!isFinishing() && !isPaused) {
+ Dialog dialog = new FeedViewAuthenticationDialog(OnlineFeedViewActivity.this,
+ R.string.authentication_notification_title, downloader.getDownloadRequest().getSource());
+ dialog.show();
+ }
+ } else {
+ String errorMsg = status.getReason().getErrorString(
+ OnlineFeedViewActivity.this);
+ if (errorMsg != null
+ && status.getReasonDetailed() != null) {
+ errorMsg += " ("
+ + status.getReasonDetailed() + ")";
+ }
+ showErrorDialog(errorMsg);
+ }
+ }
+ } else {
+ Log.wtf(TAG,
+ "DownloadStatus returned by Downloader was null");
+ finish();
+ }
+ }
+ });
+
+ }
+
+ private void startFeedDownload(String url, String username, String password) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting feed download");
+ url = URLChecker.prepareURL(url);
+ feed = new Feed(url, new Date());
+ if (username != null && password != null) {
+ feed.setPreferences(new FeedPreferences(0, true, username, password));
+ }
+ String fileUrl = new File(getExternalCacheDir(),
+ FileNameGenerator.generateFileName(feed.getDownload_url()))
+ .toString();
+ feed.setFile_url(fileUrl);
+ final DownloadRequest request = new DownloadRequest(feed.getFile_url(),
+ feed.getDownload_url(), "OnlineFeed", 0, Feed.FEEDFILETYPE_FEED, username, password, true);
+ downloader = new HttpDownloader(
+ request);
+ new Thread() {
+ @Override
+ public void run() {
+ loadData();
+ downloader.call();
+ onDownloadCompleted(downloader);
+ }
+ }.start();
+
+
+ }
+
+ /**
+ * Displays a progress indicator.
+ */
+ private void setLoadingLayout() {
+ RelativeLayout rl = new RelativeLayout(this);
+ RelativeLayout.LayoutParams rlLayoutParams = new RelativeLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT);
+
+ ProgressBar pb = new ProgressBar(this);
+ pb.setIndeterminate(true);
+ RelativeLayout.LayoutParams pbLayoutParams = new RelativeLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ pbLayoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
+ rl.addView(pb, pbLayoutParams);
+ addContentView(rl, rlLayoutParams);
+ }
+
+ private void parseFeed() {
+ if (feed == null || feed.getFile_url() == null && feed.isDownloaded()) {
+ throw new IllegalStateException(
+ "feed must be non-null and downloaded when parseFeed is called");
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Parsing feed");
+
+ Thread thread = new Thread() {
+
+ @Override
+ public void run() {
+ String reasonDetailed = "";
+ boolean successful = false;
+ FeedHandler handler = new FeedHandler();
+ try {
+ FeedHandlerResult result = handler.parseFeed(feed);
+ feed = result.feed;
+ alternateFeedUrls = result.alternateFeedUrls;
+ successful = true;
+ } catch (SAXException e) {
+ e.printStackTrace();
+ reasonDetailed = e.getMessage();
+ } catch (IOException e) {
+ e.printStackTrace();
+ reasonDetailed = e.getMessage();
+ } catch (ParserConfigurationException e) {
+ e.printStackTrace();
+ reasonDetailed = e.getMessage();
+ } catch (UnsupportedFeedtypeException e) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Unsupported feed type detected");
+ if (StringUtils.equalsIgnoreCase("html", e.getRootElement())) {
+ if (showFeedDiscoveryDialog(new File(feed.getFile_url()), feed.getDownload_url())) {
+ return;
+ }
+ } else {
+ e.printStackTrace();
+ reasonDetailed = e.getMessage();
+ }
+ } finally {
+ boolean rc = new File(feed.getFile_url()).delete();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Deleted feed source file. Result: " + rc);
+ }
+
+ if (successful) {
+ beforeShowFeedInformation(feed, alternateFeedUrls);
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ showFeedInformation(feed, alternateFeedUrls);
+ }
+ });
+ } else {
+ final String errorMsg =
+ DownloadError.ERROR_PARSER_EXCEPTION.getErrorString(
+ OnlineFeedViewActivity.this)
+ + " (" + reasonDetailed + ")";
+ runOnUiThread(new Runnable() {
+
+ @Override
+ public void run() {
+ showErrorDialog(errorMsg);
+ }
+ });
+ }
+ }
+ };
+ thread.start();
+ }
+
+ /**
+ * Can be used to load data asynchronously.
+ */
+ protected void loadData() {
+
+ }
+
+ /**
+ * Called after the feed has been downloaded and parsed and before showFeedInformation is called.
+ * This method is executed on a background thread
+ */
+ protected void beforeShowFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) {
+
+ }
+
+ /**
+ * Called when feed parsed successfully.
+ * This method is executed on the GUI thread.
+ */
+ protected void showFeedInformation(Feed feed, Map<String, String> alternateFeedUrls) {
+
+ }
+
+ private void showErrorDialog(String errorMsg) {
+ if (!isFinishing() && !isPaused) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.error_label);
+ if (errorMsg != null) {
+ builder.setMessage(getString(R.string.error_msg_prefix) + errorMsg);
+ } else {
+ builder.setMessage(R.string.error_msg_prefix);
+ }
+ builder.setNeutralButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ }
+ );
+ builder.setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ setResult(RESULT_ERROR);
+ finish();
+ }
+ });
+ builder.show();
+ }
+ }
+
+ private boolean showFeedDiscoveryDialog(File feedFile, String baseUrl) {
+ FeedDiscoverer fd = new FeedDiscoverer();
+ final Map<String, String> urlsMap;
+ try {
+ urlsMap = fd.findLinks(feedFile, baseUrl);
+ if (urlsMap == null || urlsMap.isEmpty()) {
+ return false;
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (isPaused || isFinishing()) {
+ return;
+ }
+
+ final List<String> titles = new ArrayList<String>();
+ final List<String> urls = new ArrayList<String>();
+
+ urls.addAll(urlsMap.keySet());
+ for (String url : urls) {
+ titles.add(urlsMap.get(url));
+ }
+
+ final ArrayAdapter<String> adapter = new ArrayAdapter<String>(OnlineFeedViewActivity.this, R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles);
+ DialogInterface.OnClickListener onClickListener = new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ String selectedUrl = urls.get(which);
+ dialog.dismiss();
+ resetIntent(selectedUrl, titles.get(which));
+ startFeedDownload(selectedUrl, null, null);
+ }
+ };
+
+ AlertDialog.Builder ab = new AlertDialog.Builder(OnlineFeedViewActivity.this)
+ .setTitle(R.string.feeds_label)
+ .setCancelable(true)
+ .setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ finish();
+ }
+ })
+ .setAdapter(adapter, onClickListener);
+ ab.show();
+ }
+ });
+
+
+ return true;
+ }
+
+ private class FeedViewAuthenticationDialog extends AuthenticationDialog {
+
+ private String feedUrl;
+
+ public FeedViewAuthenticationDialog(Context context, int titleRes, String feedUrl) {
+ super(context, titleRes, true, false, null, null);
+ this.feedUrl = feedUrl;
+ }
+
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+ finish();
+ }
+
+ @Override
+ protected void onConfirmed(String username, String password, boolean saveUsernamePassword) {
+ startFeedDownload(feedUrl, username, password);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java
new file mode 100644
index 000000000..e09941abf
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlFeedChooserActivity.java
@@ -0,0 +1,134 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.view.MenuItemCompat;
+import android.support.v7.app.ActionBarActivity;
+import android.util.SparseBooleanArray;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ListView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.opml.OpmlElement;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Displays the feeds that the OPML-Importer has read and lets the user choose
+ * which feeds he wants to import.
+ */
+public class OpmlFeedChooserActivity extends ActionBarActivity {
+ private static final String TAG = "OpmlFeedChooserActivity";
+
+ public static final String EXTRA_SELECTED_ITEMS = "de.danoeh.antennapod.selectedItems";
+
+ private Button butConfirm;
+ private Button butCancel;
+ private ListView feedlist;
+ private ArrayAdapter<String> listAdapter;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.opml_selection);
+ butConfirm = (Button) findViewById(R.id.butConfirm);
+ butCancel = (Button) findViewById(R.id.butCancel);
+ feedlist = (ListView) findViewById(R.id.feedlist);
+
+ feedlist.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+ listAdapter = new ArrayAdapter<String>(this,
+ android.R.layout.simple_list_item_multiple_choice,
+ getTitleList());
+
+ feedlist.setAdapter(listAdapter);
+
+ butCancel.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+ });
+
+ butConfirm.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent();
+ SparseBooleanArray checked = feedlist.getCheckedItemPositions();
+
+ int checkedCount = 0;
+ // Get number of checked items
+ for (int i = 0; i < checked.size(); i++) {
+ if (checked.valueAt(i)) {
+ checkedCount++;
+ }
+ }
+ int[] selection = new int[checkedCount];
+ for (int i = 0, collected = 0; collected < checkedCount; i++) {
+ if (checked.valueAt(i)) {
+ selection[collected] = checked.keyAt(i);
+ collected++;
+ }
+ }
+ intent.putExtra(EXTRA_SELECTED_ITEMS, selection);
+ setResult(RESULT_OK, intent);
+ finish();
+ }
+ });
+
+ }
+
+ private List<String> getTitleList() {
+ List<String> result = new ArrayList<String>();
+ if (OpmlImportHolder.getReadElements() != null) {
+ for (OpmlElement element : OpmlImportHolder.getReadElements()) {
+ result.add(element.getText());
+ }
+
+ }
+ return result;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ MenuItemCompat.setShowAsAction(menu.add(Menu.NONE, R.id.select_all_item, Menu.NONE,
+ R.string.select_all_label),
+ MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
+
+ MenuItemCompat.setShowAsAction(menu.add(Menu.NONE, R.id.deselect_all_item, Menu.NONE,
+ R.string.deselect_all_label),
+ MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.select_all_item:
+ selectAllItems(true);
+ return true;
+ case R.id.deselect_all_item:
+ selectAllItems(false);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private void selectAllItems(boolean b) {
+ for (int i = 0; i < feedlist.getCount(); i++) {
+ feedlist.setItemChecked(i, b);
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java
new file mode 100644
index 000000000..d3fd3949c
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportBaseActivity.java
@@ -0,0 +1,90 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.Intent;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.asynctask.OpmlFeedQueuer;
+import de.danoeh.antennapod.asynctask.OpmlImportWorker;
+import de.danoeh.antennapod.opml.OpmlElement;
+
+import java.io.Reader;
+import java.util.ArrayList;
+
+/**
+ * Base activity for Opml Import - e.g. with code what to do afterwards
+ * */
+public class OpmlImportBaseActivity extends ActionBarActivity {
+
+ private static final String TAG = "OpmlImportBaseActivity";
+ private OpmlImportWorker importWorker;
+
+ /**
+ * Handles the choices made by the user in the OpmlFeedChooserActivity and
+ * starts the OpmlFeedQueuer if necessary.
+ */
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received result");
+ if (resultCode == RESULT_CANCELED) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Activity was cancelled");
+ if (finishWhenCanceled())
+ finish();
+ } else {
+ int[] selected = data
+ .getIntArrayExtra(OpmlFeedChooserActivity.EXTRA_SELECTED_ITEMS);
+ if (selected != null && selected.length > 0) {
+ OpmlFeedQueuer queuer = new OpmlFeedQueuer(this, selected) {
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ Intent intent = new Intent(OpmlImportBaseActivity.this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ }
+
+ };
+ queuer.executeAsync();
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "No items were selected");
+ }
+ }
+ }
+
+ /** Starts the import process. */
+ protected void startImport(Reader reader) {
+
+ if (reader != null) {
+ importWorker = new OpmlImportWorker(this, reader) {
+
+ @Override
+ protected void onPostExecute(ArrayList<OpmlElement> result) {
+ super.onPostExecute(result);
+ if (result != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Parsing was successful");
+ OpmlImportHolder.setReadElements(result);
+ startActivityForResult(new Intent(
+ OpmlImportBaseActivity.this,
+ OpmlFeedChooserActivity.class), 0);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Parser error occurred");
+ }
+ }
+ };
+ importWorker.executeAsync();
+ }
+ }
+
+ protected boolean finishWhenCanceled() {
+ return false;
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java
new file mode 100644
index 000000000..16e663fac
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromIntentActivity.java
@@ -0,0 +1,38 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.AlertDialog;
+import android.os.Bundle;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.util.LangUtils;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.URL;
+
+/** Lets the user start the OPML-import process. */
+public class OpmlImportFromIntentActivity extends OpmlImportBaseActivity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ try {
+ URL mOpmlURL = new URL(getIntent().getData().toString());
+ BufferedReader in = new BufferedReader(new InputStreamReader(mOpmlURL.openStream(),
+ LangUtils.UTF_8));
+ startImport(in);
+ } catch (Exception e) {
+ new AlertDialog.Builder(this).setMessage("Cannot open XML - Reason: " + e.getMessage()).show();
+ }
+
+ }
+
+ @Override
+ protected boolean finishWhenCanceled() {
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java
new file mode 100644
index 000000000..94f100321
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportFromPathActivity.java
@@ -0,0 +1,172 @@
+package de.danoeh.antennapod.activity;
+
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.util.LangUtils;
+import de.danoeh.antennapod.util.StorageUtils;
+
+import java.io.*;
+
+/**
+ * Lets the user start the OPML-import process from a path
+ */
+public class OpmlImportFromPathActivity extends OpmlImportBaseActivity {
+ public static final String IMPORT_DIR = "import/";
+ private static final String TAG = "OpmlImportFromPathActivity";
+ private TextView txtvPath;
+ private Button butStart;
+ private String importPath;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ setContentView(R.layout.opml_import);
+
+ txtvPath = (TextView) findViewById(R.id.txtvPath);
+ butStart = (Button) findViewById(R.id.butStartImport);
+
+ butStart.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ checkFolderForFiles();
+ }
+
+ });
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ StorageUtils.checkStorageAvailability(this);
+ setImportPath();
+ }
+
+ /**
+ * Sets the importPath variable and makes txtvPath display the import
+ * directory.
+ */
+ private void setImportPath() {
+ File importDir = UserPreferences.getDataFolder(this, IMPORT_DIR);
+ boolean success = true;
+ if (!importDir.exists()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Import directory doesn't exist. Creating...");
+ success = importDir.mkdir();
+ if (!success) {
+ Log.e(TAG, "Could not create directory");
+ }
+ }
+ if (success) {
+ txtvPath.setText(importDir.toString());
+ importPath = importDir.toString();
+ } else {
+ txtvPath.setText(R.string.opml_directory_error);
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ finish();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Looks at the contents of the import directory and decides what to do. If
+ * more than one file is in the directory, a dialog will be created to let
+ * the user choose which item to import
+ */
+ private void checkFolderForFiles() {
+ File dir = new File(importPath);
+ if (dir.isDirectory()) {
+ File[] fileList = dir.listFiles();
+ if (fileList.length == 1) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Found one file, choosing that one.");
+ startImport(fileList[0]);
+ } else if (fileList.length > 1) {
+ Log.w(TAG, "Import directory contains more than one file.");
+ askForFile(dir);
+ } else {
+ Log.e(TAG, "Import directory is empty");
+ Toast toast = Toast
+ .makeText(this, R.string.opml_import_error_dir_empty,
+ Toast.LENGTH_LONG);
+ toast.show();
+ }
+ }
+ }
+
+ private void startImport(File file) {
+ Reader mReader = null;
+ try {
+ mReader = new InputStreamReader(new FileInputStream(file),
+ LangUtils.UTF_8);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Parsing " + file.toString());
+ startImport(mReader);
+ } catch (FileNotFoundException e) {
+ Log.d(TAG, "File not found which really should be there");
+ // this should never happen as it is a file we have just chosen
+ }
+ }
+
+ /**
+ * Asks the user to choose from a list of files in a directory and returns
+ * his choice.
+ */
+ private void askForFile(File dir) {
+ final File[] fileList = dir.listFiles();
+ String[] fileNames = dir.list();
+
+ AlertDialog.Builder dialog = new AlertDialog.Builder(this);
+ dialog.setTitle(R.string.choose_file_to_import_label);
+ dialog.setNeutralButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Dialog was cancelled");
+ dialog.dismiss();
+ }
+ });
+ dialog.setItems(fileNames, new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "File at index " + which + " was chosen");
+ dialog.dismiss();
+ startImport(fileList[which]);
+ }
+ });
+ dialog.create().show();
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java
new file mode 100644
index 000000000..ec53ed7b6
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportHolder.java
@@ -0,0 +1,29 @@
+package de.danoeh.antennapod.activity;
+
+import de.danoeh.antennapod.opml.OpmlElement;
+
+import java.util.ArrayList;
+
+/**
+ * Hold infos gathered by Ompl-Import
+ * <p/>
+ * Created with IntelliJ IDEA.
+ * User: ligi
+ * Date: 1/23/13
+ * Time: 2:15 PM
+ */
+public class OpmlImportHolder {
+
+ private static ArrayList<OpmlElement> readElements;
+
+ public static ArrayList<OpmlElement> getReadElements() {
+ return readElements;
+ }
+
+ public static void setReadElements(ArrayList<OpmlElement> _readElements) {
+ readElements = _readElements;
+ }
+
+
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java
new file mode 100644
index 000000000..cd6731c02
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java
@@ -0,0 +1,513 @@
+package de.danoeh.antennapod.activity;
+
+import android.annotation.SuppressLint;
+import android.app.ActionBar;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.res.Resources.Theme;
+import android.net.wifi.WifiConfiguration;
+import android.net.wifi.WifiManager;
+import android.os.Bundle;
+import android.preference.CheckBoxPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.Preference.OnPreferenceChangeListener;
+import android.preference.Preference.OnPreferenceClickListener;
+import android.preference.PreferenceScreen;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.Toast;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.FlattrClickWorker;
+import de.danoeh.antennapod.asynctask.OpmlExportWorker;
+import de.danoeh.antennapod.dialog.AuthenticationDialog;
+import de.danoeh.antennapod.dialog.AutoFlattrPreferenceDialog;
+import de.danoeh.antennapod.dialog.GpodnetSetHostnameDialog;
+import de.danoeh.antennapod.dialog.VariableSpeedDialog;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+import de.danoeh.antennapod.util.flattr.SimpleFlattrThing;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The main preference activity
+ */
+public class PreferenceActivity extends android.preference.PreferenceActivity {
+ private static final String TAG = "PreferenceActivity";
+
+ private static final String PREF_FLATTR_THIS_APP = "prefFlattrThisApp";
+ private static final String PREF_FLATTR_SETTINGS = "prefFlattrSettings";
+ private static final String PREF_FLATTR_AUTH = "pref_flattr_authenticate";
+ private static final String PREF_FLATTR_REVOKE = "prefRevokeAccess";
+ private static final String PREF_AUTO_FLATTR_PREFS = "prefAutoFlattrPrefs";
+ private static final String PREF_OPML_EXPORT = "prefOpmlExport";
+ private static final String PREF_ABOUT = "prefAbout";
+ private static final String PREF_CHOOSE_DATA_DIR = "prefChooseDataDir";
+ private static final String AUTO_DL_PREF_SCREEN = "prefAutoDownloadSettings";
+ private static final String PREF_PLAYBACK_SPEED_LAUNCHER = "prefPlaybackSpeedLauncher";
+
+ private static final String PREF_GPODNET_LOGIN = "pref_gpodnet_authenticate";
+ private static final String PREF_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information";
+ private static final String PREF_GPODNET_LOGOUT = "pref_gpodnet_logout";
+ private static final String PREF_GPODNET_HOSTNAME = "pref_gpodnet_hostname";
+
+ private CheckBoxPreference[] selectedNetworks;
+
+ @SuppressLint("NewApi")
+ @SuppressWarnings("deprecation")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ if (android.os.Build.VERSION.SDK_INT >= 11) {
+ @SuppressLint("AppCompatMethod") ActionBar ab = getActionBar();
+ if (ab != null) {
+ ab.setDisplayHomeAsUpEnabled(true);
+ }
+ }
+
+ addPreferencesFromResource(R.xml.preferences);
+ findPreference(PREF_FLATTR_THIS_APP).setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ new FlattrClickWorker(PreferenceActivity.this,
+ new SimpleFlattrThing(PreferenceActivity.this.getString(R.string.app_name),
+ FlattrUtils.APP_URL,
+ new FlattrStatus(FlattrStatus.STATUS_QUEUE)
+ )
+ ).executeAsync();
+
+ return true;
+ }
+ }
+ );
+
+ findPreference(PREF_FLATTR_REVOKE).setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ FlattrUtils.revokeAccessToken(PreferenceActivity.this);
+ checkItemVisibility();
+ return true;
+ }
+
+ }
+ );
+
+ findPreference(PREF_ABOUT).setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ PreferenceActivity.this.startActivity(new Intent(
+ PreferenceActivity.this, AboutActivity.class));
+ return true;
+ }
+
+ }
+ );
+
+ findPreference(PREF_OPML_EXPORT).setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ new OpmlExportWorker(PreferenceActivity.this)
+ .executeAsync();
+
+ return true;
+ }
+ }
+ );
+
+ findPreference(PREF_CHOOSE_DATA_DIR).setOnPreferenceClickListener(
+ new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ startActivityForResult(
+ new Intent(PreferenceActivity.this,
+ DirectoryChooserActivity.class),
+ DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED
+ );
+ return true;
+ }
+ }
+ );
+ findPreference(UserPreferences.PREF_THEME)
+ .setOnPreferenceChangeListener(
+ new OnPreferenceChangeListener() {
+
+ @Override
+ public boolean onPreferenceChange(
+ Preference preference, Object newValue) {
+ Intent i = getIntent();
+ i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK
+ | Intent.FLAG_ACTIVITY_NEW_TASK);
+ finish();
+ startActivity(i);
+ return true;
+ }
+ }
+ );
+ findPreference(UserPreferences.PREF_ENABLE_AUTODL)
+ .setOnPreferenceChangeListener(new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (newValue instanceof Boolean) {
+ findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER).setEnabled((Boolean) newValue);
+ setSelectedNetworksEnabled((Boolean) newValue && UserPreferences.isEnableAutodownloadWifiFilter());
+ }
+ return true;
+ }
+ });
+ findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER)
+ .setOnPreferenceChangeListener(
+ new OnPreferenceChangeListener() {
+
+ @Override
+ public boolean onPreferenceChange(
+ Preference preference, Object newValue) {
+ if (newValue instanceof Boolean) {
+ setSelectedNetworksEnabled((Boolean) newValue);
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+ );
+ findPreference(UserPreferences.PREF_EPISODE_CACHE_SIZE)
+ .setOnPreferenceChangeListener(
+ new OnPreferenceChangeListener() {
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object o) {
+ if (o instanceof String) {
+ setEpisodeCacheSizeText(UserPreferences.readEpisodeCacheSize((String) o));
+ }
+ return true;
+ }
+ }
+ );
+ findPreference(PREF_PLAYBACK_SPEED_LAUNCHER)
+ .setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ VariableSpeedDialog.showDialog(PreferenceActivity.this);
+ return true;
+ }
+ });
+ findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ AuthenticationDialog dialog = new AuthenticationDialog(PreferenceActivity.this,
+ R.string.pref_gpodnet_setlogin_information_title, false, false, GpodnetPreferences.getUsername(),
+ null) {
+
+ @Override
+ protected void onConfirmed(String username, String password, boolean saveUsernamePassword) {
+ GpodnetPreferences.setPassword(password);
+ }
+ };
+ dialog.show();
+ return true;
+ }
+ });
+ findPreference(PREF_GPODNET_LOGOUT).setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ GpodnetPreferences.logout();
+ Toast toast = Toast.makeText(PreferenceActivity.this, R.string.pref_gpodnet_logout_toast, Toast.LENGTH_SHORT);
+ toast.show();
+ updateGpodnetPreferenceScreen();
+ return true;
+ }
+ });
+ findPreference(PREF_GPODNET_HOSTNAME).setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ GpodnetSetHostnameDialog.createDialog(PreferenceActivity.this).setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ updateGpodnetPreferenceScreen();
+ }
+ });
+ return true;
+ }
+ });
+
+ findPreference(PREF_AUTO_FLATTR_PREFS).setOnPreferenceClickListener(new OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ AutoFlattrPreferenceDialog.newAutoFlattrPreferenceDialog(PreferenceActivity.this,
+ new AutoFlattrPreferenceDialog.AutoFlattrPreferenceDialogInterface() {
+ @Override
+ public void onCancelled() {
+
+ }
+
+ @Override
+ public void onConfirmed(boolean autoFlattrEnabled, float autoFlattrValue) {
+ UserPreferences.setAutoFlattrSettings(PreferenceActivity.this, autoFlattrEnabled, autoFlattrValue);
+ checkItemVisibility();
+ }
+ });
+ return true;
+ }
+ });
+ buildUpdateIntervalPreference();
+ buildAutodownloadSelectedNetworsPreference();
+ setSelectedNetworksEnabled(UserPreferences
+ .isEnableAutodownloadWifiFilter());
+
+
+ }
+
+ private void updateGpodnetPreferenceScreen() {
+ final boolean loggedIn = GpodnetPreferences.loggedIn();
+ findPreference(PREF_GPODNET_LOGIN).setEnabled(!loggedIn);
+ findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setEnabled(loggedIn);
+ findPreference(PREF_GPODNET_LOGOUT).setEnabled(loggedIn);
+ findPreference(PREF_GPODNET_HOSTNAME).setSummary(GpodnetPreferences.getHostname());
+ }
+
+ private void buildUpdateIntervalPreference() {
+ ListPreference pref = (ListPreference) findPreference(UserPreferences.PREF_UPDATE_INTERVAL);
+ String[] values = getResources().getStringArray(
+ R.array.update_intervall_values);
+ String[] entries = new String[values.length];
+ for (int x = 0; x < values.length; x++) {
+ Integer v = Integer.parseInt(values[x]);
+ switch (v) {
+ case 0:
+ entries[x] = getString(R.string.pref_update_interval_hours_manual);
+ break;
+ case 1:
+ entries[x] = v
+ + " "
+ + getString(R.string.pref_update_interval_hours_singular);
+ break;
+ default:
+ entries[x] = v + " "
+ + getString(R.string.pref_update_interval_hours_plural);
+ break;
+
+ }
+ }
+ pref.setEntries(entries);
+
+ }
+
+ private void setSelectedNetworksEnabled(boolean b) {
+ if (selectedNetworks != null) {
+ for (Preference p : selectedNetworks) {
+ p.setEnabled(b);
+ }
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ checkItemVisibility();
+ setEpisodeCacheSizeText(UserPreferences.getEpisodeCacheSize());
+ setDataFolderText();
+ updateGpodnetPreferenceScreen();
+ }
+
+ @SuppressWarnings("deprecation")
+ private void checkItemVisibility() {
+
+ boolean hasFlattrToken = FlattrUtils.hasToken();
+
+ findPreference(PREF_FLATTR_SETTINGS).setEnabled(FlattrUtils.hasAPICredentials());
+ findPreference(PREF_FLATTR_AUTH).setEnabled(!hasFlattrToken);
+ findPreference(PREF_FLATTR_REVOKE).setEnabled(hasFlattrToken);
+ findPreference(PREF_AUTO_FLATTR_PREFS).setEnabled(hasFlattrToken);
+
+ findPreference(UserPreferences.PREF_ENABLE_AUTODL_WIFI_FILTER)
+ .setEnabled(UserPreferences.isEnableAutodownload());
+ setSelectedNetworksEnabled(UserPreferences.isEnableAutodownload()
+ && UserPreferences.isEnableAutodownloadWifiFilter());
+
+ }
+
+ private void setEpisodeCacheSizeText(int cacheSize) {
+ String s;
+ if (cacheSize == getResources().getInteger(
+ R.integer.episode_cache_size_unlimited)) {
+ s = getString(R.string.pref_episode_cache_unlimited);
+ } else {
+ s = Integer.toString(cacheSize)
+ + getString(R.string.episodes_suffix);
+ }
+ findPreference(UserPreferences.PREF_EPISODE_CACHE_SIZE).setSummary(s);
+ }
+
+ private void setDataFolderText() {
+ File f = UserPreferences.getDataFolder(this, null);
+ if (f != null) {
+ findPreference(PREF_CHOOSE_DATA_DIR)
+ .setSummary(f.getAbsolutePath());
+ }
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ Intent destIntent = new Intent(this, MainActivity.class);
+ destIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(destIntent);
+ finish();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ protected void onApplyThemeResource(Theme theme, int resid, boolean first) {
+ theme.applyStyle(UserPreferences.getTheme(), true);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
+ String dir = data
+ .getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting data folder");
+ UserPreferences.setDataFolder(dir);
+ }
+ }
+
+ private void buildAutodownloadSelectedNetworsPreference() {
+ if (selectedNetworks != null) {
+ clearAutodownloadSelectedNetworsPreference();
+ }
+ // get configured networks
+ WifiManager wifiservice = (WifiManager) getSystemService(Context.WIFI_SERVICE);
+ List<WifiConfiguration> networks = wifiservice.getConfiguredNetworks();
+
+ if (networks != null) {
+ selectedNetworks = new CheckBoxPreference[networks.size()];
+ List<String> prefValues = Arrays.asList(UserPreferences
+ .getAutodownloadSelectedNetworks());
+ PreferenceScreen prefScreen = (PreferenceScreen) findPreference(AUTO_DL_PREF_SCREEN);
+ OnPreferenceClickListener clickListener = new OnPreferenceClickListener() {
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ if (preference instanceof CheckBoxPreference) {
+ String key = preference.getKey();
+ ArrayList<String> prefValuesList = new ArrayList<String>(
+ Arrays.asList(UserPreferences
+ .getAutodownloadSelectedNetworks())
+ );
+ boolean newValue = ((CheckBoxPreference) preference)
+ .isChecked();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Selected network " + key
+ + ". New state: " + newValue);
+
+ int index = prefValuesList.indexOf(key);
+ if (index >= 0 && newValue == false) {
+ // remove network
+ prefValuesList.remove(index);
+ } else if (index < 0 && newValue == true) {
+ prefValuesList.add(key);
+ }
+
+ UserPreferences.setAutodownloadSelectedNetworks(
+ PreferenceActivity.this, prefValuesList
+ .toArray(new String[prefValuesList
+ .size()])
+ );
+ return true;
+ } else {
+ return false;
+ }
+ }
+ };
+ // create preference for each known network. attach listener and set
+ // value
+ for (int i = 0; i < networks.size(); i++) {
+ WifiConfiguration config = networks.get(i);
+
+ CheckBoxPreference pref = new CheckBoxPreference(this);
+ String key = Integer.toString(config.networkId);
+ pref.setTitle(config.SSID);
+ pref.setKey(key);
+ pref.setOnPreferenceClickListener(clickListener);
+ pref.setPersistent(false);
+ pref.setChecked(prefValues.contains(key));
+ selectedNetworks[i] = pref;
+ prefScreen.addPreference(pref);
+ }
+ } else {
+ Log.e(TAG, "Couldn't get list of configure Wi-Fi networks");
+ }
+ }
+
+ private void clearAutodownloadSelectedNetworsPreference() {
+ if (selectedNetworks != null) {
+ PreferenceScreen prefScreen = (PreferenceScreen) findPreference(AUTO_DL_PREF_SCREEN);
+
+ for (int i = 0; i < selectedNetworks.length; i++) {
+ if (selectedNetworks[i] != null) {
+ prefScreen.removePreference(selectedNetworks[i]);
+ }
+ }
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen,
+ Preference preference) {
+ super.onPreferenceTreeClick(preferenceScreen, preference);
+ if (preference != null)
+ if (preference instanceof PreferenceScreen)
+ if (((PreferenceScreen) preference).getDialog() != null)
+ ((PreferenceScreen) preference)
+ .getDialog()
+ .getWindow()
+ .getDecorView()
+ .setBackgroundDrawable(
+ this.getWindow().getDecorView()
+ .getBackground().getConstantState()
+ .newDrawable()
+ );
+ return false;
+ }
+
+ @Override
+ public void onBackPressed() {
+ // The default back button behavior has to be overwritten because changing the theme clears the back stack
+ Intent destIntent = new Intent(this, MainActivity.class);
+ destIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(destIntent);
+ finish();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java
new file mode 100644
index 000000000..d8a137eb9
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/StorageErrorActivity.java
@@ -0,0 +1,75 @@
+package de.danoeh.antennapod.activity;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.util.StorageUtils;
+
+/** Is show if there is now external storage available. */
+public class StorageErrorActivity extends ActionBarActivity {
+ private static final String TAG = "StorageErrorActivity";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.storage_error);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ try {
+ unregisterReceiver(mediaUpdate);
+ } catch (IllegalArgumentException e) {
+
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (StorageUtils.storageAvailable(this)) {
+ leaveErrorState();
+ } else {
+ registerReceiver(mediaUpdate, new IntentFilter(
+ Intent.ACTION_MEDIA_MOUNTED));
+ }
+ }
+
+ private void leaveErrorState() {
+ finish();
+ startActivity(new Intent(this, MainActivity.class));
+ }
+
+ private BroadcastReceiver mediaUpdate = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), Intent.ACTION_MEDIA_MOUNTED)) {
+ if (intent.getBooleanExtra("read-only", true)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Media was mounted; Finishing activity");
+ leaveErrorState();
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Media seemed to have been mounted read only");
+ }
+ }
+ }
+
+ };
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java
new file mode 100644
index 000000000..81661a288
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java
@@ -0,0 +1,359 @@
+package de.danoeh.antennapod.activity;
+
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.graphics.drawable.ColorDrawable;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import android.util.Pair;
+import android.view.MotionEvent;
+import android.view.SurfaceHolder;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.SeekBar;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.service.playback.PlayerStatus;
+import de.danoeh.antennapod.util.playback.ExternalMedia;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.view.AspectRatioVideoView;
+
+/**
+ * Activity for playing video files.
+ */
+public class VideoplayerActivity extends MediaplayerActivity {
+ private static final String TAG = "VideoplayerActivity";
+
+ /**
+ * True if video controls are currently visible.
+ */
+ private boolean videoControlsShowing = true;
+ private boolean videoSurfaceCreated = false;
+ private VideoControlsHider videoControlsToggler;
+
+ private LinearLayout videoOverlay;
+ private AspectRatioVideoView videoview;
+ private ProgressBar progressIndicator;
+
+ @Override
+ protected void chooseTheme() {
+ setTheme(R.style.Theme_AntennaPod_Dark);
+ }
+
+ @SuppressLint("AppCompatMethod")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ if (Build.VERSION.SDK_INT >= 11) {
+ requestWindowFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
+ }
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().setBackgroundDrawable(new ColorDrawable(0x80000000));
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (videoControlsToggler != null) {
+ videoControlsToggler.cancel(true);
+ }
+ if (controller != null && controller.getStatus() == PlayerStatus.PLAYING) {
+ controller.pause();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (getIntent().getAction() != null
+ && getIntent().getAction().equals(Intent.ACTION_VIEW)) {
+ Intent intent = getIntent();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received VIEW intent: "
+ + intent.getData().getPath());
+ ExternalMedia media = new ExternalMedia(intent.getData().getPath(),
+ MediaType.VIDEO);
+ Intent launchIntent = new Intent(this, PlaybackService.class);
+ launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media);
+ launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED,
+ true);
+ launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false);
+ launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY,
+ true);
+ startService(launchIntent);
+ }
+ }
+
+ @Override
+ protected boolean loadMediaInfo() {
+ if (!super.loadMediaInfo()) {
+ return false;
+ }
+ Playable media = controller.getMedia();
+ if (media != null) {
+ getSupportActionBar().setSubtitle(media.getEpisodeTitle());
+ getSupportActionBar().setTitle(media.getFeedTitle());
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void setupGUI() {
+ super.setupGUI();
+ videoOverlay = (LinearLayout) findViewById(R.id.overlay);
+ videoview = (AspectRatioVideoView) findViewById(R.id.videoview);
+ progressIndicator = (ProgressBar) findViewById(R.id.progressIndicator);
+ videoview.getHolder().addCallback(surfaceHolderCallback);
+ videoview.setOnTouchListener(onVideoviewTouched);
+
+ setupVideoControlsToggler();
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
+ WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ }
+
+ @Override
+ protected void onAwaitingVideoSurface() {
+ if (videoSurfaceCreated) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Videosurface already created, setting videosurface now");
+
+ Pair<Integer, Integer> videoSize = controller.getVideoSize();
+ if (videoSize != null && videoSize.first > 0 && videoSize.second > 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Width,height of video: " + videoSize.first + ", " + videoSize.second);
+ videoview.setVideoSize(videoSize.first, videoSize.second);
+ } else {
+ Log.e(TAG, "Could not determine video size");
+ }
+ controller.setVideoSurface(videoview.getHolder());
+ }
+ }
+
+ @Override
+ protected void postStatusMsg(int resId) {
+ if (resId == R.string.player_preparing_msg) {
+ progressIndicator.setVisibility(View.VISIBLE);
+ } else {
+ progressIndicator.setVisibility(View.INVISIBLE);
+ }
+
+ }
+
+ @Override
+ protected void clearStatusMsg() {
+ progressIndicator.setVisibility(View.INVISIBLE);
+ }
+
+ View.OnTouchListener onVideoviewTouched = new View.OnTouchListener() {
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ if (videoControlsToggler != null) {
+ videoControlsToggler.cancel(true);
+ }
+ toggleVideoControlsVisibility();
+ if (videoControlsShowing) {
+ setupVideoControlsToggler();
+ }
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+ };
+
+ @SuppressLint("NewApi")
+ void setupVideoControlsToggler() {
+ if (videoControlsToggler != null) {
+ videoControlsToggler.cancel(true);
+ }
+ videoControlsToggler = new VideoControlsHider();
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ videoControlsToggler
+ .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ videoControlsToggler.execute();
+ }
+ }
+
+ private void toggleVideoControlsVisibility() {
+ if (videoControlsShowing) {
+ getSupportActionBar().hide();
+ hideVideoControls();
+ } else {
+ getSupportActionBar().show();
+ showVideoControls();
+ }
+ videoControlsShowing = !videoControlsShowing;
+ }
+
+ /**
+ * Hides the videocontrols after a certain period of time.
+ */
+ public class VideoControlsHider extends AsyncTask<Void, Void, Void> {
+ @Override
+ protected void onCancelled() {
+ videoControlsToggler = null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ videoControlsToggler = null;
+ }
+
+ private static final int WAITING_INTERVALL = 5000;
+ private static final String TAG = "VideoControlsToggler";
+
+ @Override
+ protected void onProgressUpdate(Void... values) {
+ if (videoControlsShowing) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Hiding video controls");
+ getSupportActionBar().hide();
+ hideVideoControls();
+ videoControlsShowing = false;
+ }
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ Thread.sleep(WAITING_INTERVALL);
+ } catch (InterruptedException e) {
+ return null;
+ }
+ publishProgress();
+ return null;
+ }
+
+ }
+
+ private final SurfaceHolder.Callback surfaceHolderCallback = new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width,
+ int height) {
+ holder.setFixedSize(width, height);
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Videoview holder created");
+ videoSurfaceCreated = true;
+ if (controller.getStatus() == PlayerStatus.PLAYING) {
+ if (controller.serviceAvailable()) {
+ controller.setVideoSurface(holder);
+ } else {
+ Log.e(TAG,
+ "Could'nt attach surface to mediaplayer - reference to service was null");
+ }
+ }
+
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Videosurface was destroyed");
+ videoSurfaceCreated = false;
+ controller.notifyVideoSurfaceAbandoned();
+ }
+ };
+
+
+ @Override
+ protected void onReloadNotification(int notificationCode) {
+ if (notificationCode == PlaybackService.EXTRA_CODE_AUDIO) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "ReloadNotification received, switching to Audioplayer now");
+ finish();
+ startActivity(new Intent(this, AudioplayerActivity.class));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ super.onStartTrackingTouch(seekBar);
+ if (videoControlsToggler != null) {
+ videoControlsToggler.cancel(true);
+ }
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ super.onStopTrackingTouch(seekBar);
+ setupVideoControlsToggler();
+ }
+
+ @Override
+ protected void onBufferStart() {
+ progressIndicator.setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ protected void onBufferEnd() {
+ progressIndicator.setVisibility(View.INVISIBLE);
+ }
+
+ @SuppressLint("NewApi")
+ private void showVideoControls() {
+ videoOverlay.setVisibility(View.VISIBLE);
+ butPlay.setVisibility(View.VISIBLE);
+ final Animation animation = AnimationUtils.loadAnimation(this,
+ R.anim.fade_in);
+ if (animation != null) {
+ videoOverlay.startAnimation(animation);
+ butPlay.startAnimation(animation);
+ }
+ if (Build.VERSION.SDK_INT >= 14) {
+ videoview.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE);
+ }
+ }
+
+ @SuppressLint("NewApi")
+ private void hideVideoControls() {
+ final Animation animation = AnimationUtils.loadAnimation(this,
+ R.anim.fade_out);
+ if (animation != null) {
+ videoOverlay.startAnimation(animation);
+ butPlay.startAnimation(animation);
+ }
+ if (Build.VERSION.SDK_INT >= 14) {
+ videoview.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE);
+ }
+ videoOverlay.setVisibility(View.GONE);
+ butPlay.setVisibility(View.GONE);
+ }
+
+ @Override
+ protected int getContentViewResourceId() {
+ return R.layout.videoplayer_activity;
+ }
+
+
+ @Override
+ protected void setScreenOn(boolean enable) {
+ super.setScreenOn(enable);
+ if (enable) {
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java
new file mode 100644
index 000000000..6a60f65fe
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java
@@ -0,0 +1,372 @@
+package de.danoeh.antennapod.activity.gpoddernet;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.NavUtils;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.*;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetDevice;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.GpodnetSyncService;
+
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Guides the user through the authentication process
+ * Step 1: Request username and password from user
+ * Step 2: Choose device from a list of available devices or create a new one
+ * Step 3: Choose from a list of actions
+ */
+public class GpodnetAuthenticationActivity extends ActionBarActivity {
+ private static final String TAG = "GpodnetAuthenticationActivity";
+
+ private static final String CURRENT_STEP = "current_step";
+
+ private ViewFlipper viewFlipper;
+
+ private static final int STEP_DEFAULT = -1;
+ private static final int STEP_LOGIN = 0;
+ private static final int STEP_DEVICE = 1;
+ private static final int STEP_FINISH = 2;
+
+ private int currentStep = -1;
+
+ private GpodnetService service;
+ private volatile String username;
+ private volatile String password;
+ private volatile GpodnetDevice selectedDevice;
+
+ View[] views;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ setTheme(UserPreferences.getTheme());
+ super.onCreate(savedInstanceState);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+
+ setContentView(R.layout.gpodnetauth_activity);
+ service = new GpodnetService();
+
+ viewFlipper = (ViewFlipper) findViewById(R.id.viewflipper);
+ LayoutInflater inflater = (LayoutInflater)
+ getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ views = new View[]{
+ inflater.inflate(R.layout.gpodnetauth_credentials, viewFlipper, false),
+ inflater.inflate(R.layout.gpodnetauth_device, viewFlipper, false),
+ inflater.inflate(R.layout.gpodnetauth_finish, viewFlipper, false)
+ };
+ for (View view : views) {
+ viewFlipper.addView(view);
+ }
+ advance();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (service != null) {
+ service.shutdown();
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ switch (item.getItemId()) {
+ case android.R.id.home:
+ NavUtils.navigateUpFromSameTask(this);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ }
+
+ private void setupLoginView(View view) {
+ final EditText username = (EditText) view.findViewById(R.id.etxtUsername);
+ final EditText password = (EditText) view.findViewById(R.id.etxtPassword);
+ final Button login = (Button) view.findViewById(R.id.butLogin);
+ final TextView txtvError = (TextView) view.findViewById(R.id.txtvError);
+ final ProgressBar progressBar = (ProgressBar) view.findViewById(R.id.progBarLogin);
+
+ login.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+
+ final String usernameStr = username.getText().toString();
+ final String passwordStr = password.getText().toString();
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Checking login credentials");
+ new AsyncTask<GpodnetService, Void, Void>() {
+
+ volatile Exception exception;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ login.setEnabled(false);
+ progressBar.setVisibility(View.VISIBLE);
+ txtvError.setVisibility(View.GONE);
+
+ }
+
+ @Override
+ protected void onPostExecute(Void aVoid) {
+ super.onPostExecute(aVoid);
+ login.setEnabled(true);
+ progressBar.setVisibility(View.GONE);
+
+ if (exception == null) {
+ advance();
+ } else {
+ txtvError.setText(exception.getMessage());
+ txtvError.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ protected Void doInBackground(GpodnetService... params) {
+ try {
+ params[0].authenticate(usernameStr, passwordStr);
+ GpodnetAuthenticationActivity.this.username = usernameStr;
+ GpodnetAuthenticationActivity.this.password = passwordStr;
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ exception = e;
+ }
+ return null;
+ }
+ }.execute(service);
+ }
+ });
+ }
+
+ private void setupDeviceView(View view) {
+ final EditText deviceID = (EditText) view.findViewById(R.id.etxtDeviceID);
+ final EditText caption = (EditText) view.findViewById(R.id.etxtCaption);
+ final Button createNewDevice = (Button) view.findViewById(R.id.butCreateNewDevice);
+ final Button chooseDevice = (Button) view.findViewById(R.id.butChooseExistingDevice);
+ final TextView txtvError = (TextView) view.findViewById(R.id.txtvError);
+ final ProgressBar progBarCreateDevice = (ProgressBar) view.findViewById(R.id.progbarCreateDevice);
+ final Spinner spinnerDevices = (Spinner) view.findViewById(R.id.spinnerChooseDevice);
+
+
+ // load device list
+ final AtomicReference<List<GpodnetDevice>> devices = new AtomicReference<List<GpodnetDevice>>();
+ new AsyncTask<GpodnetService, Void, List<GpodnetDevice>>() {
+
+ private volatile Exception exception;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ chooseDevice.setEnabled(false);
+ spinnerDevices.setEnabled(false);
+ createNewDevice.setEnabled(false);
+ }
+
+ @Override
+ protected void onPostExecute(List<GpodnetDevice> gpodnetDevices) {
+ super.onPostExecute(gpodnetDevices);
+ if (gpodnetDevices != null) {
+ List<String> deviceNames = new ArrayList<String>();
+ for (GpodnetDevice device : gpodnetDevices) {
+ deviceNames.add(device.getCaption());
+ }
+ spinnerDevices.setAdapter(new ArrayAdapter<String>(GpodnetAuthenticationActivity.this,
+ android.R.layout.simple_spinner_dropdown_item, deviceNames));
+ spinnerDevices.setEnabled(true);
+ if (!deviceNames.isEmpty()) {
+ chooseDevice.setEnabled(true);
+ }
+ devices.set(gpodnetDevices);
+ createNewDevice.setEnabled(true);
+ }
+ }
+
+ @Override
+ protected List<GpodnetDevice> doInBackground(GpodnetService... params) {
+ try {
+ return params[0].getDevices(username);
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ exception = e;
+ return null;
+ }
+ }
+ }.execute(service);
+
+
+ createNewDevice.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (checkDeviceIDText(deviceID, txtvError, devices.get())) {
+ final String deviceStr = deviceID.getText().toString();
+ final String captionStr = caption.getText().toString();
+
+ new AsyncTask<GpodnetService, Void, GpodnetDevice>() {
+
+ private volatile Exception exception;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ createNewDevice.setEnabled(false);
+ chooseDevice.setEnabled(false);
+ progBarCreateDevice.setVisibility(View.VISIBLE);
+ txtvError.setVisibility(View.GONE);
+ }
+
+ @Override
+ protected void onPostExecute(GpodnetDevice result) {
+ super.onPostExecute(result);
+ createNewDevice.setEnabled(true);
+ chooseDevice.setEnabled(true);
+ progBarCreateDevice.setVisibility(View.GONE);
+ if (exception == null) {
+ selectedDevice = result;
+ advance();
+ } else {
+ txtvError.setText(exception.getMessage());
+ txtvError.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ protected GpodnetDevice doInBackground(GpodnetService... params) {
+ try {
+ params[0].configureDevice(username, deviceStr, captionStr, GpodnetDevice.DeviceType.MOBILE);
+ return new GpodnetDevice(deviceStr, captionStr, GpodnetDevice.DeviceType.MOBILE.toString(), 0);
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ exception = e;
+ }
+ return null;
+ }
+ }.execute(service);
+ }
+ }
+ });
+
+ deviceID.setText(generateDeviceID());
+ chooseDevice.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ final int position = spinnerDevices.getSelectedItemPosition();
+ if (position != AdapterView.INVALID_POSITION) {
+ selectedDevice = devices.get().get(position);
+ advance();
+ }
+ }
+ });
+ }
+
+
+ private String generateDeviceID() {
+ final int DEVICE_ID_LENGTH = 10;
+ StringBuilder buffer = new StringBuilder(DEVICE_ID_LENGTH);
+ SecureRandom random = new SecureRandom();
+ for (int i = 0; i < DEVICE_ID_LENGTH; i++) {
+ buffer.append(random.nextInt(10));
+
+ }
+ return buffer.toString();
+ }
+
+ private boolean checkDeviceIDText(EditText deviceID, TextView txtvError, List<GpodnetDevice> devices) {
+ String text = deviceID.getText().toString();
+ if (text.length() == 0) {
+ txtvError.setText(R.string.gpodnetauth_device_errorEmpty);
+ txtvError.setVisibility(View.VISIBLE);
+ return false;
+ } else {
+ if (devices != null) {
+ for (GpodnetDevice device : devices) {
+ if (device.getId().equals(text)) {
+ txtvError.setText(R.string.gpodnetauth_device_errorAlreadyUsed);
+ txtvError.setVisibility(View.VISIBLE);
+ return false;
+ }
+ }
+ txtvError.setVisibility(View.GONE);
+ return true;
+ }
+ return true;
+ }
+
+ }
+
+ private void setupFinishView(View view) {
+ final Button sync = (Button) view.findViewById(R.id.butSyncNow);
+ final Button back = (Button) view.findViewById(R.id.butGoMainscreen);
+
+ sync.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ GpodnetSyncService.sendSyncIntent(GpodnetAuthenticationActivity.this);
+ finish();
+ }
+ });
+ back.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(GpodnetAuthenticationActivity.this, MainActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ }
+ });
+ }
+
+ private void writeLoginCredentials() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Writing login credentials");
+ GpodnetPreferences.setUsername(username);
+ GpodnetPreferences.setPassword(password);
+ GpodnetPreferences.setDeviceID(selectedDevice.getId());
+ }
+
+ private void advance() {
+ if (currentStep < STEP_FINISH) {
+
+ View view = views[currentStep + 1];
+ if (currentStep == STEP_DEFAULT) {
+ setupLoginView(view);
+ } else if (currentStep == STEP_LOGIN) {
+ if (username == null || password == null) {
+ throw new IllegalStateException("Username and password must not be null here");
+ } else {
+ setupDeviceView(view);
+ }
+ } else if (currentStep == STEP_DEVICE) {
+ if (selectedDevice == null) {
+ throw new IllegalStateException("Device must not be null here");
+ } else {
+ writeLoginCredentials();
+ setupFinishView(view);
+ }
+ }
+ if (currentStep != STEP_DEFAULT) {
+ viewFlipper.showNext();
+ }
+ currentStep++;
+ } else {
+ finish();
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java
new file mode 100644
index 000000000..30ad2d03f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonCallback.java
@@ -0,0 +1,8 @@
+package de.danoeh.antennapod.adapter;
+
+import de.danoeh.antennapod.feed.FeedItem;
+
+public interface ActionButtonCallback {
+ /** Is called when the action button of a list item has been pressed. */
+ abstract void onActionButtonPressed(FeedItem item);
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java
new file mode 100644
index 000000000..1de071a73
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/ActionButtonUtils.java
@@ -0,0 +1,78 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.view.View;
+import android.widget.ImageButton;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+/**
+ * Utility methods for the action button that is displayed on the right hand side
+ * of a listitem.
+ */
+public class ActionButtonUtils {
+
+ private final int[] labels;
+ private final TypedArray drawables;
+ private final Context context;
+
+ public ActionButtonUtils(Context context) {
+ Validate.notNull(context);
+
+ this.context = context;
+ drawables = context.obtainStyledAttributes(new int[]{
+ R.attr.av_play, R.attr.navigation_cancel, R.attr.av_download, R.attr.navigation_chapters, R.attr.navigation_accept});
+ labels = new int[]{R.string.play_label, R.string.cancel_download_label, R.string.download_label, R.string.mark_read_label};
+ }
+
+ /**
+ * Sets the displayed bitmap and content description of the given
+ * action button so that it matches the state of the FeedItem.
+ */
+ public void configureActionButton(ImageButton butSecondary, FeedItem item) {
+ Validate.isTrue(butSecondary != null && item != null, "butSecondary or item was null");
+
+ final FeedMedia media = item.getMedia();
+ if (media != null) {
+ final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media);
+ if (!media.isDownloaded()) {
+ if (isDownloadingMedia) {
+ // item is being downloaded
+ butSecondary.setVisibility(View.VISIBLE);
+ butSecondary.setImageDrawable(drawables
+ .getDrawable(1));
+ butSecondary.setContentDescription(context.getString(labels[1]));
+ } else {
+ // item is not downloaded and not being downloaded
+ butSecondary.setVisibility(View.VISIBLE);
+ butSecondary.setImageDrawable(drawables.getDrawable(2));
+ butSecondary.setContentDescription(context.getString(labels[2]));
+ }
+ } else {
+ // item is not being downloaded
+ butSecondary.setVisibility(View.VISIBLE);
+ if (media.isPlaying()) {
+ butSecondary.setImageDrawable(drawables.getDrawable(3));
+ } else {
+ butSecondary
+ .setImageDrawable(drawables.getDrawable(0));
+ }
+ butSecondary.setContentDescription(context.getString(labels[0]));
+ }
+ } else {
+ if (item.isRead()) {
+ butSecondary.setVisibility(View.INVISIBLE);
+ } else {
+ butSecondary.setVisibility(View.VISIBLE);
+ butSecondary.setImageDrawable(drawables.getDrawable(4));
+ butSecondary.setContentDescription(context.getString(labels[3]));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java b/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java
new file mode 100644
index 000000000..f393fb7d7
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/AdapterUtils.java
@@ -0,0 +1,57 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.res.Resources;
+import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.util.Converter;
+
+/**
+ * Utility methods for adapters
+ */
+public class AdapterUtils {
+
+ private AdapterUtils() {
+
+ }
+
+ /**
+ * Updates the contents of the TextView that shows the current playback position and the ProgressBar.
+ */
+ public static void updateEpisodePlaybackProgress(FeedItem item, Resources res, TextView txtvPos, ProgressBar episodeProgress) {
+ FeedMedia media = item.getMedia();
+ episodeProgress.setVisibility(View.GONE);
+ if (media == null) {
+ txtvPos.setVisibility(View.GONE);
+ return;
+ } else {
+ txtvPos.setVisibility(View.VISIBLE);
+ }
+
+ FeedItem.State state = item.getState();
+ if (state == FeedItem.State.PLAYING
+ || state == FeedItem.State.IN_PROGRESS) {
+ if (media.getDuration() > 0) {
+ episodeProgress.setVisibility(View.VISIBLE);
+ episodeProgress
+ .setProgress((int) (((double) media
+ .getPosition()) / media.getDuration() * 100));
+ txtvPos.setText(Converter
+ .getDurationStringLong(media.getDuration()
+ - media.getPosition()));
+ }
+ } else if (!media.isDownloaded()) {
+ txtvPos.setText(res.getString(
+ R.string.size_prefix)
+ + Converter.byteToString(media.getSize()));
+ } else {
+ txtvPos.setText(res.getString(
+ R.string.length_prefix)
+ + Converter.getDurationStringLong(media
+ .getDuration()));
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java
new file mode 100644
index 000000000..c12de6ebd
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/ChapterListAdapter.java
@@ -0,0 +1,180 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.text.Layout;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.text.util.Linkify;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnTouchListener;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.util.ChapterUtils;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.playback.Playable;
+
+import java.util.List;
+
+public class ChapterListAdapter extends ArrayAdapter<Chapter> {
+
+ private static final String TAG = "ChapterListAdapter";
+
+ private List<Chapter> chapters;
+ private Playable media;
+
+ private int defaultTextColor;
+
+ public ChapterListAdapter(Context context, int textViewResourceId,
+ List<Chapter> objects, Playable media) {
+ super(context, textViewResourceId, objects);
+ this.chapters = objects;
+ this.media = media;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+
+ Chapter sc = getItem(position);
+
+ // Inflate Layout
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.simplechapter_item, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ defaultTextColor = holder.title.getTextColors().getDefaultColor();
+ holder.start = (TextView) convertView.findViewById(R.id.txtvStart);
+ holder.link = (TextView) convertView.findViewById(R.id.txtvLink);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+
+ }
+
+ holder.title.setText(sc.getTitle());
+ holder.start.setText(Converter.getDurationStringLong((int) sc
+ .getStart()));
+ if (sc.getLink() != null) {
+ holder.link.setVisibility(View.VISIBLE);
+ holder.link.setText(sc.getLink());
+ Linkify.addLinks(holder.link, Linkify.WEB_URLS);
+ } else {
+ holder.link.setVisibility(View.GONE);
+ }
+ holder.link.setMovementMethod(null);
+ holder.link.setOnTouchListener(new OnTouchListener() {
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ // from
+ // http://stackoverflow.com/questions/7236840/android-textview-linkify-intercepts-with-parent-view-gestures
+ TextView widget = (TextView) v;
+ Object text = widget.getText();
+ if (text instanceof Spanned) {
+ Spannable buffer = (Spannable) text;
+
+ int action = event.getAction();
+
+ if (action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_DOWN) {
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+
+ x -= widget.getTotalPaddingLeft();
+ y -= widget.getTotalPaddingTop();
+
+ x += widget.getScrollX();
+ y += widget.getScrollY();
+
+ Layout layout = widget.getLayout();
+ int line = layout.getLineForVertical(y);
+ int off = layout.getOffsetForHorizontal(line, x);
+
+ ClickableSpan[] link = buffer.getSpans(off, off,
+ ClickableSpan.class);
+
+ if (link.length != 0) {
+ if (action == MotionEvent.ACTION_UP) {
+ link[0].onClick(widget);
+ } else if (action == MotionEvent.ACTION_DOWN) {
+ Selection.setSelection(buffer,
+ buffer.getSpanStart(link[0]),
+ buffer.getSpanEnd(link[0]));
+ }
+ return true;
+ }
+ }
+
+ }
+
+ return false;
+
+ }
+ });
+ Chapter current = ChapterUtils.getCurrentChapter(media);
+ if (current != null) {
+ if (current == sc) {
+ holder.title.setTextColor(convertView.getResources().getColor(
+ R.color.bright_blue));
+ holder.start.setTextColor(convertView.getResources().getColor(
+ R.color.bright_blue));
+ } else {
+ holder.title.setTextColor(defaultTextColor);
+ holder.start.setTextColor(defaultTextColor);
+ }
+ } else {
+ Log.w(TAG, "Could not find out what the current chapter is.");
+ }
+
+ return convertView;
+ }
+
+ static class Holder {
+ TextView title;
+ TextView start;
+ TextView link;
+ }
+
+ @Override
+ public int getCount() {
+ // ignore invalid chapters
+ int counter = 0;
+ for (Chapter chapter : chapters) {
+ if (!ignoreChapter(chapter)) {
+ counter++;
+ }
+ }
+ return counter;
+ }
+
+ private boolean ignoreChapter(Chapter c) {
+ return media.getDuration() > 0 && media.getDuration() < c.getStart();
+ }
+
+ @Override
+ public Chapter getItem(int position) {
+ int i = 0;
+ for (Chapter chapter : chapters) {
+ if (!ignoreChapter(chapter)) {
+ if (i == position) {
+ return chapter;
+ } else {
+ i++;
+ }
+ }
+ }
+ return super.getItem(position);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java
new file mode 100644
index 000000000..0c4cbe685
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java
@@ -0,0 +1,57 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+/**
+ * Default implementation of an ActionButtonCallback
+ */
+public class DefaultActionButtonCallback implements ActionButtonCallback {
+ private static final String TAG = "DefaultActionButtonCallback";
+
+ private final Context context;
+
+ public DefaultActionButtonCallback(Context context) {
+ Validate.notNull(context);
+ this.context = context;
+ }
+
+ @Override
+ public void onActionButtonPressed(final FeedItem item) {
+
+
+ if (item.hasMedia()) {
+ final FeedMedia media = item.getMedia();
+ boolean isDownloading = DownloadRequester.getInstance().isDownloadingFile(media);
+ if (!isDownloading && !media.isDownloaded()) {
+ try {
+ DBTasks.downloadFeedItems(context, item);
+ Toast.makeText(context, R.string.status_downloading_label, Toast.LENGTH_SHORT).show();
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DownloadRequestErrorDialogCreator.newRequestErrorDialog(context, e.getMessage());
+ }
+ } else if (isDownloading) {
+ DownloadRequester.getInstance().cancelDownload(context, media);
+ Toast.makeText(context, R.string.download_cancelled_msg, Toast.LENGTH_SHORT).show();
+ } else { // media is downloaded
+ DBTasks.playMedia(context, media, true, true, false);
+ }
+ } else {
+ if (!item.isRead()) {
+ DBWriter.markItemRead(context, item, true, true);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java
new file mode 100644
index 000000000..2cc216227
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadLogAdapter.java
@@ -0,0 +1,112 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+
+/** Displays a list of DownloadStatus entries. */
+public class DownloadLogAdapter extends BaseAdapter {
+
+ private Context context;
+
+ private ItemAccess itemAccess;
+
+ public DownloadLogAdapter(Context context, ItemAccess itemAccess) {
+ super();
+ this.itemAccess = itemAccess;
+ this.context = context;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ DownloadStatus status = getItem(position);
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.downloadlog_item, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.type = (TextView) convertView.findViewById(R.id.txtvType);
+ holder.date = (TextView) convertView.findViewById(R.id.txtvDate);
+ holder.successful = (TextView) convertView
+ .findViewById(R.id.txtvStatus);
+ holder.reason = (TextView) convertView
+ .findViewById(R.id.txtvReason);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+ if (status.getFeedfileType() == Feed.FEEDFILETYPE_FEED) {
+ holder.type.setText(R.string.download_type_feed);
+ } else if (status.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ holder.type.setText(R.string.download_type_media);
+ } else if (status.getFeedfileType() == FeedImage.FEEDFILETYPE_FEEDIMAGE) {
+ holder.type.setText(R.string.download_type_image);
+ }
+ if (status.getTitle() != null) {
+ holder.title.setText(status.getTitle());
+ } else {
+ holder.title.setText(R.string.download_log_title_unknown);
+ }
+ holder.date.setText(DateUtils.getRelativeTimeSpanString(
+ status.getCompletionDate().getTime(),
+ System.currentTimeMillis(), 0, 0));
+ if (status.isSuccessful()) {
+ holder.successful.setTextColor(convertView.getResources().getColor(
+ R.color.download_success_green));
+ holder.successful.setText(R.string.download_successful);
+ holder.reason.setVisibility(View.GONE);
+ } else {
+ holder.successful.setTextColor(convertView.getResources().getColor(
+ R.color.download_failed_red));
+ holder.successful.setText(R.string.download_failed);
+ String reasonText = status.getReason().getErrorString(context);
+ if (status.getReasonDetailed() != null) {
+ reasonText += ": " + status.getReasonDetailed();
+ }
+ holder.reason.setText(reasonText);
+ holder.reason.setVisibility(View.VISIBLE);
+ }
+
+ return convertView;
+ }
+
+ static class Holder {
+ TextView title;
+ TextView type;
+ TextView date;
+ TextView successful;
+ TextView reason;
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public DownloadStatus getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ public static interface ItemAccess {
+ public int getCount();
+ public DownloadStatus getItem(int position);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java
new file mode 100644
index 000000000..ef5af67de
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadedEpisodesListAdapter.java
@@ -0,0 +1,122 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.util.Converter;
+
+/**
+ * Shows a list of downloaded episodes
+ */
+public class DownloadedEpisodesListAdapter extends BaseAdapter {
+
+ private final Context context;
+ private final ItemAccess itemAccess;
+
+ private final int imageSize;
+
+ public DownloadedEpisodesListAdapter(Context context, ItemAccess itemAccess) {
+ super();
+ this.context = context;
+ this.itemAccess = itemAccess;
+ this.imageSize = (int) context.getResources().getDimension(R.dimen.thumbnail_length_downloaded_item);
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ final FeedItem item = (FeedItem) getItem(position);
+ if (item == null) return null;
+
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.downloaded_episodeslist_item,
+ parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.pubDate = (TextView) convertView
+ .findViewById(R.id.txtvPublished);
+ holder.butSecondary = (ImageButton) convertView
+ .findViewById(R.id.butSecondaryAction);
+ holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage);
+ holder.txtvSize = (TextView) convertView.findViewById(R.id.txtvSize);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(item.getTitle());
+ holder.pubDate.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE));
+ holder.txtvSize.setText(Converter.byteToString(item.getMedia().getSize()));
+ FeedItem.State state = item.getState();
+
+ if (state == FeedItem.State.PLAYING) {
+ holder.butSecondary.setEnabled(false);
+ } else {
+ holder.butSecondary.setEnabled(true);
+ }
+
+ holder.butSecondary.setFocusable(false);
+ holder.butSecondary.setTag(item);
+ holder.butSecondary.setOnClickListener(secondaryActionListener);
+
+
+ PicassoProvider.getMediaMetadataPicassoInstance(context)
+ .load(item.getImageUri())
+ .fit()
+ .into(holder.imageView);
+
+ return convertView;
+ }
+
+ private View.OnClickListener secondaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FeedItem item = (FeedItem) v.getTag();
+ itemAccess.onFeedItemSecondaryAction(item);
+ }
+ };
+
+
+ static class Holder {
+ TextView title;
+ TextView pubDate;
+ ImageView imageView;
+ TextView txtvSize;
+ ImageButton butSecondary;
+ }
+
+ public interface ItemAccess {
+ int getCount();
+
+ FeedItem getItem(int position);
+
+ void onFeedItemSecondaryAction(FeedItem item);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java
new file mode 100644
index 000000000..658af9e4e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/DownloadlistAdapter.java
@@ -0,0 +1,142 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageButton;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.service.download.DownloadRequest;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.ThemeUtils;
+
+public class DownloadlistAdapter extends BaseAdapter {
+
+ public static final int SELECTION_NONE = -1;
+
+ private int selectedItemIndex;
+ private ItemAccess itemAccess;
+ private Context context;
+
+ public DownloadlistAdapter(Context context,
+ ItemAccess itemAccess) {
+ super();
+ this.selectedItemIndex = SELECTION_NONE;
+ this.context = context;
+ this.itemAccess = itemAccess;
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public Downloader getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ Downloader downloader = getItem(position);
+ DownloadRequest request = downloader.getDownloadRequest();
+ // Inflate layout
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.downloadlist_item, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.message = (TextView) convertView
+ .findViewById(R.id.txtvMessage);
+ holder.downloaded = (TextView) convertView
+ .findViewById(R.id.txtvDownloaded);
+ holder.percent = (TextView) convertView
+ .findViewById(R.id.txtvPercent);
+ holder.progbar = (ProgressBar) convertView
+ .findViewById(R.id.progProgress);
+ holder.butSecondary = (ImageButton) convertView
+ .findViewById(R.id.butSecondaryAction);
+
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ if (position == selectedItemIndex) {
+ convertView.setBackgroundColor(convertView.getResources().getColor(
+ ThemeUtils.getSelectionBackgroundColor()));
+ } else {
+ convertView.setBackgroundResource(0);
+ }
+
+ holder.title.setText(request.getTitle());
+ if (request.getStatusMsg() != 0) {
+ holder.message.setText(request.getStatusMsg());
+ }
+ String strDownloaded = Converter.byteToString(request.getSoFar());
+ if (request.getSize() != DownloadStatus.SIZE_UNKNOWN) {
+ strDownloaded += " / " + Converter.byteToString(request.getSize());
+ holder.percent.setText(request.getProgressPercent() + "%");
+ holder.progbar.setProgress(request.getProgressPercent());
+ holder.percent.setVisibility(View.VISIBLE);
+ } else {
+ holder.progbar.setProgress(0);
+ holder.percent.setVisibility(View.INVISIBLE);
+ }
+
+ holder.downloaded.setText(strDownloaded);
+
+ holder.butSecondary.setFocusable(false);
+ holder.butSecondary.setTag(downloader);
+ holder.butSecondary.setOnClickListener(butSecondaryListener);
+
+ return convertView;
+ }
+
+ private View.OnClickListener butSecondaryListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Downloader downloader = (Downloader) v.getTag();
+ itemAccess.onSecondaryActionClick(downloader);
+ }
+ };
+
+ static class Holder {
+ TextView title;
+ TextView message;
+ TextView downloaded;
+ TextView percent;
+ ProgressBar progbar;
+ ImageButton butSecondary;
+ }
+
+ public int getSelectedItemIndex() {
+ return selectedItemIndex;
+ }
+
+ public void setSelectedItemIndex(int selectedItemIndex) {
+ this.selectedItemIndex = selectedItemIndex;
+ notifyDataSetChanged();
+ }
+
+ public interface ItemAccess {
+ public int getCount();
+
+ public Downloader getItem(int position);
+
+ public void onSecondaryActionClick(Downloader downloader);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java
new file mode 100644
index 000000000..3f666eb8b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/ExternalEpisodesListAdapter.java
@@ -0,0 +1,306 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.Converter;
+
+/**
+ * Displays unread items and items in the queue in one combined list. The
+ * structure of this list is: [header] [queueItems] [header] [unreadItems].
+ */
+public class ExternalEpisodesListAdapter extends BaseExpandableListAdapter {
+ private static final String TAG = "ExternalEpisodesListAdapter";
+
+ public static final int GROUP_POS_QUEUE = 0;
+ public static final int GROUP_POS_UNREAD = 1;
+
+ private Context context;
+ private ItemAccess itemAccess;
+
+ private ActionButtonCallback feedItemActionCallback;
+ private OnGroupActionClicked groupActionCallback;
+
+ private final int imageSize;
+
+ public ExternalEpisodesListAdapter(Context context,
+ ActionButtonCallback callback,
+ OnGroupActionClicked groupActionCallback,
+ ItemAccess itemAccess) {
+ super();
+ this.context = context;
+ this.itemAccess = itemAccess;
+ this.feedItemActionCallback = callback;
+ this.groupActionCallback = groupActionCallback;
+ this.imageSize = (int) context.getResources().getDimension(R.dimen.thumbnail_length);
+ }
+
+ @Override
+ public boolean areAllItemsEnabled() {
+ return true;
+ }
+
+ @Override
+ public FeedItem getChild(int groupPosition, int childPosition) {
+ if (groupPosition == GROUP_POS_QUEUE) {
+ return itemAccess.getQueueItemAt(childPosition);
+ } else if (groupPosition == GROUP_POS_UNREAD) {
+ return itemAccess.getUnreadItemAt(childPosition);
+ }
+ return null;
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ return childPosition;
+ }
+
+ @Override
+ public View getChildView(int groupPosition, final int childPosition,
+ boolean isLastChild, View convertView, ViewGroup parent) {
+ Holder holder;
+ final FeedItem item = getChild(groupPosition, childPosition);
+
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.external_itemlist_item,
+ parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.feedTitle = (TextView) convertView
+ .findViewById(R.id.txtvFeedname);
+ holder.lenSize = (TextView) convertView
+ .findViewById(R.id.txtvLenSize);
+ holder.downloadStatus = (ImageView) convertView
+ .findViewById(R.id.imgvDownloadStatus);
+ holder.feedImage = (ImageView) convertView
+ .findViewById(R.id.imgvFeedimage);
+ holder.butAction = (ImageButton) convertView
+ .findViewById(R.id.butAction);
+ holder.statusPlaying = (View) convertView
+ .findViewById(R.id.statusPlaying);
+ holder.episodeProgress = (ProgressBar) convertView
+ .findViewById(R.id.pbar_episode_progress);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(item.getTitle());
+ holder.feedTitle.setText(item.getFeed().getTitle());
+ FeedItem.State state = item.getState();
+
+ if (groupPosition == GROUP_POS_QUEUE) {
+ switch (state) {
+ case PLAYING:
+ holder.statusPlaying.setVisibility(View.VISIBLE);
+ holder.episodeProgress.setVisibility(View.VISIBLE);
+ break;
+ case IN_PROGRESS:
+ holder.statusPlaying.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.VISIBLE);
+ break;
+ case NEW:
+ holder.statusPlaying.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.GONE);
+ break;
+ default:
+ holder.statusPlaying.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.GONE);
+ break;
+ }
+ } else {
+ holder.statusPlaying.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.GONE);
+ }
+
+ FeedMedia media = item.getMedia();
+ if (media != null) {
+
+ if (state == FeedItem.State.PLAYING
+ || state == FeedItem.State.IN_PROGRESS) {
+ if (media.getDuration() > 0) {
+ holder.episodeProgress.setProgress((int) (((double) media
+ .getPosition()) / media.getDuration() * 100));
+ holder.lenSize.setText(Converter
+ .getDurationStringLong(media.getDuration()
+ - media.getPosition()));
+ }
+ } else if (!media.isDownloaded()) {
+ holder.lenSize.setText(context.getString(R.string.size_prefix)
+ + Converter.byteToString(media.getSize()));
+ } else {
+ holder.lenSize.setText(context
+ .getString(R.string.length_prefix)
+ + Converter.getDurationStringLong(media.getDuration()));
+ }
+
+ TypedArray drawables = context.obtainStyledAttributes(new int[]{
+ R.attr.av_download, R.attr.navigation_refresh});
+ final int[] labels = new int[]{R.string.status_downloaded_label, R.string.downloading_label};
+ holder.lenSize.setVisibility(View.VISIBLE);
+ if (!media.isDownloaded()) {
+ if (DownloadRequester.getInstance().isDownloadingFile(media)) {
+ holder.downloadStatus.setVisibility(View.VISIBLE);
+ holder.downloadStatus.setImageDrawable(drawables
+ .getDrawable(1));
+ holder.downloadStatus.setContentDescription(context.getString(labels[1]));
+ } else {
+ holder.downloadStatus.setVisibility(View.INVISIBLE);
+ }
+ } else {
+ holder.downloadStatus.setVisibility(View.VISIBLE);
+ holder.downloadStatus
+ .setImageDrawable(drawables.getDrawable(0));
+ holder.downloadStatus.setContentDescription(context.getString(labels[0]));
+ }
+ } else {
+ holder.downloadStatus.setVisibility(View.INVISIBLE);
+ holder.lenSize.setVisibility(View.INVISIBLE);
+ }
+
+ PicassoProvider.getMediaMetadataPicassoInstance(context)
+ .load(item.getImageUri())
+ .fit()
+ .into(holder.feedImage);
+
+ holder.butAction.setFocusable(false);
+ holder.butAction.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ feedItemActionCallback.onActionButtonPressed(item);
+ }
+ });
+
+ return convertView;
+
+ }
+
+ static class Holder {
+ TextView title;
+ TextView feedTitle;
+ TextView lenSize;
+ ImageView downloadStatus;
+ ImageView feedImage;
+ ImageButton butAction;
+ View statusPlaying;
+ ProgressBar episodeProgress;
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ if (groupPosition == GROUP_POS_QUEUE) {
+ return itemAccess.getQueueSize();
+ } else if (groupPosition == GROUP_POS_UNREAD) {
+ return itemAccess.getUnreadItemsSize();
+ }
+ return 0;
+ }
+
+ @Override
+ public int getGroupCount() {
+ // Hide 'unread items' group if empty
+ if (itemAccess.getUnreadItemsSize() > 0) {
+ return 2;
+ } else {
+ return 1;
+ }
+ }
+
+ @Override
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public View getGroupView(final int groupPosition, boolean isExpanded,
+ View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.feeditemlist_header, parent, false);
+ TextView headerTitle = (TextView) convertView
+ .findViewById(0);
+ ImageButton actionButton = (ImageButton) convertView
+ .findViewById(R.id.butAction);
+ TextView numItems = (TextView) convertView.findViewById(0);
+
+ String headerString = null;
+ int childrenCount = 0;
+
+ if (groupPosition == 0) {
+ headerString = context.getString(R.string.queue_label);
+ childrenCount = getChildrenCount(GROUP_POS_QUEUE);
+ } else {
+ headerString = context.getString(R.string.waiting_list_label);
+ childrenCount = getChildrenCount(GROUP_POS_UNREAD);
+ }
+ headerTitle.setText(headerString);
+ if (childrenCount <= 0) {
+ numItems.setVisibility(View.INVISIBLE);
+ } else {
+ numItems.setVisibility(View.VISIBLE);
+ numItems.setText(Integer.toString(childrenCount));
+ }
+ actionButton.setFocusable(false);
+ actionButton.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ groupActionCallback.onClick(getGroupId(groupPosition));
+ }
+ });
+ return convertView;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return itemAccess.getUnreadItemsSize() == 0
+ && itemAccess.getQueueSize() == 0;
+ }
+
+ @Override
+ public Object getGroup(int groupPosition) {
+ return null;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return true;
+ }
+
+ public interface OnGroupActionClicked {
+ public void onClick(long groupId);
+ }
+
+ public static interface ItemAccess {
+ public int getQueueSize();
+
+ public int getUnreadItemsSize();
+
+ public FeedItem getQueueItemAt(int position);
+
+ public FeedItem getUnreadItemAt(int position);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java
new file mode 100644
index 000000000..357b5f8b4
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistAdapter.java
@@ -0,0 +1,220 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.*;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.ThemeUtils;
+
+/**
+ * List adapter for items of feeds that the user has already subscribed to.
+ */
+public class FeedItemlistAdapter extends BaseAdapter {
+
+ private ActionButtonCallback callback;
+ private final ItemAccess itemAccess;
+ private final Context context;
+ private boolean showFeedtitle;
+ private int selectedItemIndex;
+ private final ActionButtonUtils actionButtonUtils;
+
+ public static final int SELECTION_NONE = -1;
+
+ public FeedItemlistAdapter(Context context,
+ ItemAccess itemAccess,
+ ActionButtonCallback callback, boolean showFeedtitle) {
+ super();
+ this.callback = callback;
+ this.context = context;
+ this.itemAccess = itemAccess;
+ this.showFeedtitle = showFeedtitle;
+ this.selectedItemIndex = SELECTION_NONE;
+ this.actionButtonUtils = new ActionButtonUtils(context);
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public View getView(final int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ final FeedItem item = getItem(position);
+
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.feeditemlist_item, parent, false);
+ holder.title = (TextView) convertView
+ .findViewById(R.id.txtvItemname);
+ holder.lenSize = (TextView) convertView
+ .findViewById(R.id.txtvLenSize);
+ holder.butAction = (ImageButton) convertView
+ .findViewById(R.id.butSecondaryAction);
+ holder.published = (TextView) convertView
+ .findViewById(R.id.txtvPublished);
+ holder.inPlaylist = (ImageView) convertView
+ .findViewById(R.id.imgvInPlaylist);
+ holder.type = (ImageView) convertView.findViewById(R.id.imgvType);
+ holder.statusUnread = (View) convertView
+ .findViewById(R.id.statusUnread);
+ holder.episodeProgress = (ProgressBar) convertView
+ .findViewById(R.id.pbar_episode_progress);
+
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+ if (!(getItemViewType(position) == Adapter.IGNORE_ITEM_VIEW_TYPE)) {
+ convertView.setVisibility(View.VISIBLE);
+ if (position == selectedItemIndex) {
+ convertView.setBackgroundColor(convertView.getResources()
+ .getColor(ThemeUtils.getSelectionBackgroundColor()));
+ } else {
+ convertView.setBackgroundResource(0);
+ }
+
+ StringBuilder buffer = new StringBuilder(item.getTitle());
+ if (showFeedtitle) {
+ buffer.append("(");
+ buffer.append(item.getFeed().getTitle());
+ buffer.append(")");
+ }
+ holder.title.setText(buffer.toString());
+
+ FeedItem.State state = item.getState();
+ switch (state) {
+ case PLAYING:
+ holder.statusUnread.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.VISIBLE);
+ break;
+ case IN_PROGRESS:
+ holder.statusUnread.setVisibility(View.GONE);
+ holder.episodeProgress.setVisibility(View.VISIBLE);
+ break;
+ case NEW:
+ holder.statusUnread.setVisibility(View.VISIBLE);
+ break;
+ default:
+ holder.statusUnread.setVisibility(View.GONE);
+ break;
+ }
+
+ holder.published.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE));
+
+
+ FeedMedia media = item.getMedia();
+ if (media == null) {
+ holder.episodeProgress.setVisibility(View.GONE);
+ holder.inPlaylist.setVisibility(View.INVISIBLE);
+ holder.type.setVisibility(View.INVISIBLE);
+ holder.lenSize.setVisibility(View.INVISIBLE);
+ } else {
+
+ AdapterUtils.updateEpisodePlaybackProgress(item, context.getResources(), holder.lenSize, holder.episodeProgress);
+
+ if (((ItemAccess) itemAccess).isInQueue(item)) {
+ holder.inPlaylist.setVisibility(View.VISIBLE);
+ } else {
+ holder.inPlaylist.setVisibility(View.INVISIBLE);
+ }
+
+ if (DownloadRequester.getInstance().isDownloadingFile(
+ item.getMedia())) {
+ holder.episodeProgress.setVisibility(View.VISIBLE);
+ holder.episodeProgress.setProgress(((ItemAccess) itemAccess).getItemDownloadProgressPercent(item));
+ }
+
+ TypedArray typeDrawables = context.obtainStyledAttributes(
+ new int[]{R.attr.type_audio, R.attr.type_video});
+ final int[] labels = new int[]{R.string.media_type_audio_label, R.string.media_type_video_label};
+
+ MediaType mediaType = item.getMedia().getMediaType();
+ if (mediaType == MediaType.AUDIO) {
+ holder.type.setImageDrawable(typeDrawables.getDrawable(0));
+ holder.type.setContentDescription(context.getString(labels[0]));
+ holder.type.setVisibility(View.VISIBLE);
+ } else if (mediaType == MediaType.VIDEO) {
+ holder.type.setImageDrawable(typeDrawables.getDrawable(1));
+ holder.type.setContentDescription(context.getString(labels[1]));
+ holder.type.setVisibility(View.VISIBLE);
+ } else {
+ holder.type.setImageBitmap(null);
+ holder.type.setVisibility(View.GONE);
+ }
+ }
+
+ actionButtonUtils.configureActionButton(holder.butAction, item);
+ holder.butAction.setFocusable(false);
+ holder.butAction.setTag(item);
+ holder.butAction.setOnClickListener(butActionListener);
+
+ } else {
+ convertView.setVisibility(View.GONE);
+ }
+ return convertView;
+
+ }
+
+ private final OnClickListener butActionListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FeedItem item = (FeedItem) v.getTag();
+ callback.onActionButtonPressed(item);
+ }
+ };
+
+ static class Holder {
+ TextView title;
+ TextView published;
+ TextView lenSize;
+ ImageView type;
+ ImageView inPlaylist;
+ ImageButton butAction;
+ View statusUnread;
+ ProgressBar episodeProgress;
+ }
+
+ public int getSelectedItemIndex() {
+ return selectedItemIndex;
+ }
+
+ public void setSelectedItemIndex(int selectedItemIndex) {
+ this.selectedItemIndex = selectedItemIndex;
+ notifyDataSetChanged();
+ }
+
+ public static interface ItemAccess {
+ public boolean isInQueue(FeedItem item);
+
+ int getItemDownloadProgressPercent(FeedItem item);
+
+ int getCount();
+
+ FeedItem getItem(int position);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java
new file mode 100644
index 000000000..c2c2285ac
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+
+import java.util.List;
+
+/**
+ * List adapter for showing a list of FeedItems with their title and description.
+ */
+public class FeedItemlistDescriptionAdapter extends ArrayAdapter<FeedItem> {
+
+ public FeedItemlistDescriptionAdapter(Context context, int resource, List<FeedItem> objects) {
+ super(context, resource, objects);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+
+ FeedItem item = getItem(position);
+
+ // Inflate layout
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.itemdescription_listitem, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.description = (TextView) convertView.findViewById(R.id.txtvDescription);
+
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(item.getTitle());
+ if (item.getDescription() != null) {
+ holder.description.setText(item.getDescription());
+ }
+
+ return convertView;
+ }
+
+ static class Holder {
+ TextView title;
+ TextView description;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java
new file mode 100644
index 000000000..ef8e8ce07
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java
@@ -0,0 +1,229 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.Feed;
+
+/**
+ * BaseAdapter for the navigation drawer
+ */
+public class NavListAdapter extends BaseAdapter {
+ public static final int VIEW_TYPE_COUNT = 3;
+ public static final int VIEW_TYPE_NAV = 0;
+ public static final int VIEW_TYPE_SECTION_DIVIDER = 1;
+ public static final int VIEW_TYPE_SUBSCRIPTION = 2;
+
+ public static final int[] NAV_TITLES = {R.string.all_episodes_label, R.string.queue_label, R.string.downloads_label, R.string.playback_history_label, R.string.add_feed_label};
+
+ private final Drawable[] drawables;
+
+ public static final int SUBSCRIPTION_OFFSET = 1 + NAV_TITLES.length;
+
+ private ItemAccess itemAccess;
+ private Context context;
+
+ public NavListAdapter(ItemAccess itemAccess, Context context) {
+ this.itemAccess = itemAccess;
+ this.context = context;
+
+ TypedArray ta = context.obtainStyledAttributes(new int[]{R.attr.ic_new, R.attr.stat_playlist,
+ R.attr.av_download, R.attr.device_access_time, R.attr.content_new});
+ drawables = new Drawable[]{ta.getDrawable(0), ta.getDrawable(1), ta.getDrawable(2),
+ ta.getDrawable(3), ta.getDrawable(4)};
+ ta.recycle();
+ }
+
+ @Override
+ public int getCount() {
+ return NAV_TITLES.length + 1 + itemAccess.getCount();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ int viewType = getItemViewType(position);
+ if (viewType == VIEW_TYPE_NAV) {
+ return context.getString(NAV_TITLES[position]);
+ } else if (viewType == VIEW_TYPE_SECTION_DIVIDER) {
+ return context.getString(R.string.podcasts_label);
+ } else {
+ return itemAccess.getItem(position);
+ }
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if (0 <= position && position < NAV_TITLES.length) {
+ return VIEW_TYPE_NAV;
+ } else if (position < NAV_TITLES.length + 1) {
+ return VIEW_TYPE_SECTION_DIVIDER;
+ } else {
+ return VIEW_TYPE_SUBSCRIPTION;
+ }
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return VIEW_TYPE_COUNT;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ int viewType = getItemViewType(position);
+ View v = null;
+ if (viewType == VIEW_TYPE_NAV) {
+ v = getNavView((String) getItem(position), position, convertView, parent);
+ } else if (viewType == VIEW_TYPE_SECTION_DIVIDER) {
+ v = getSectionDividerView((String) getItem(position), position, convertView, parent);
+ } else {
+ v = getFeedView(position - SUBSCRIPTION_OFFSET, convertView, parent);
+ }
+ if (v != null) {
+ TextView txtvTitle = (TextView) v.findViewById(R.id.txtvTitle);
+ if (position == itemAccess.getSelectedItemIndex()) {
+ txtvTitle.setTypeface(null, Typeface.BOLD);
+ } else {
+ txtvTitle.setTypeface(null, Typeface.NORMAL);
+ }
+ }
+ return v;
+ }
+
+ private View getNavView(String title, int position, View convertView, ViewGroup parent) {
+ NavHolder holder;
+ if (convertView == null) {
+ holder = new NavHolder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.nav_listitem, parent, false);
+
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.count = (TextView) convertView.findViewById(R.id.txtvCount);
+ holder.image = (ImageView) convertView.findViewById(R.id.imgvCover);
+ convertView.setTag(holder);
+ } else {
+ holder = (NavHolder) convertView.getTag();
+ }
+
+ holder.title.setText(title);
+
+ if (NAV_TITLES[position] == R.string.queue_label) {
+ int queueSize = itemAccess.getQueueSize();
+ if (queueSize > 0) {
+ holder.count.setVisibility(View.VISIBLE);
+ holder.count.setText(String.valueOf(queueSize));
+ } else {
+ holder.count.setVisibility(View.GONE);
+ }
+ } else if (NAV_TITLES[position] == R.string.all_episodes_label) {
+ int unreadItems = itemAccess.getNumberOfUnreadItems();
+ if (unreadItems > 0) {
+ holder.count.setVisibility(View.VISIBLE);
+ holder.count.setText(String.valueOf(unreadItems));
+ } else {
+ holder.count.setVisibility(View.GONE);
+ }
+ } else {
+ holder.count.setVisibility(View.GONE);
+ }
+
+ holder.image.setImageDrawable(drawables[position]);
+
+ return convertView;
+ }
+
+ private View getSectionDividerView(String title, int position, View convertView, ViewGroup parent) {
+ SectionHolder holder;
+ if (convertView == null) {
+ holder = new SectionHolder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.nav_section_item, parent, false);
+
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ convertView.setTag(holder);
+ } else {
+ holder = (SectionHolder) convertView.getTag();
+ }
+
+ holder.title.setText(title);
+
+ convertView.setEnabled(false);
+ convertView.setOnClickListener(null);
+
+ return convertView;
+ }
+
+ private View getFeedView(int feedPos, View convertView, ViewGroup parent) {
+ FeedHolder holder;
+ Feed feed = itemAccess.getItem(feedPos);
+
+ if (convertView == null) {
+ holder = new FeedHolder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.nav_feedlistitem, parent, false);
+
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.image = (ImageView) convertView.findViewById(R.id.imgvCover);
+ convertView.setTag(holder);
+ } else {
+ holder = (FeedHolder) convertView.getTag();
+ }
+
+ holder.title.setText(feed.getTitle());
+
+ PicassoProvider.getDefaultPicassoInstance(context)
+ .load(feed.getImageUri())
+ .fit()
+ .into(holder.image);
+
+ return convertView;
+ }
+
+ static class NavHolder {
+ TextView title;
+ TextView count;
+ ImageView image;
+ }
+
+ static class SectionHolder {
+ TextView title;
+ }
+
+ static class FeedHolder {
+ TextView title;
+ ImageView image;
+ }
+
+
+ public interface ItemAccess {
+ public int getCount();
+
+ public Feed getItem(int position);
+
+ public int getSelectedItemIndex();
+
+ public int getQueueSize();
+
+ public int getNumberOfUnreadItems();
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java
new file mode 100644
index 000000000..8abe49133
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/NewEpisodesListAdapter.java
@@ -0,0 +1,170 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.Converter;
+
+/**
+ * List adapter for the list of new episodes
+ */
+public class NewEpisodesListAdapter extends BaseAdapter {
+
+ private final Context context;
+ private final ItemAccess itemAccess;
+ private final ActionButtonCallback actionButtonCallback;
+ private final ActionButtonUtils actionButtonUtils;
+
+ public NewEpisodesListAdapter(Context context, ItemAccess itemAccess, ActionButtonCallback actionButtonCallback) {
+ super();
+ this.context = context;
+ this.itemAccess = itemAccess;
+ this.actionButtonUtils = new ActionButtonUtils(context);
+ this.actionButtonCallback = actionButtonCallback;
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ final FeedItem item = (FeedItem) getItem(position);
+ if (item == null) return null;
+
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.new_episodes_listitem,
+ parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.pubDate = (TextView) convertView
+ .findViewById(R.id.txtvPublished);
+ holder.statusUnread = convertView.findViewById(R.id.statusUnread);
+ holder.butSecondary = (ImageButton) convertView
+ .findViewById(R.id.butSecondaryAction);
+ holder.queueStatus = (ImageView) convertView
+ .findViewById(R.id.imgvInPlaylist);
+ holder.downloadProgress = (ProgressBar) convertView
+ .findViewById(R.id.pbar_download_progress);
+ holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage);
+ holder.txtvDuration = (TextView) convertView.findViewById(R.id.txtvDuration);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(item.getTitle());
+ holder.pubDate.setText(DateUtils.formatDateTime(context, item.getPubDate().getTime(), DateUtils.FORMAT_SHOW_DATE));
+ if (item.isRead()) {
+ holder.statusUnread.setVisibility(View.GONE);
+ } else {
+ holder.statusUnread.setVisibility(View.VISIBLE);
+ }
+
+ FeedMedia media = item.getMedia();
+ if (media != null) {
+ final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media);
+
+ if (media.getDuration() > 0) {
+ holder.txtvDuration.setText(Converter.getDurationStringLong(media.getDuration()));
+ } else {
+ holder.txtvDuration.setText("");
+ }
+
+ if (isDownloadingMedia) {
+ holder.downloadProgress.setVisibility(View.VISIBLE);
+ holder.txtvDuration.setVisibility(View.GONE);
+ } else {
+ holder.txtvDuration.setVisibility(View.VISIBLE);
+ holder.downloadProgress.setVisibility(View.GONE);
+ }
+
+ if (!media.isDownloaded()) {
+ if (isDownloadingMedia) {
+ // item is being downloaded
+ holder.downloadProgress.setProgress(itemAccess.getItemDownloadProgressPercent(item));
+ }
+ }
+ }
+ if (itemAccess.isInQueue(item)) {
+ holder.queueStatus.setVisibility(View.VISIBLE);
+ } else {
+ holder.queueStatus.setVisibility(View.INVISIBLE);
+ }
+
+ actionButtonUtils.configureActionButton(holder.butSecondary, item);
+ holder.butSecondary.setFocusable(false);
+ holder.butSecondary.setTag(item);
+ holder.butSecondary.setOnClickListener(secondaryActionListener);
+
+ PicassoProvider.getMediaMetadataPicassoInstance(context)
+ .load(item.getImageUri())
+ .fit()
+ .into(holder.imageView);
+
+ return convertView;
+ }
+
+ private View.OnClickListener secondaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FeedItem item = (FeedItem) v.getTag();
+ actionButtonCallback.onActionButtonPressed(item);
+ }
+ };
+
+
+ static class Holder {
+ TextView title;
+ TextView pubDate;
+ View statusUnread;
+ ImageView queueStatus;
+ ImageView imageView;
+ ProgressBar downloadProgress;
+ TextView txtvDuration;
+ ImageButton butSecondary;
+ }
+
+ public interface ItemAccess {
+
+ int getCount();
+
+ FeedItem getItem(int position);
+
+ int getItemDownloadProgressPercent(FeedItem item);
+
+ boolean isInQueue(FeedItem item);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java
new file mode 100644
index 000000000..ebe519592
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/QueueListAdapter.java
@@ -0,0 +1,127 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.*;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+/**
+ * List adapter for the queue.
+ */
+public class QueueListAdapter extends BaseAdapter {
+
+
+ private final Context context;
+ private final ItemAccess itemAccess;
+ private final ActionButtonCallback actionButtonCallback;
+ private final ActionButtonUtils actionButtonUtils;
+
+
+ public QueueListAdapter(Context context, ItemAccess itemAccess, ActionButtonCallback actionButtonCallback) {
+ super();
+ this.context = context;
+ this.itemAccess = itemAccess;
+ this.actionButtonUtils = new ActionButtonUtils(context);
+ this.actionButtonCallback = actionButtonCallback;
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+ final FeedItem item = (FeedItem) getItem(position);
+ if (item == null) return null;
+
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ convertView = inflater.inflate(R.layout.queue_listitem,
+ parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.butSecondary = (ImageButton) convertView
+ .findViewById(R.id.butSecondaryAction);
+ holder.position = (TextView) convertView.findViewById(R.id.txtvPosition);
+ holder.progress = (ProgressBar) convertView
+ .findViewById(R.id.pbar_download_progress);
+ holder.imageView = (ImageView) convertView.findViewById(R.id.imgvImage);
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(item.getTitle());
+
+ AdapterUtils.updateEpisodePlaybackProgress(item, context.getResources(), holder.position, holder.progress);
+
+ FeedMedia media = item.getMedia();
+ if (media != null) {
+ final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media);
+
+ if (!media.isDownloaded()) {
+ if (isDownloadingMedia) {
+ // item is being downloaded
+ holder.progress.setVisibility(View.VISIBLE);
+ holder.progress.setProgress(itemAccess.getItemDownloadProgressPercent(item));
+ }
+ }
+ }
+
+ actionButtonUtils.configureActionButton(holder.butSecondary, item);
+ holder.butSecondary.setFocusable(false);
+ holder.butSecondary.setTag(item);
+ holder.butSecondary.setOnClickListener(secondaryActionListener);
+
+ PicassoProvider.getMediaMetadataPicassoInstance(context)
+ .load(item.getImageUri())
+ .fit()
+ .into(holder.imageView);
+
+ return convertView;
+ }
+
+ private View.OnClickListener secondaryActionListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ FeedItem item = (FeedItem) v.getTag();
+ actionButtonCallback.onActionButtonPressed(item);
+ }
+ };
+
+
+ static class Holder {
+ TextView title;
+ ImageView imageView;
+ TextView position;
+ ProgressBar progress;
+ ImageButton butSecondary;
+ }
+
+ public interface ItemAccess {
+ int getCount();
+
+ FeedItem getItem(int position);
+
+ int getItemDownloadProgressPercent(FeedItem item);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java
new file mode 100644
index 000000000..2314c2269
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/SearchlistAdapter.java
@@ -0,0 +1,110 @@
+package de.danoeh.antennapod.adapter;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedComponent;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.SearchResult;
+
+/**
+ * List adapter for search activity.
+ */
+public class SearchlistAdapter extends BaseAdapter {
+
+ private final Context context;
+ private final ItemAccess itemAccess;
+
+
+ public SearchlistAdapter(Context context, ItemAccess itemAccess) {
+ this.context = context;
+ this.itemAccess = itemAccess;
+ }
+
+ @Override
+ public int getCount() {
+ return itemAccess.getCount();
+ }
+
+ @Override
+ public SearchResult getItem(int position) {
+ return itemAccess.getItem(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return 0;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ final Holder holder;
+ SearchResult result = getItem(position);
+ FeedComponent component = result.getComponent();
+
+ // Inflate Layout
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.searchlist_item, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.cover = (ImageView) convertView
+ .findViewById(R.id.imgvFeedimage);
+ holder.subtitle = (TextView) convertView
+ .findViewById(R.id.txtvSubtitle);
+
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+ if (component.getClass() == Feed.class) {
+ final Feed feed = (Feed) component;
+ holder.title.setText(feed.getTitle());
+ holder.subtitle.setVisibility(View.GONE);
+
+ PicassoProvider.getDefaultPicassoInstance(context)
+ .load(feed.getImageUri())
+ .fit()
+ .into(holder.cover);
+
+ } else if (component.getClass() == FeedItem.class) {
+ final FeedItem item = (FeedItem) component;
+ holder.title.setText(item.getTitle());
+ if (result.getSubtitle() != null) {
+ holder.subtitle.setVisibility(View.VISIBLE);
+ holder.subtitle.setText(result.getSubtitle());
+ }
+
+ PicassoProvider.getDefaultPicassoInstance(context)
+ .load(item.getFeed().getImageUri())
+ .fit()
+ .into(holder.cover);
+
+ }
+
+ return convertView;
+ }
+
+ static class Holder {
+ ImageView cover;
+ TextView title;
+ TextView subtitle;
+ }
+
+ public static interface ItemAccess {
+ int getCount();
+
+ SearchResult getItem(int position);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java
new file mode 100644
index 000000000..f2e78a57e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/gpodnet/PodcastListAdapter.java
@@ -0,0 +1,64 @@
+package de.danoeh.antennapod.adapter.gpodnet;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.List;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+
+/**
+ * Adapter for displaying a list of GPodnetPodcast-Objects.
+ */
+public class PodcastListAdapter extends ArrayAdapter<GpodnetPodcast> {
+
+ public PodcastListAdapter(Context context, int resource, List<GpodnetPodcast> objects) {
+ super(context, resource, objects);
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ Holder holder;
+
+ GpodnetPodcast podcast = getItem(position);
+
+ // Inflate Layout
+ if (convertView == null) {
+ holder = new Holder();
+ LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+
+ convertView = inflater.inflate(R.layout.gpodnet_podcast_listitem, parent, false);
+ holder.title = (TextView) convertView.findViewById(R.id.txtvTitle);
+ holder.description = (TextView) convertView.findViewById(R.id.txtvDescription);
+ holder.image = (ImageView) convertView.findViewById(R.id.imgvCover);
+
+ convertView.setTag(holder);
+ } else {
+ holder = (Holder) convertView.getTag();
+ }
+
+ holder.title.setText(podcast.getTitle());
+ holder.description.setText(podcast.getDescription());
+
+ PicassoProvider.getDefaultPicassoInstance(convertView.getContext())
+ .load(podcast.getLogoUrl())
+ .fit()
+ .into(holder.image);
+
+ return convertView;
+ }
+
+ static class Holder {
+ TextView title;
+ TextView description;
+ ImageView image;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java b/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java
new file mode 100644
index 000000000..21ae5291e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/DownloadObserver.java
@@ -0,0 +1,177 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.app.Activity;
+import android.content.*;
+import android.os.Handler;
+import android.os.IBinder;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.service.download.DownloadService;
+import de.danoeh.antennapod.service.download.Downloader;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Provides access to the DownloadService's list of items that are currently being downloaded.
+ * The DownloadObserver object should be created in the activity's onCreate() method. resume() and pause()
+ * should be called in the activity's onResume() and onPause() methods
+ */
+public class DownloadObserver {
+ private static final String TAG = "DownloadObserver";
+
+ /**
+ * Time period between update notifications.
+ */
+ public static final int WAITING_INTERVAL_MS = 3000;
+
+ private volatile Activity activity;
+ private final Handler handler;
+ private final Callback callback;
+
+ private DownloadService downloadService = null;
+ private AtomicBoolean mIsBound = new AtomicBoolean(false);
+
+ private Thread refresherThread;
+ private AtomicBoolean refresherThreadRunning = new AtomicBoolean(false);
+
+
+ /**
+ * Creates a new download observer.
+ *
+ * @param activity Used for registering receivers
+ * @param handler All callback methods are executed on this handler. The handler MUST run on the GUI thread.
+ * @param callback Callback methods for posting content updates
+ * @throws java.lang.IllegalArgumentException if one of the arguments is null.
+ */
+ public DownloadObserver(Activity activity, Handler handler, Callback callback) {
+ Validate.notNull(activity);
+ Validate.notNull(handler);
+ Validate.notNull(callback);
+
+ this.activity = activity;
+ this.handler = handler;
+ this.callback = callback;
+ }
+
+ public void onResume() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "DownloadObserver resumed");
+ activity.registerReceiver(contentChangedReceiver, new IntentFilter(DownloadService.ACTION_DOWNLOADS_CONTENT_CHANGED));
+ connectToDownloadService();
+ }
+
+ public void onPause() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "DownloadObserver paused");
+ try {
+ activity.unregisterReceiver(contentChangedReceiver);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ }
+ try {
+ activity.unbindService(mConnection);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ }
+ stopRefresher();
+ }
+
+ private BroadcastReceiver contentChangedReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // reconnect to DownloadService if connection has been closed
+ if (downloadService == null) {
+ connectToDownloadService();
+ }
+ callback.onContentChanged();
+ startRefresher();
+ }
+ };
+
+ public interface Callback {
+ void onContentChanged();
+
+ void onDownloadDataAvailable(List<Downloader> downloaderList);
+ }
+
+ private void connectToDownloadService() {
+ activity.bindService(new Intent(activity, DownloadService.class), mConnection, 0);
+ }
+
+ private ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceDisconnected(ComponentName className) {
+ downloadService = null;
+ mIsBound.set(false);
+ stopRefresher();
+ Log.i(TAG, "Closed connection with DownloadService.");
+ }
+
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ downloadService = ((DownloadService.LocalBinder) service)
+ .getService();
+ mIsBound.set(true);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Connection to service established");
+ List<Downloader> downloaderList = downloadService.getDownloads();
+ if (downloaderList != null && !downloaderList.isEmpty()) {
+ callback.onDownloadDataAvailable(downloaderList);
+ startRefresher();
+ }
+ }
+ };
+
+ private void stopRefresher() {
+ if (refresherThread != null) {
+ refresherThread.interrupt();
+ }
+ }
+
+ private void startRefresher() {
+ if (refresherThread == null || refresherThread.isInterrupted()) {
+ refresherThread = new Thread(new RefresherThread());
+ refresherThread.start();
+ }
+ }
+
+ private class RefresherThread implements Runnable {
+
+ public void run() {
+ refresherThreadRunning.set(true);
+ while (!Thread.interrupted()) {
+ try {
+ Thread.sleep(WAITING_INTERVAL_MS);
+ } catch (InterruptedException e) {
+ Log.d(TAG, "Refresher thread was interrupted");
+ }
+ if (mIsBound.get()) {
+ postUpdate();
+ }
+ }
+ refresherThreadRunning.set(false);
+ }
+
+ private void postUpdate() {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ callback.onContentChanged();
+ if (downloadService != null) {
+ List<Downloader> downloaderList = downloadService.getDownloads();
+ if (downloaderList == null || downloaderList.isEmpty()) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ });
+ }
+ }
+
+ public void setActivity(Activity activity) {
+ Validate.notNull(activity);
+ this.activity = activity;
+ }
+
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java
new file mode 100644
index 000000000..0549a4255
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FeedRemover.java
@@ -0,0 +1,74 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.annotation.SuppressLint;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.os.AsyncTask;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.storage.DBWriter;
+
+import java.util.concurrent.ExecutionException;
+
+/** Removes a feed in the background. */
+public class FeedRemover extends AsyncTask<Void, Void, Void> {
+ Context context;
+ ProgressDialog dialog;
+ Feed feed;
+
+ public FeedRemover(Context context, Feed feed) {
+ super();
+ this.context = context;
+ this.feed = feed;
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ try {
+ DBWriter.deleteFeed(context, feed.getId()).get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ @Override
+ protected void onCancelled() {
+ dialog.dismiss();
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ dialog.dismiss();
+ }
+
+ @Override
+ protected void onPreExecute() {
+ dialog = new ProgressDialog(context);
+ dialog.setMessage(context.getString(R.string.feed_remover_msg));
+ dialog.setOnCancelListener(new OnCancelListener() {
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ cancel(true);
+
+ }
+
+ });
+ dialog.show();
+ }
+
+ @SuppressLint("NewApi")
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java
new file mode 100644
index 000000000..9210ac1d1
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrClickWorker.java
@@ -0,0 +1,238 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.annotation.TargetApi;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.Validate;
+import org.shredzone.flattr4j.exception.FlattrException;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.FlattrAuthActivity;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.NetworkUtils;
+import de.danoeh.antennapod.util.flattr.FlattrThing;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+
+/**
+ * Performs a click action in a background thread.
+ * <p/>
+ * When started, the flattr click worker will try to flattr every item that is in the flattr queue. If no network
+ * connection is available it will shut down immediately. The FlattrClickWorker can also be given one additional
+ * FlattrThing which will be flattrd immediately.
+ * <p/>
+ * The FlattrClickWorker will display a toast notification for every item that has been flattrd. If the FlattrClickWorker failed
+ * to flattr something, a notification will be displayed.
+ */
+public class FlattrClickWorker extends AsyncTask<Void, Integer, FlattrClickWorker.ExitCode> {
+ protected static final String TAG = "FlattrClickWorker";
+
+ private static final int NOTIFICATION_ID = 4;
+
+ private final Context context;
+
+ public static enum ExitCode {EXIT_NORMAL, NO_TOKEN, NO_NETWORK, NO_THINGS}
+
+ private volatile int countFailed = 0;
+ private volatile int countSuccess = 0;
+
+ private volatile FlattrThing extraFlattrThing;
+
+ /**
+ * Only relevant if just one thing is flattrd
+ */
+ private volatile FlattrException exception;
+
+ /**
+ * Creates a new FlattrClickWorker which will only flattr all things in the queue.
+ * <p/>
+ * The FlattrClickWorker has to be started by calling executeAsync().
+ *
+ * @param context A context for accessing the database and posting notifications. Must not be null.
+ */
+ public FlattrClickWorker(Context context) {
+ Validate.notNull(context);
+ this.context = context.getApplicationContext();
+ }
+
+ /**
+ * Creates a new FlattrClickWorker which will flattr all things in the queue and one additional
+ * FlattrThing.
+ * <p/>
+ * The FlattrClickWorker has to be started by calling executeAsync().
+ *
+ * @param context A context for accessing the database and posting notifications. Must not be null.
+ * @param extraFlattrThing The additional thing to flattr
+ */
+ public FlattrClickWorker(Context context, FlattrThing extraFlattrThing) {
+ this(context);
+ this.extraFlattrThing = extraFlattrThing;
+ }
+
+
+ @Override
+ protected ExitCode doInBackground(Void... params) {
+
+ if (!FlattrUtils.hasToken()) {
+ return ExitCode.NO_TOKEN;
+ }
+
+ if (!NetworkUtils.networkAvailable(context)) {
+ return ExitCode.NO_NETWORK;
+ }
+
+ final List<FlattrThing> flattrQueue = DBReader.getFlattrQueue(context);
+ if (extraFlattrThing != null) {
+ flattrQueue.add(extraFlattrThing);
+ } else if (flattrQueue.size() == 1) {
+ // if only one item is flattrd, the report can specifically mentioned that this item has failed
+ extraFlattrThing = flattrQueue.get(0);
+ }
+
+ if (flattrQueue.isEmpty()) {
+ return ExitCode.NO_THINGS;
+ }
+
+ List<Future> dbFutures = new LinkedList<Future>();
+ for (FlattrThing thing : flattrQueue) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Processing " + thing.getTitle());
+
+ try {
+ thing.getFlattrStatus().setUnflattred(); // pop from queue to prevent unflattrable things from getting stuck in flattr queue infinitely
+ FlattrUtils.clickUrl(context, thing.getPaymentLink());
+ thing.getFlattrStatus().setFlattred();
+ publishProgress(R.string.flattr_click_success);
+ countSuccess++;
+
+ } catch (FlattrException e) {
+ e.printStackTrace();
+ countFailed++;
+ if (countFailed == 1) {
+ exception = e;
+ }
+ }
+
+ Future<?> f = DBWriter.setFlattredStatus(context, thing, false);
+ if (f != null) {
+ dbFutures.add(f);
+ }
+ }
+
+ for (Future f : dbFutures) {
+ try {
+ f.get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ return ExitCode.EXIT_NORMAL;
+ }
+
+ @Override
+ protected void onPostExecute(ExitCode exitCode) {
+ super.onPostExecute(exitCode);
+ switch (exitCode) {
+ case EXIT_NORMAL:
+ if (countFailed > 0) {
+ postFlattrFailedNotification();
+ }
+ break;
+ case NO_NETWORK:
+ postToastNotification(R.string.flattr_click_enqueued);
+ break;
+ case NO_TOKEN:
+ postNoTokenNotification();
+ break;
+ case NO_THINGS: // nothing to notify here
+ break;
+ }
+ }
+
+ @Override
+ protected void onProgressUpdate(Integer... values) {
+ super.onProgressUpdate(values);
+ postToastNotification(values[0]);
+ }
+
+ private void postToastNotification(int msg) {
+ Toast.makeText(context, context.getString(msg), Toast.LENGTH_LONG).show();
+ }
+
+ private void postNoTokenNotification() {
+ PendingIntent contentIntent = PendingIntent.getActivity(context, 0, new Intent(context, FlattrAuthActivity.class), 0);
+
+ Notification notification = new NotificationCompat.Builder(context)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(R.string.no_flattr_token_notification_msg)))
+ .setContentIntent(contentIntent)
+ .setContentTitle(context.getString(R.string.no_flattr_token_title))
+ .setTicker(context.getString(R.string.no_flattr_token_title))
+ .setSmallIcon(R.drawable.stat_notify_sync_error)
+ .setOngoing(false)
+ .setAutoCancel(true)
+ .build();
+ ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, notification);
+ }
+
+ private void postFlattrFailedNotification() {
+ if (countFailed == 0) {
+ return;
+ }
+
+ PendingIntent contentIntent = PendingIntent.getActivity(context, 0, new Intent(context, MainActivity.class), 0);
+ String title;
+ String subtext;
+
+ if (countFailed == 1) {
+ title = context.getString(R.string.flattrd_failed_label);
+ String exceptionMsg = (exception.getMessage() != null) ? exception.getMessage() : "";
+ subtext = context.getString(R.string.flattr_click_failure, extraFlattrThing.getTitle())
+ + "\n" + exceptionMsg;
+ } else {
+ title = context.getString(R.string.flattrd_label);
+ subtext = context.getString(R.string.flattr_click_success_count, countSuccess) + "\n"
+ + context.getString(R.string.flattr_click_failure_count, countFailed);
+ }
+
+ Notification notification = new NotificationCompat.Builder(context)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(subtext))
+ .setContentIntent(contentIntent)
+ .setContentTitle(title)
+ .setTicker(title)
+ .setSmallIcon(R.drawable.stat_notify_sync_error)
+ .setOngoing(false)
+ .setAutoCancel(true)
+ .build();
+ ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, notification);
+ }
+
+
+ /**
+ * Starts the FlattrClickWorker as an AsyncTask.
+ */
+ @TargetApi(11)
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java
new file mode 100644
index 000000000..04d349671
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrStatusFetcher.java
@@ -0,0 +1,47 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.content.Context;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+import org.shredzone.flattr4j.exception.FlattrException;
+import org.shredzone.flattr4j.model.Flattr;
+
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Fetch list of flattred things and flattr status in database in a background thread.
+ */
+
+public class FlattrStatusFetcher extends Thread {
+ protected static final String TAG = "FlattrStatusFetcher";
+ protected Context context;
+
+ public FlattrStatusFetcher(Context context) {
+ super();
+ this.context = context;
+ }
+
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Starting background work: Retrieving Flattr status");
+
+ Thread.currentThread().setPriority(Thread.MIN_PRIORITY);
+
+ try {
+ List<Flattr> flattredThings = FlattrUtils.retrieveFlattredThings();
+ DBWriter.setFlattredStatus(context, flattredThings).get();
+ } catch (FlattrException e) {
+ e.printStackTrace();
+ Log.d(TAG, "flattrQueue exception retrieving list with flattred items " + e.getMessage());
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Finished background work: Retrieved Flattr status");
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java
new file mode 100644
index 000000000..0dcf832f7
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/FlattrTokenFetcher.java
@@ -0,0 +1,95 @@
+package de.danoeh.antennapod.asynctask;
+
+
+import android.annotation.SuppressLint;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.FlattrAuthActivity;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+import org.shredzone.flattr4j.exception.FlattrException;
+import org.shredzone.flattr4j.oauth.AccessToken;
+import org.shredzone.flattr4j.oauth.AndroidAuthenticator;
+
+/** Fetches the access token in the background in order to avoid networkOnMainThread exception. */
+
+public class FlattrTokenFetcher extends AsyncTask<Void, Void, AccessToken> {
+ private static final String TAG = "FlattrTokenFetcher";
+ Context context;
+ AndroidAuthenticator auth;
+ AccessToken token;
+ Uri uri;
+ ProgressDialog dialog;
+ FlattrException exception;
+
+ public FlattrTokenFetcher(Context context, AndroidAuthenticator auth, Uri uri) {
+ super();
+ this.context = context;
+ this.auth = auth;
+ this.uri = uri;
+ }
+
+ @Override
+ protected void onPostExecute(AccessToken result) {
+ if (result != null) {
+ FlattrUtils.storeToken(result);
+ }
+ dialog.dismiss();
+ if (exception == null) {
+ FlattrAuthActivity instance = FlattrAuthActivity.getInstance();
+ if (instance != null) {
+ instance.handleAuthenticationSuccess();
+ } else {
+ Log.e(TAG, "FlattrAuthActivity instance was null");
+ }
+ } else {
+ FlattrUtils.showErrorDialog(context, exception.getMessage());
+ }
+ }
+
+
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ dialog = new ProgressDialog(context);
+ dialog.setMessage(context.getString(R.string.processing_label));
+ dialog.setIndeterminate(true);
+ dialog.setCancelable(false);
+ dialog.show();
+ }
+
+
+
+ @Override
+ protected AccessToken doInBackground(Void... params) {
+ try {
+ token = auth.fetchAccessToken(uri);
+ } catch (FlattrException e) {
+ e.printStackTrace();
+ exception = e;
+ return null;
+ }
+ if (token != null) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Successfully got token");
+ return token;
+ } else {
+ Log.w(TAG, "Flattr token was null");
+ return null;
+ }
+ }
+
+ @SuppressLint("NewApi")
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java
new file mode 100644
index 000000000..4abb1a67d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlExportWorker.java
@@ -0,0 +1,114 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.util.Log;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.opml.OpmlWriter;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.LangUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+
+/** Writes an OPML file into the export directory in the background. */
+public class OpmlExportWorker extends AsyncTask<Void, Void, Void> {
+ private static final String TAG = "OpmlExportWorker";
+ private static final String DEFAULT_OUTPUT_NAME = "antennapod-feeds.opml";
+ private Context context;
+ private File output;
+
+ private ProgressDialog progDialog;
+ private Exception exception;
+
+ public OpmlExportWorker(Context context, File output) {
+ this.context = context;
+ this.output = output;
+ }
+
+ public OpmlExportWorker(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ OpmlWriter opmlWriter = new OpmlWriter();
+ if (output == null) {
+ output = new File(
+ UserPreferences.getDataFolder(context, PodcastApp.EXPORT_DIR),
+ DEFAULT_OUTPUT_NAME);
+ if (output.exists()) {
+ Log.w(TAG, "Overwriting previously exported file.");
+ output.delete();
+ }
+ }
+ OutputStreamWriter writer = null;
+ try {
+ writer = new OutputStreamWriter(new FileOutputStream(output), LangUtils.UTF_8);
+ opmlWriter.writeDocument(DBReader.getFeedList(context), writer);
+ } catch (IOException e) {
+ e.printStackTrace();
+ exception = e;
+ } finally {
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (IOException ioe) {
+ exception = ioe;
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ progDialog.dismiss();
+ AlertDialog.Builder alert = new AlertDialog.Builder(context)
+ .setNeutralButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ dialog.dismiss();
+ }
+ });
+ if (exception != null) {
+ alert.setTitle(R.string.export_error_label);
+ alert.setMessage(exception.getMessage());
+ } else {
+ alert.setTitle(R.string.opml_export_success_title);
+ alert.setMessage(context
+ .getString(R.string.opml_export_success_sum)
+ + output.toString());
+ }
+ alert.create().show();
+ }
+
+ @Override
+ protected void onPreExecute() {
+ progDialog = new ProgressDialog(context);
+ progDialog.setMessage(context.getString(R.string.exporting_label));
+ progDialog.setIndeterminate(true);
+ progDialog.show();
+ }
+
+ @SuppressLint("NewApi")
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java
new file mode 100644
index 000000000..038b8dcc5
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlFeedQueuer.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.annotation.SuppressLint;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.os.AsyncTask;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.OpmlImportHolder;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.opml.OpmlElement;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+import java.util.Arrays;
+import java.util.Date;
+
+/** Queues items for download in the background. */
+public class OpmlFeedQueuer extends AsyncTask<Void, Void, Void> {
+ private Context context;
+ private ProgressDialog progDialog;
+ private int[] selection;
+
+ public OpmlFeedQueuer(Context context, int[] selection) {
+ super();
+ this.context = context;
+ this.selection = Arrays.copyOf(selection, selection.length);
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ progDialog.dismiss();
+ }
+
+ @Override
+ protected void onPreExecute() {
+ progDialog = new ProgressDialog(context);
+ progDialog.setMessage(context.getString(R.string.processing_label));
+ progDialog.setCancelable(false);
+ progDialog.setIndeterminate(true);
+ progDialog.show();
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ DownloadRequester requester = DownloadRequester.getInstance();
+ for (int idx = 0; idx < selection.length; idx++) {
+ OpmlElement element = OpmlImportHolder.getReadElements().get(
+ selection[idx]);
+ Feed feed = new Feed(element.getXmlUrl(), new Date(),
+ element.getText());
+ try {
+ requester.downloadFeed(context.getApplicationContext(), feed);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
+ }
+ return null;
+ }
+
+ @SuppressLint("NewApi")
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java
new file mode 100644
index 000000000..13534fa64
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/OpmlImportWorker.java
@@ -0,0 +1,116 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.os.AsyncTask;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.opml.OpmlElement;
+import de.danoeh.antennapod.opml.OpmlReader;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+
+public class OpmlImportWorker extends
+ AsyncTask<Void, Void, ArrayList<OpmlElement>> {
+ private static final String TAG = "OpmlImportWorker";
+
+ private Context context;
+ private Exception exception;
+
+ private ProgressDialog progDialog;
+
+ private Reader mReader;
+
+ public OpmlImportWorker(Context context, Reader reader) {
+ super();
+ this.context = context;
+ this.mReader=reader;
+ }
+
+ @Override
+ protected ArrayList<OpmlElement> doInBackground(Void... params) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting background work");
+
+ if (mReader==null) {
+ return null;
+ }
+
+ OpmlReader opmlReader = new OpmlReader();
+ try {
+ ArrayList<OpmlElement> result = opmlReader.readDocument(mReader);
+ mReader.close();
+ return result;
+ } catch (XmlPullParserException e) {
+ e.printStackTrace();
+ exception = e;
+ return null;
+ } catch (IOException e) {
+ e.printStackTrace();
+ exception = e;
+ return null;
+ }
+
+ }
+
+ @Override
+ protected void onPostExecute(ArrayList<OpmlElement> result) {
+ if (mReader != null) {
+ try {
+ mReader.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ progDialog.dismiss();
+ if (exception != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "An error occured while trying to parse the opml document");
+ AlertDialog.Builder alert = new AlertDialog.Builder(context);
+ alert.setTitle(R.string.error_label);
+ alert.setMessage(context.getString(R.string.opml_reader_error)
+ + exception.getMessage());
+ alert.setNeutralButton(android.R.string.ok, new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+
+ });
+ alert.create().show();
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ progDialog = new ProgressDialog(context);
+ progDialog.setMessage(context.getString(R.string.reading_opml_label));
+ progDialog.setIndeterminate(true);
+ progDialog.setCancelable(false);
+ progDialog.show();
+ }
+
+ public boolean wasSuccessful() {
+ return exception != null;
+ }
+
+ @SuppressLint("NewApi")
+ public void executeAsync() {
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ executeOnExecutor(THREAD_POOL_EXECUTOR);
+ } else {
+ execute();
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java
new file mode 100644
index 000000000..26f9d9278
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoImageResource.java
@@ -0,0 +1,37 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.net.Uri;
+
+/**
+ * Classes that implement this interface provide access to an image resource that can
+ * be loaded by the Picasso library.
+ */
+public interface PicassoImageResource {
+
+ /**
+ * This scheme should be used by PicassoImageResources to
+ * indicate that the image Uri points to a file that is not an image
+ * (e.g. a media file). This workaround is needed so that the Picasso library
+ * loads these Uri with a Downloader instead of trying to load it directly.
+ * <p/>
+ * For example implementations, see FeedMedia or ExternalMedia.
+ */
+ public static final String SCHEME_MEDIA = "media";
+
+
+ /**
+ * Parameter key for an encoded fallback Uri. This Uri MUST point to a local image file
+ */
+ public static final String PARAM_FALLBACK = "fallback";
+
+ /**
+ * Returns a Uri to the image or null if no image is available.
+ * <p/>
+ * The Uri can either be an HTTP-URL, a URL pointing to a local image file or
+ * a non-image file (see SCHEME_MEDIA for more details).
+ * <p/>
+ * The Uri can also have an optional fallback-URL if loading the default URL
+ * failed (see PARAM_FALLBACK).
+ */
+ public Uri getImageUri();
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java
new file mode 100644
index 000000000..849725630
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/asynctask/PicassoProvider.java
@@ -0,0 +1,152 @@
+package de.danoeh.antennapod.asynctask;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import com.squareup.picasso.Cache;
+import com.squareup.picasso.Downloader;
+import com.squareup.picasso.LruCache;
+import com.squareup.picasso.OkHttpDownloader;
+import com.squareup.picasso.Picasso;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Provides access to Picasso instances.
+ */
+public class PicassoProvider {
+ private static final String TAG = "PicassoProvider";
+
+ private static final boolean DEBUG = false;
+
+ private static ExecutorService executorService;
+ private static Cache memoryCache;
+
+ private static Picasso defaultPicassoInstance;
+ private static Picasso mediaMetadataPicassoInstance;
+
+ private static synchronized ExecutorService getExecutorService() {
+ if (executorService == null) {
+ executorService = Executors.newFixedThreadPool(3);
+ }
+ return executorService;
+ }
+
+ private static synchronized Cache getMemoryCache(Context context) {
+ if (memoryCache == null) {
+ memoryCache = new LruCache(context);
+ }
+ return memoryCache;
+ }
+
+ /**
+ * Returns a Picasso instance that uses an OkHttpDownloader. This instance can only load images
+ * from image files.
+ * <p/>
+ * This instance should be used as long as no images from media files are loaded.
+ */
+ public static synchronized Picasso getDefaultPicassoInstance(Context context) {
+ Validate.notNull(context);
+ if (defaultPicassoInstance == null) {
+ defaultPicassoInstance = new Picasso.Builder(context)
+ .indicatorsEnabled(DEBUG)
+ .loggingEnabled(DEBUG)
+ .downloader(new OkHttpDownloader(context))
+ .executor(getExecutorService())
+ .memoryCache(getMemoryCache(context))
+ .listener(new Picasso.Listener() {
+ @Override
+ public void onImageLoadFailed(Picasso picasso, Uri uri, Exception e) {
+ Log.e(TAG, "Failed to load Uri:" + uri.toString());
+ e.printStackTrace();
+ }
+ })
+ .build();
+ }
+ return defaultPicassoInstance;
+ }
+
+ /**
+ * Returns a Picasso instance that uses a MediaMetadataRetriever if the given Uri is a media file
+ * and a default OkHttpDownloader otherwise.
+ */
+ public static synchronized Picasso getMediaMetadataPicassoInstance(Context context) {
+ Validate.notNull(context);
+ if (mediaMetadataPicassoInstance == null) {
+ mediaMetadataPicassoInstance = new Picasso.Builder(context)
+ .indicatorsEnabled(DEBUG)
+ .loggingEnabled(DEBUG)
+ .downloader(new MediaMetadataDownloader(context))
+ .executor(getExecutorService())
+ .memoryCache(getMemoryCache(context))
+ .listener(new Picasso.Listener() {
+ @Override
+ public void onImageLoadFailed(Picasso picasso, Uri uri, Exception e) {
+ Log.e(TAG, "Failed to load Uri:" + uri.toString());
+ e.printStackTrace();
+ }
+ })
+ .build();
+ }
+ return mediaMetadataPicassoInstance;
+ }
+
+ private static class MediaMetadataDownloader implements Downloader {
+
+ private static final String TAG = "MediaMetadataDownloader";
+
+ private final OkHttpDownloader okHttpDownloader;
+
+ public MediaMetadataDownloader(Context context) {
+ Validate.notNull(context);
+ okHttpDownloader = new OkHttpDownloader(context);
+ }
+
+ @Override
+ public Response load(Uri uri, boolean b) throws IOException {
+ if (StringUtils.equals(uri.getScheme(), PicassoImageResource.SCHEME_MEDIA)) {
+ String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(FilenameUtils.getExtension(uri.getLastPathSegment()));
+ if (StringUtils.startsWith(type, "image")) {
+ File imageFile = new File(uri.toString());
+ return new Response(new BufferedInputStream(new FileInputStream(imageFile)), true, imageFile.length());
+ } else {
+ MediaMetadataRetriever mmr = new MediaMetadataRetriever();
+ mmr.setDataSource(uri.getPath());
+ byte[] data = mmr.getEmbeddedPicture();
+ mmr.release();
+
+ if (data != null) {
+ return new Response(new ByteArrayInputStream(data), true, data.length);
+ } else {
+
+ // check for fallback Uri
+ String fallbackParam = uri.getQueryParameter(PicassoImageResource.PARAM_FALLBACK);
+
+ if (fallbackParam != null) {
+ String fallback = Uri.decode(Uri.parse(fallbackParam).getPath());
+ if (fallback != null) {
+ File imageFile = new File(fallback);
+ return new Response(new BufferedInputStream(new FileInputStream(imageFile)), true, imageFile.length());
+ }
+ }
+ return null;
+ }
+ }
+ }
+ return okHttpDownloader.load(uri, b);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java b/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java
new file mode 100644
index 000000000..56d1ca092
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/backup/OpmlBackupAgent.java
@@ -0,0 +1,212 @@
+package de.danoeh.antennapod.backup;
+
+import android.app.backup.BackupAgentHelper;
+import android.app.backup.BackupDataInputStream;
+import android.app.backup.BackupDataOutput;
+import android.app.backup.BackupHelper;
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import de.danoeh.antennapod.BuildConfig;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.math.BigInteger;
+import java.security.DigestInputStream;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+
+import de.danoeh.antennapod.AppConfig;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.opml.OpmlElement;
+import de.danoeh.antennapod.opml.OpmlReader;
+import de.danoeh.antennapod.opml.OpmlWriter;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.LangUtils;
+
+public class OpmlBackupAgent extends BackupAgentHelper {
+ private static final String OPML_BACKUP_KEY = "opml";
+
+ @Override
+ public void onCreate() {
+ addHelper(OPML_BACKUP_KEY, new OpmlBackupHelper(this));
+ }
+
+ private static final void LOGD(String tag, String msg) {
+ if (BuildConfig.DEBUG && Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, msg);
+ }
+ }
+
+ private static final void LOGD(String tag, String msg, Throwable tr) {
+ if (BuildConfig.DEBUG && Log.isLoggable(tag, Log.DEBUG)) {
+ Log.d(tag, msg, tr);
+ }
+ }
+
+ /** Class for backing up and restoring the OPML file. */
+ private static class OpmlBackupHelper implements BackupHelper {
+ private static final String TAG = "OpmlBackupHelper";
+
+ private static final String OPML_ENTITY_KEY = "antennapod-feeds.opml";
+
+ private final Context mContext;
+
+ /** Checksum of restored OPML file */
+ private byte[] mChecksum;
+
+ public OpmlBackupHelper(Context context) {
+ mContext = context;
+ }
+
+ @Override
+ public void performBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) {
+ Log.d(TAG, "Performing backup");
+ ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+ MessageDigest digester = null;
+ Writer writer;
+
+ try {
+ digester = MessageDigest.getInstance("MD5");
+ writer = new OutputStreamWriter(new DigestOutputStream(byteStream, digester),
+ LangUtils.UTF_8);
+ } catch (NoSuchAlgorithmException e) {
+ writer = new OutputStreamWriter(byteStream, LangUtils.UTF_8);
+ }
+
+ try {
+ // Write OPML
+ new OpmlWriter().writeDocument(DBReader.getFeedList(mContext), writer);
+
+ // Compare checksum of new and old file to see if we need to perform a backup at all
+ if (digester != null) {
+ byte[] newChecksum = digester.digest();
+ LOGD(TAG, "New checksum: " + new BigInteger(1, newChecksum).toString(16));
+
+ // Get the old checksum
+ if (oldState != null) {
+ FileInputStream inState = new FileInputStream(oldState.getFileDescriptor());
+ int len = inState.read();
+
+ if (len != -1) {
+ byte[] oldChecksum = new byte[len];
+ inState.read(oldChecksum);
+ LOGD(TAG, "Old checksum: " + new BigInteger(1, oldChecksum).toString(16));
+
+ if (Arrays.equals(oldChecksum, newChecksum)) {
+ LOGD(TAG, "Checksums are the same; won't backup");
+ return;
+ }
+ }
+ }
+
+ writeNewStateDescription(newState, newChecksum);
+ }
+
+ LOGD(TAG, "Backing up OPML");
+ byte[] bytes = byteStream.toByteArray();
+ data.writeEntityHeader(OPML_ENTITY_KEY, bytes.length);
+ data.writeEntityData(bytes, bytes.length);
+ } catch (IOException e) {
+ Log.e(TAG, "Error during backup", e);
+ } finally {
+ if (writer != null) {
+ try {
+ writer.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ @Override
+ public void restoreEntity(BackupDataInputStream data) {
+ LOGD(TAG, "Backup restore");
+
+ if (!OPML_ENTITY_KEY.equals(data.getKey())) {
+ LOGD(TAG, "Unknown entity key: " + data.getKey());
+ return;
+ }
+
+ MessageDigest digester = null;
+ Reader reader;
+
+ try {
+ digester = MessageDigest.getInstance("MD5");
+ reader = new InputStreamReader(new DigestInputStream(data, digester),
+ LangUtils.UTF_8);
+ } catch (NoSuchAlgorithmException e) {
+ reader = new InputStreamReader(data, LangUtils.UTF_8);
+ }
+
+ try {
+ ArrayList<OpmlElement> opmlElements = new OpmlReader().readDocument(reader);
+ mChecksum = digester == null ? null : digester.digest();
+ DownloadRequester downloader = DownloadRequester.getInstance();
+ Date lastUpdated = new Date();
+
+ for (OpmlElement opmlElem : opmlElements) {
+ Feed feed = new Feed(opmlElem.getXmlUrl(), lastUpdated, opmlElem.getText());
+
+ try {
+ downloader.downloadFeed(mContext, feed);
+ } catch (DownloadRequestException e) {
+ LOGD(TAG, "Error while restoring/downloading feed", e);
+ }
+ }
+ } catch (XmlPullParserException e) {
+ Log.e(TAG, "Error while parsing the OPML file", e);
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to restore OPML backup", e);
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ }
+
+ @Override
+ public void writeNewStateDescription(ParcelFileDescriptor newState) {
+ writeNewStateDescription(newState, mChecksum);
+ }
+
+ /**
+ * Writes the new state description, which is the checksum of the OPML file.
+ *
+ * @param newState
+ * @param checksum
+ */
+ private void writeNewStateDescription(ParcelFileDescriptor newState, byte[] checksum) {
+ if (checksum == null) {
+ return;
+ }
+
+ try {
+ FileOutputStream outState = new FileOutputStream(newState.getFileDescriptor());
+ outState.write(checksum.length);
+ outState.write(checksum);
+ outState.flush();
+ outState.close();
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to write new state description", e);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java
new file mode 100644
index 000000000..bdb2d68ba
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/AuthenticationDialog.java
@@ -0,0 +1,89 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.View;
+import android.view.Window;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import de.danoeh.antennapod.R;
+
+/**
+ * Displays a dialog with a username and password text field and an optional checkbox to save username and preferences.
+ */
+public abstract class AuthenticationDialog extends Dialog {
+
+ private final int titleRes;
+ private final boolean enableUsernameField;
+ private final boolean showSaveCredentialsCheckbox;
+ private final String usernameInitialValue;
+ private final String passwordInitialValue;
+
+ public AuthenticationDialog(Context context, int titleRes, boolean enableUsernameField, boolean showSaveCredentialsCheckbox, String usernameInitialValue, String passwordInitialValue) {
+ super(context);
+ this.titleRes = titleRes;
+ this.enableUsernameField = enableUsernameField;
+ this.showSaveCredentialsCheckbox = showSaveCredentialsCheckbox;
+ this.usernameInitialValue = usernameInitialValue;
+ this.passwordInitialValue = passwordInitialValue;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.authentication_dialog);
+ final EditText etxtUsername = (EditText) findViewById(R.id.etxtUsername);
+ final EditText etxtPassword = (EditText) findViewById(R.id.etxtPassword);
+ final CheckBox saveUsernamePassword = (CheckBox) findViewById(R.id.chkSaveUsernamePassword);
+ final Button butConfirm = (Button) findViewById(R.id.butConfirm);
+ final Button butCancel = (Button) findViewById(R.id.butCancel);
+
+ if (titleRes != 0) {
+ setTitle(titleRes);
+ } else {
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ }
+ etxtUsername.setEnabled(enableUsernameField);
+ if (showSaveCredentialsCheckbox) {
+ saveUsernamePassword.setVisibility(View.VISIBLE);
+ } else {
+ saveUsernamePassword.setVisibility(View.GONE);
+ }
+ if (usernameInitialValue != null) {
+ etxtUsername.setText(usernameInitialValue);
+ }
+ if (passwordInitialValue != null) {
+ etxtPassword.setText(passwordInitialValue);
+ }
+ setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ onCancelled();
+ }
+ });
+ butCancel.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ cancel();
+ }
+ });
+ butConfirm.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onConfirmed(etxtUsername.getText().toString(),
+ etxtPassword.getText().toString(),
+ showSaveCredentialsCheckbox && saveUsernamePassword.isChecked());
+ dismiss();
+ }
+ });
+ }
+
+ protected void onCancelled() {
+
+ }
+
+ protected abstract void onConfirmed(String username, String password, boolean saveUsernamePassword);
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java
new file mode 100644
index 000000000..d1ed795dc
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/AutoFlattrPreferenceDialog.java
@@ -0,0 +1,107 @@
+package de.danoeh.antennapod.dialog;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+/**
+ * Creates a new AlertDialog that displays preferences for auto-flattring to the user.
+ */
+public class AutoFlattrPreferenceDialog {
+
+ private AutoFlattrPreferenceDialog() {
+ }
+
+ public static void newAutoFlattrPreferenceDialog(final Activity activity, final AutoFlattrPreferenceDialogInterface callback) {
+ Validate.notNull(activity);
+ Validate.notNull(callback);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+
+ @SuppressLint("InflateParams") View view = activity.getLayoutInflater().inflate(R.layout.autoflattr_preference_dialog, null);
+ final CheckBox chkAutoFlattr = (CheckBox) view.findViewById(R.id.chkAutoFlattr);
+ final SeekBar skbPercent = (SeekBar) view.findViewById(R.id.skbPercent);
+ final TextView txtvStatus = (TextView) view.findViewById(R.id.txtvStatus);
+
+ chkAutoFlattr.setChecked(UserPreferences.isAutoFlattr());
+ skbPercent.setEnabled(chkAutoFlattr.isChecked());
+ txtvStatus.setEnabled(chkAutoFlattr.isChecked());
+
+ final int initialValue = (int) (UserPreferences.getAutoFlattrPlayedDurationThreshold() * 100.0f);
+ setStatusMsgText(activity, txtvStatus, initialValue);
+ skbPercent.setProgress(initialValue);
+
+ chkAutoFlattr.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ skbPercent.setEnabled(chkAutoFlattr.isChecked());
+ txtvStatus.setEnabled(chkAutoFlattr.isChecked());
+ }
+ });
+
+ skbPercent.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ setStatusMsgText(activity, txtvStatus, progress);
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+
+ }
+ });
+
+ builder.setTitle(R.string.pref_auto_flattr_title)
+ .setView(view)
+ .setPositiveButton(R.string.confirm_label, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ float progDouble = ((float) skbPercent.getProgress()) / 100.0f;
+ callback.onConfirmed(chkAutoFlattr.isChecked(), progDouble);
+ dialog.dismiss();
+ }
+ })
+ .setNegativeButton(R.string.cancel_label, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ callback.onCancelled();
+ dialog.dismiss();
+ }
+ })
+ .setCancelable(false).show();
+ }
+
+ private static void setStatusMsgText(Context context, TextView txtvStatus, int progress) {
+ if (progress == 0) {
+ txtvStatus.setText(R.string.auto_flattr_ater_beginning);
+ } else if (progress == 100) {
+ txtvStatus.setText(R.string.auto_flattr_ater_end);
+ } else {
+ txtvStatus.setText(context.getString(R.string.auto_flattr_after_percent, progress));
+ }
+ }
+
+ public static interface AutoFlattrPreferenceDialogInterface {
+ public void onCancelled();
+
+ public void onConfirmed(boolean autoFlattrEnabled, float autoFlattrValue);
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java
new file mode 100644
index 000000000..df71fff77
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/ConfirmationDialog.java
@@ -0,0 +1,64 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+
+/**
+ * Creates an AlertDialog which asks the user to confirm something. Other
+ * classes can handle events like confirmation or cancellation.
+ */
+public abstract class ConfirmationDialog {
+ private static final String TAG = "ConfirmationDialog";
+
+ Context context;
+ int titleId;
+ int messageId;
+
+ public ConfirmationDialog(Context context, int titleId, int messageId) {
+ this.context = context;
+ this.titleId = titleId;
+ this.messageId = messageId;
+ }
+
+ public void onCancelButtonPressed(DialogInterface dialog) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Dialog was cancelled");
+ dialog.dismiss();
+ }
+
+ public abstract void onConfirmButtonPressed(DialogInterface dialog);
+
+ public final AlertDialog createNewDialog() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(titleId);
+ builder.setMessage(messageId);
+ builder.setPositiveButton(R.string.confirm_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ onConfirmButtonPressed(dialog);
+ }
+ });
+ builder.setNegativeButton(R.string.cancel_label,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ onCancelButtonPressed(dialog);
+ }
+ });
+ builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ onCancelButtonPressed(dialog);
+ }
+ });
+ return builder.create();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java b/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java
new file mode 100644
index 000000000..e363a6911
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/DownloadRequestErrorDialogCreator.java
@@ -0,0 +1,30 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import de.danoeh.antennapod.R;
+
+/** Creates Alert Dialogs if a DownloadRequestException has happened. */
+public class DownloadRequestErrorDialogCreator {
+ private DownloadRequestErrorDialogCreator() {
+ }
+
+ public static void newRequestErrorDialog(Context context,
+ String errorMessage) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setNeutralButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.dismiss();
+ }
+ })
+ .setTitle(R.string.download_error_request_error)
+ .setMessage(
+ context.getString(R.string.download_request_error_dialog_message_prefix)
+ + errorMessage);
+ builder.create().show();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java
new file mode 100644
index 000000000..7384463de
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/FeedItemDialog.java
@@ -0,0 +1,428 @@
+package de.danoeh.antennapod.dialog;
+
+import android.annotation.TargetApi;
+import android.app.Dialog;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.TypedArray;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v7.widget.PopupMenu;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.Window;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.ImageButton;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.StringEscapeUtils;
+import org.apache.commons.lang3.Validate;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.adapter.DefaultActionButtonCallback;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.ShownotesProvider;
+import de.danoeh.antennapod.util.menuhandler.FeedItemMenuHandler;
+
+/**
+ * Shows information about a specific FeedItem and provides actions like playing, downloading, etc.
+ */
+public class FeedItemDialog extends Dialog {
+ private static final String TAG = "FeedItemDialog";
+
+ private FeedItem item;
+ private QueueAccess queue;
+
+ private View header;
+ private TextView txtvTitle;
+ private WebView webvDescription;
+ private ImageButton butAction1;
+ private ImageButton butAction2;
+ private ImageButton butMore;
+ private PopupMenu popupMenu;
+
+ public static FeedItemDialog newInstance(Context context, FeedItemDialogSavedInstance savedInstance) {
+ Validate.notNull(savedInstance);
+ FeedItemDialog dialog = newInstance(context, savedInstance.item, savedInstance.queueAccess);
+ if (savedInstance.isShowing) {
+ dialog.show();
+ }
+ return dialog;
+ }
+
+ public static FeedItemDialog newInstance(Context context, FeedItem item, QueueAccess queue) {
+ if (useDarkThemeWorkAround()) {
+ return new FeedItemDialog(context, R.style.Theme_AntennaPod_Dark, item, queue);
+ } else {
+ return new FeedItemDialog(context, item, queue);
+ }
+ }
+
+ public FeedItemDialog(Context context, int theme, FeedItem item, QueueAccess queue) {
+ super(context, theme);
+ Validate.notNull(item);
+ Validate.notNull(queue);
+ this.item = item;
+ this.queue = queue;
+ }
+
+ private FeedItemDialog(Context context, FeedItem item, QueueAccess queue) {
+ this(context, 0, item, queue);
+ }
+
+ /**
+ * Returns true if the dialog should use a dark theme. This has to be done on Gingerbread devices
+ * because dialogs are only available in a dark theme.
+ */
+ private static boolean useDarkThemeWorkAround() {
+ return Build.VERSION.SDK_INT <= Build.VERSION_CODES.GINGERBREAD_MR1
+ && UserPreferences.getTheme() != R.style.Theme_AntennaPod_Dark;
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ setContentView(R.layout.feeditem_dialog);
+
+ txtvTitle = (TextView) findViewById(R.id.txtvTitle);
+ header = findViewById(R.id.header);
+ webvDescription = (WebView) findViewById(R.id.webview);
+ butAction1 = (ImageButton) findViewById(R.id.butAction1);
+ butAction2 = (ImageButton) findViewById(R.id.butAction2);
+ butMore = (ImageButton) findViewById(R.id.butMoreActions);
+ popupMenu = new PopupMenu(getContext(), butMore);
+
+ webvDescription.setWebViewClient(new WebViewClient());
+ txtvTitle.setText(item.getTitle());
+
+ if (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) {
+ if (Build.VERSION.SDK_INT >= 11
+ && Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+ webvDescription.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+ }
+ webvDescription.setBackgroundColor(getContext().getResources().getColor(
+ R.color.black));
+ }
+ webvDescription.getSettings().setUseWideViewPort(false);
+ webvDescription.getSettings().setLayoutAlgorithm(
+ WebSettings.LayoutAlgorithm.NARROW_COLUMNS);
+ webvDescription.getSettings().setLoadWithOverviewMode(true);
+ webvDescription.setWebViewClient(new WebViewClient() {
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ try {
+ getContext().startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+ });
+
+ loadDescriptionWebview(item);
+
+ butAction1.setOnClickListener(new View.OnClickListener() {
+ DefaultActionButtonCallback actionButtonCallback = new DefaultActionButtonCallback(getContext());
+
+ @Override
+
+ public void onClick(View v) {
+ actionButtonCallback.onActionButtonPressed(item);
+ FeedMedia media = item.getMedia();
+ if (media != null && media.isDownloaded()) {
+ // playback was started, dialog should close itself
+ dismiss();
+ }
+
+ }
+ }
+ );
+
+ butAction2.setOnClickListener(new View.OnClickListener()
+
+ {
+ @Override
+ public void onClick(View v) {
+ if (item.hasMedia()) {
+ FeedMedia media = item.getMedia();
+ if (!media.isDownloaded()) {
+ DBTasks.playMedia(getContext(), media, true, true, true);
+ dismiss();
+ } else {
+ DBWriter.deleteFeedMediaOfItem(getContext(), media.getId());
+ }
+ } else if (item.getLink() != null) {
+ Uri uri = Uri.parse(item.getLink());
+ getContext().startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ }
+ }
+ }
+ );
+
+ butMore.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ popupMenu.getMenu().clear();
+ popupMenu.inflate(R.menu.feeditem_dialog);
+ if (item.hasMedia()) {
+ FeedItemMenuHandler.onPrepareMenu(popupMenuInterface, item, true, queue);
+ } else {
+ // these are already available via button1 and button2
+ FeedItemMenuHandler.onPrepareMenu(popupMenuInterface, item, true, queue,
+ R.id.mark_read_item, R.id.visit_website_item);
+ }
+ popupMenu.show();
+ }
+ }
+ );
+
+ popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
+ @Override
+ public boolean onMenuItemClick(MenuItem menuItem) {
+
+ try {
+ return FeedItemMenuHandler.onMenuItemClicked(getContext(), menuItem.getItemId(), item);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ Toast.makeText(getContext(), e.getMessage(), Toast.LENGTH_LONG).show();
+ return true;
+ }
+ }
+ }
+ );
+
+ updateMenuAppearance();
+ }
+
+
+ private final FeedItemMenuHandler.MenuInterface popupMenuInterface = new FeedItemMenuHandler.MenuInterface() {
+ @Override
+ public void setItemVisibility(int id, boolean visible) {
+ MenuItem item = popupMenu.getMenu().findItem(id);
+ if (item != null) {
+ item.setVisible(visible);
+ }
+ }
+ };
+
+ public void updateMenuAppearance() {
+ if (item == null || queue == null) {
+ Log.w(TAG, "UpdateMenuAppearance called while item or queue was null");
+ return;
+ }
+ FeedMedia media = item.getMedia();
+ if (media == null) {
+ TypedArray drawables = getContext().obtainStyledAttributes(new int[]{R.attr.navigation_accept,
+ R.attr.location_web_site});
+
+ if (!item.isRead()) {
+ butAction1.setImageDrawable(drawables.getDrawable(0));
+ butAction1.setContentDescription(getContext().getString(R.string.mark_read_label));
+ butAction1.setVisibility(View.VISIBLE);
+ } else {
+ butAction1.setVisibility(View.INVISIBLE);
+ }
+
+ if (item.getLink() != null) {
+ butAction2.setImageDrawable(drawables.getDrawable(1));
+ butAction2.setContentDescription(getContext().getString(R.string.visit_website_label));
+ } else {
+ butAction2.setEnabled(false);
+ }
+
+ drawables.recycle();
+ } else {
+ boolean isDownloading = DownloadRequester.getInstance().isDownloadingFile(media);
+ TypedArray drawables = getContext().obtainStyledAttributes(new int[]{R.attr.av_play,
+ R.attr.av_download, R.attr.action_stream, R.attr.content_discard, R.attr.navigation_cancel});
+
+ if (!media.isDownloaded()) {
+ butAction2.setImageDrawable(drawables.getDrawable(2));
+ butAction2.setContentDescription(getContext().getString(R.string.stream_label));
+ } else {
+ butAction2.setImageDrawable(drawables.getDrawable(3));
+ butAction2.setContentDescription(getContext().getString(R.string.remove_episode_lable));
+ }
+
+ if (isDownloading) {
+ butAction1.setImageDrawable(drawables.getDrawable(4));
+ butAction1.setContentDescription(getContext().getString(R.string.cancel_download_label));
+ } else if (media.isDownloaded()) {
+ butAction1.setImageDrawable(drawables.getDrawable(0));
+ butAction1.setContentDescription(getContext().getString(R.string.play_label));
+ } else {
+ butAction1.setImageDrawable(drawables.getDrawable(1));
+ butAction1.setContentDescription(getContext().getString(R.string.download_label));
+ }
+
+ drawables.recycle();
+ }
+ }
+
+
+ private void loadDescriptionWebview(final ShownotesProvider shownotesProvider) {
+ AsyncTask<Void, Void, Void> loadTask = new AsyncTask<Void, Void, Void>() {
+ String data;
+
+
+ private String applyWebviewStyle(String textColor, String data) {
+ final String WEBVIEW_STYLE = "<html><head><style type=\"text/css\"> @font-face { font-family: 'Roboto-Light'; src: url('file:///android_asset/Roboto-Light.ttf'); } * { color: %s; font-family: roboto-Light; font-size: 11pt; } a { font-style: normal; text-decoration: none; font-weight: normal; color: #00A8DF; } img { display: block; margin: 10 auto; max-width: %s; height: auto; } body { margin: %dpx %dpx %dpx %dpx; }</style></head><body>%s</body></html>";
+ final int pageMargin = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 8, getContext().getResources()
+ .getDisplayMetrics()
+ );
+ return String.format(WEBVIEW_STYLE, textColor, "100%", pageMargin,
+ pageMargin, pageMargin, pageMargin, data);
+ }
+
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ // /webvDescription.loadData(url, "text/html", "utf-8");
+ if (FeedItemDialog.this.isShowing() && webvDescription != null) {
+ webvDescription.loadDataWithBaseURL(null, data, "text/html",
+ "utf-8", "about:blank");
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Webview loaded");
+ }
+ }
+
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading Webview");
+ try {
+ Callable<String> shownotesLoadTask = shownotesProvider.loadShownotes();
+ final String shownotes = shownotesLoadTask.call();
+
+ data = StringEscapeUtils.unescapeHtml4(shownotes);
+ TypedArray res = getContext()
+ .getTheme()
+ .obtainStyledAttributes(
+ new int[]{android.R.attr.textColorPrimary});
+ int colorResource;
+ if (useDarkThemeWorkAround()) {
+ colorResource = getContext().getResources().getColor(R.color.black);
+ } else {
+ colorResource = res.getColor(0, 0);
+ }
+ String colorString = String.format("#%06X",
+ 0xFFFFFF & colorResource);
+ Log.i(TAG, "text color: " + colorString);
+ res.recycle();
+ data = applyWebviewStyle(colorString, data);
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ };
+ loadTask.execute();
+ }
+
+ /**
+ * Convenience method that calls setQueue() and setItemFromCollection() with
+ * the given arguments.
+ *
+ * @return true if one of the calls to setItemFromCollection returned true,
+ * false otherwise.
+ */
+ public boolean updateContent(QueueAccess queue, List<FeedItem>... collections) {
+ setQueue(queue);
+
+ boolean setItemFromCollectionResult = false;
+ if (collections != null) {
+ for (List<FeedItem> list : collections) {
+ setItemFromCollectionResult |= setItemFromCollection(list);
+ }
+ }
+ if (isShowing()) {
+ updateMenuAppearance();
+ }
+
+ return setItemFromCollectionResult;
+ }
+
+
+ public void setItem(FeedItem item) {
+ Validate.notNull(item);
+ this.item = item;
+ }
+
+ /**
+ * Finds the FeedItem of this dialog in a collection and updates its state from that
+ * collection.
+ *
+ * @return true if the FeedItem was found, false otherwise.
+ */
+ public boolean setItemFromCollection(Collection<FeedItem> items) {
+ for (FeedItem item : items) {
+ if (item.getId() == this.item.getId()) {
+ setItem(item);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void setQueue(QueueAccess queue) {
+ Validate.notNull(queue);
+ this.queue = queue;
+ }
+
+ public FeedItem getItem() {
+ return item;
+ }
+
+ public QueueAccess getQueue() {
+ return queue;
+ }
+
+ public FeedItemDialogSavedInstance save() {
+ return new FeedItemDialogSavedInstance(item, queue, isShowing());
+ }
+
+ /**
+ * Used to save the FeedItemDialog's state across configuration changes
+ */
+ public static class FeedItemDialogSavedInstance {
+ final FeedItem item;
+ final QueueAccess queueAccess;
+ final boolean isShowing;
+
+ private FeedItemDialogSavedInstance(FeedItem item, QueueAccess queueAccess, boolean isShowing) {
+ this.item = item;
+ this.queueAccess = queueAccess;
+ this.isShowing = isShowing;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java
new file mode 100644
index 000000000..a9c596d2e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/GpodnetSetHostnameDialog.java
@@ -0,0 +1,67 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.text.Editable;
+import android.text.InputType;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+
+/**
+ * Creates a dialog that lets the user change the hostname for the gpodder.net service.
+ */
+public class GpodnetSetHostnameDialog {
+ private static final String TAG = "GpodnetSetHostnameDialog";
+
+ public static AlertDialog createDialog(final Context context) {
+ AlertDialog.Builder dialog = new AlertDialog.Builder(context);
+ final EditText et = new EditText(context);
+ et.setText(GpodnetPreferences.getHostname());
+ et.setInputType(InputType.TYPE_TEXT_VARIATION_URI);
+ dialog.setTitle(R.string.pref_gpodnet_sethostname_title)
+ .setView(setupContentView(context, et))
+ .setPositiveButton(R.string.confirm_label, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ final Editable e = et.getText();
+ if (e != null) {
+ GpodnetPreferences.setHostname(e.toString());
+ }
+ dialog.dismiss();
+ }
+ })
+ .setNegativeButton(R.string.cancel_label, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ })
+ .setNeutralButton(R.string.pref_gpodnet_sethostname_use_default_host, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ GpodnetPreferences.setHostname(GpodnetService.DEFAULT_BASE_HOST);
+ dialog.dismiss();
+ }
+ })
+ .setCancelable(true);
+ return dialog.show();
+ }
+
+ private static View setupContentView(Context context, EditText et) {
+ LinearLayout ll = new LinearLayout(context);
+ ll.addView(et);
+ LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) et.getLayoutParams();
+ if (params != null) {
+ params.setMargins(8, 8, 8, 8);
+ params.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ params.height = ViewGroup.LayoutParams.MATCH_PARENT;
+ }
+ return ll;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java
new file mode 100644
index 000000000..bbd514640
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/TimeDialog.java
@@ -0,0 +1,138 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.*;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+
+import java.util.concurrent.TimeUnit;
+
+public abstract class TimeDialog extends Dialog {
+ private static final String TAG = "TimeDialog";
+
+ private static final int DEFAULT_SPINNER_POSITION = 1;
+
+ private Context context;
+
+ private EditText etxtTime;
+ private Spinner spTimeUnit;
+ private Button butConfirm;
+ private Button butCancel;
+
+ private TimeUnit[] units = {TimeUnit.SECONDS, TimeUnit.MINUTES,
+ TimeUnit.HOURS};
+
+ public TimeDialog(Context context, int titleTextId, int leftButtonTextId) {
+ super(context);
+ this.context = context;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ String[] spinnerContent = new String[]{context.getString(R.string.time_unit_seconds),
+ context.getString(R.string.time_unit_minutes),
+ context.getString(R.string.time_unit_hours)};
+
+ setContentView(R.layout.time_dialog);
+ etxtTime = (EditText) findViewById(R.id.etxtTime);
+ spTimeUnit = (Spinner) findViewById(R.id.spTimeUnit);
+ butConfirm = (Button) findViewById(R.id.butConfirm);
+ butCancel = (Button) findViewById(R.id.butCancel);
+
+ butConfirm.setText(R.string.set_sleeptimer_label);
+ butCancel.setText(R.string.cancel_label);
+ setTitle(R.string.set_sleeptimer_label);
+ ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<String>(
+ this.getContext(), android.R.layout.simple_spinner_item,
+ spinnerContent);
+ spinnerAdapter
+ .setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ spTimeUnit.setAdapter(spinnerAdapter);
+ spTimeUnit.setSelection(DEFAULT_SPINNER_POSITION);
+ butCancel.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ dismiss();
+ }
+ });
+ butConfirm.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ try {
+ long input = readTimeMillis();
+ onTimeEntered(input);
+ dismiss();
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ Toast toast = Toast.makeText(context,
+ R.string.time_dialog_invalid_input,
+ Toast.LENGTH_LONG);
+ toast.show();
+ }
+ }
+ });
+ etxtTime.addTextChangedListener(new TextWatcher() {
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ checkInputLength(s.length());
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count,
+ int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before,
+ int count) {
+
+ }
+ });
+ checkInputLength(etxtTime.getText().length());
+ etxtTime.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(etxtTime, InputMethodManager.SHOW_IMPLICIT);
+ }
+ }, 100);
+
+
+
+ }
+
+ private void checkInputLength(int length) {
+ if (length > 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Length is larger than 0, enabling confirm button");
+ butConfirm.setEnabled(true);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Length is smaller than 0, disabling confirm button");
+ butConfirm.setEnabled(false);
+ }
+ }
+
+ public abstract void onTimeEntered(long millis);
+
+ private long readTimeMillis() {
+ TimeUnit selectedUnit = units[spTimeUnit.getSelectedItemPosition()];
+ long value = Long.valueOf(etxtTime.getText().toString());
+ return selectedUnit.toMillis(value);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java
new file mode 100644
index 000000000..b009e76a7
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java
@@ -0,0 +1,100 @@
+package de.danoeh.antennapod.dialog;
+
+import android.app.AlertDialog;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class VariableSpeedDialog {
+ private VariableSpeedDialog() {
+ }
+
+ public static void showDialog(final Context context) {
+ if (com.aocate.media.MediaPlayer.isPrestoLibraryInstalled(context)) {
+ showSpeedSelectorDialog(context);
+ } else {
+ showGetPluginDialog(context);
+ }
+ }
+
+ private static void showGetPluginDialog(final Context context) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.no_playback_plugin_title);
+ builder.setMessage(R.string.no_playback_plugin_msg);
+ builder.setNegativeButton(R.string.close_label, null);
+ builder.setPositiveButton(R.string.download_plugin_label,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ Intent playStoreIntent = new Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("market://details?id=com.falconware.prestissimo"));
+ context.startActivity(playStoreIntent);
+ } catch (ActivityNotFoundException e) {
+ // this is usually thrown on an emulator if the Android market is not installed
+ e.printStackTrace();
+ }
+ }
+ });
+ builder.create().show();
+ }
+
+ private static void showSpeedSelectorDialog(final Context context) {
+ final String[] speedValues = context.getResources().getStringArray(
+ R.array.playback_speed_values);
+ // According to Java spec these get initialized to false on creation
+ final boolean[] speedChecked = new boolean[speedValues.length];
+
+ // Build the "isChecked" array so that multiChoice dialog is
+ // populated correctly
+ List<String> selectedSpeedList = Arrays.asList(UserPreferences
+ .getPlaybackSpeedArray());
+ for (int i = 0; i < speedValues.length; i++) {
+ speedChecked[i] = selectedSpeedList.contains(speedValues[i]);
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.set_playback_speed_label);
+ builder.setMultiChoiceItems(R.array.playback_speed_values,
+ speedChecked, new DialogInterface.OnMultiChoiceClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which,
+ boolean isChecked) {
+ speedChecked[which] = isChecked;
+ }
+
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ int choiceCount = 0;
+ for (int i = 0; i < speedChecked.length; i++) {
+ if (speedChecked[i]) {
+ choiceCount++;
+ }
+ }
+ String[] newSpeedValues = new String[choiceCount];
+ int newSpeedIndex = 0;
+ for (int i = 0; i < speedChecked.length; i++) {
+ if (speedChecked[i]) {
+ newSpeedValues[newSpeedIndex++] = speedValues[i];
+ }
+ }
+
+ UserPreferences.setPlaybackSpeedArray(newSpeedValues);
+
+ }
+ });
+ builder.create().show();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java b/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java
new file mode 100644
index 000000000..d6151ee9f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/Chapter.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.feed;
+
+public abstract class Chapter extends FeedComponent {
+
+ /** Defines starting point in milliseconds. */
+ protected long start;
+ protected String title;
+ protected String link;
+
+ public Chapter() {
+ }
+
+ public Chapter(long start) {
+ super();
+ this.start = start;
+ }
+
+ public Chapter(long start, String title, FeedItem item, String link) {
+ super();
+ this.start = start;
+ this.title = title;
+ this.link = link;
+ }
+
+ public abstract int getChapterType();
+
+ public long getStart() {
+ return start;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getLink() {
+ return link;
+ }
+
+ public void setStart(long start) {
+ this.start = start;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ @Override
+ public String getHumanReadableIdentifier() {
+ return title;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java b/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java
new file mode 100644
index 000000000..5fb72048e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/EventDistributor.java
@@ -0,0 +1,140 @@
+package de.danoeh.antennapod.feed;
+
+import android.os.Handler;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+
+import java.util.AbstractQueue;
+import java.util.Observable;
+import java.util.Observer;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Notifies its observers about changes in the feed database. Observers can
+ * register by retrieving an instance of this class and registering an
+ * EventListener. When new events arrive, the EventDistributor will process the
+ * event queue in a handler that runs on the main thread. The observers will only
+ * be notified once if the event queue contains multiple elements.
+ *
+ * Events can be sent with the send* methods.
+ */
+public class EventDistributor extends Observable {
+ private static final String TAG = "EventDistributor";
+
+ public static final int FEED_LIST_UPDATE = 1;
+ public static final int UNREAD_ITEMS_UPDATE = 2;
+ public static final int QUEUE_UPDATE = 4;
+ public static final int DOWNLOADLOG_UPDATE = 8;
+ public static final int PLAYBACK_HISTORY_UPDATE = 16;
+ public static final int DOWNLOAD_QUEUED = 32;
+ public static final int DOWNLOAD_HANDLED = 64;
+
+ private Handler handler;
+ private AbstractQueue<Integer> events;
+
+ private static EventDistributor instance;
+
+ private EventDistributor() {
+ this.handler = new Handler();
+ events = new ConcurrentLinkedQueue<Integer>();
+ }
+
+ public static synchronized EventDistributor getInstance() {
+ if (instance == null) {
+ instance = new EventDistributor();
+ }
+ return instance;
+ }
+
+ public void register(EventListener el) {
+ addObserver(el);
+ }
+
+ public void unregister(EventListener el) {
+ deleteObserver(el);
+ }
+
+ public void addEvent(Integer i) {
+ events.offer(i);
+ handler.post(new Runnable() {
+
+ @Override
+ public void run() {
+ processEventQueue();
+ }
+ });
+ }
+
+ private void processEventQueue() {
+ Integer result = 0;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Processing event queue. Number of events: "
+ + events.size());
+ for (Integer current = events.poll(); current != null; current = events
+ .poll()) {
+ result |= current;
+ }
+ if (result != 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Notifying observers. Data: " + result);
+ setChanged();
+ notifyObservers(result);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Event queue didn't contain any new events. Observers will not be notified.");
+ }
+ }
+
+ @Override
+ public void addObserver(Observer observer) {
+ super.addObserver(observer);
+ Validate.isInstanceOf(EventListener.class, observer);
+ }
+
+ public void sendDownloadQueuedBroadcast() {
+ addEvent(DOWNLOAD_QUEUED);
+ }
+
+ public void sendUnreadItemsUpdateBroadcast() {
+ addEvent(UNREAD_ITEMS_UPDATE);
+ }
+
+ public void sendQueueUpdateBroadcast() {
+ addEvent(QUEUE_UPDATE);
+ }
+
+ public void sendFeedUpdateBroadcast() {
+ addEvent(FEED_LIST_UPDATE);
+ }
+
+ public void sendPlaybackHistoryUpdateBroadcast() {
+ addEvent(PLAYBACK_HISTORY_UPDATE);
+ }
+
+ public void sendDownloadLogUpdateBroadcast() {
+ addEvent(DOWNLOADLOG_UPDATE);
+ }
+
+ public void sendDownloadHandledBroadcast() {
+ addEvent(DOWNLOAD_HANDLED);
+ }
+
+ public static abstract class EventListener implements Observer {
+
+ @Override
+ public void update(Observable observable, Object data) {
+ if (observable instanceof EventDistributor
+ && data instanceof Integer) {
+ update((EventDistributor) observable, (Integer) data);
+ }
+ }
+
+ public abstract void update(EventDistributor eventDistributor,
+ Integer arg);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/Feed.java b/app/src/main/java/de/danoeh/antennapod/feed/Feed.java
new file mode 100644
index 000000000..b5415c69c
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/Feed.java
@@ -0,0 +1,445 @@
+package de.danoeh.antennapod.feed;
+
+import android.content.Context;
+import android.net.Uri;
+
+import de.danoeh.antennapod.asynctask.PicassoImageResource;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.EpisodeFilter;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+import de.danoeh.antennapod.util.flattr.FlattrThing;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Data Object for a whole feed
+ *
+ * @author daniel
+ */
+public class Feed extends FeedFile implements FlattrThing, PicassoImageResource {
+ public static final int FEEDFILETYPE_FEED = 0;
+ public static final String TYPE_RSS2 = "rss";
+ public static final String TYPE_RSS091 = "rss";
+ public static final String TYPE_ATOM1 = "atom";
+
+ private String title;
+ /**
+ * Contains 'id'-element in Atom feed.
+ */
+ private String feedIdentifier;
+ /**
+ * Link to the website.
+ */
+ private String link;
+ private String description;
+ private String language;
+ /**
+ * Name of the author
+ */
+ private String author;
+ private FeedImage image;
+ private List<FeedItem> items;
+ /**
+ * Date of last refresh.
+ */
+ private Date lastUpdate;
+ private FlattrStatus flattrStatus;
+ private String paymentLink;
+ /**
+ * Feed type, for example RSS 2 or Atom
+ */
+ private String type;
+
+ /**
+ * Feed preferences
+ */
+ private FeedPreferences preferences;
+
+ /**
+ * This constructor is used for restoring a feed from the database.
+ */
+ public Feed(long id, Date lastUpdate, String title, String link, String description, String paymentLink,
+ String author, String language, String type, String feedIdentifier, FeedImage image, String fileUrl,
+ String downloadUrl, boolean downloaded, FlattrStatus status) {
+ super(fileUrl, downloadUrl, downloaded);
+ this.id = id;
+ this.title = title;
+ if (lastUpdate != null) {
+ this.lastUpdate = (Date) lastUpdate.clone();
+ } else {
+ this.lastUpdate = null;
+ }
+ this.link = link;
+ this.description = description;
+ this.paymentLink = paymentLink;
+ this.author = author;
+ this.language = language;
+ this.type = type;
+ this.feedIdentifier = feedIdentifier;
+ this.image = image;
+ this.flattrStatus = status;
+
+ items = new ArrayList<FeedItem>();
+ }
+
+ /**
+ * This constructor is used for test purposes and uses a default flattr status object.
+ */
+ public Feed(long id, Date lastUpdate, String title, String link, String description, String paymentLink,
+ String author, String language, String type, String feedIdentifier, FeedImage image, String fileUrl,
+ String downloadUrl, boolean downloaded) {
+ this(id, lastUpdate, title, link, description, paymentLink, author, language, type, feedIdentifier, image,
+ fileUrl, downloadUrl, downloaded, new FlattrStatus());
+ }
+
+ /**
+ * This constructor can be used when parsing feed data. Only the 'lastUpdate' and 'items' field are initialized.
+ */
+ public Feed() {
+ super();
+ items = new ArrayList<FeedItem>();
+ lastUpdate = new Date();
+ this.flattrStatus = new FlattrStatus();
+ }
+
+ /**
+ * This constructor is used for requesting a feed download (it must not be used for anything else!). It should NOT be
+ * used if the title of the feed is already known.
+ */
+ public Feed(String url, Date lastUpdate) {
+ super(null, url, false);
+ this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null;
+ this.flattrStatus = new FlattrStatus();
+ }
+
+ /**
+ * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be
+ * used if the title of the feed is already known.
+ */
+ public Feed(String url, Date lastUpdate, String title) {
+ this(url, lastUpdate);
+ this.title = title;
+ this.flattrStatus = new FlattrStatus();
+ }
+
+ /**
+ * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be
+ * used if the title of the feed is already known.
+ */
+ public Feed(String url, Date lastUpdate, String title, String username, String password) {
+ this(url, lastUpdate, title);
+ preferences = new FeedPreferences(0, true, username, password);
+ }
+
+ /**
+ * Returns the number of FeedItems where 'read' is false. If the 'display
+ * only episodes' - preference is set to true, this method will only count
+ * items with episodes.
+ */
+ public int getNumOfNewItems() {
+ int count = 0;
+ for (FeedItem item : items) {
+ if (item.getState() == FeedItem.State.NEW) {
+ if (!UserPreferences.isDisplayOnlyEpisodes()
+ || item.getMedia() != null) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Returns the number of FeedItems where the media started to play but
+ * wasn't finished yet.
+ */
+ public int getNumOfStartedItems() {
+ int count = 0;
+
+ for (FeedItem item : items) {
+ FeedItem.State state = item.getState();
+ if (state == FeedItem.State.IN_PROGRESS
+ || state == FeedItem.State.PLAYING) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Returns true if at least one item in the itemlist is unread.
+ *
+ * @param enableEpisodeFilter true if this method should only count items with episodes if
+ * the 'display only episodes' - preference is set to true by the
+ * user.
+ */
+ public boolean hasNewItems(boolean enableEpisodeFilter) {
+ for (FeedItem item : items) {
+ if (item.getState() == FeedItem.State.NEW) {
+ if (!(enableEpisodeFilter && UserPreferences
+ .isDisplayOnlyEpisodes()) || item.getMedia() != null) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the number of FeedItems.
+ *
+ * @param enableEpisodeFilter true if this method should only count items with episodes if
+ * the 'display only episodes' - preference is set to true by the
+ * user.
+ */
+ public int getNumOfItems(boolean enableEpisodeFilter) {
+ if (enableEpisodeFilter && UserPreferences.isDisplayOnlyEpisodes()) {
+ return EpisodeFilter.countItemsWithEpisodes(items);
+ } else {
+ return items.size();
+ }
+ }
+
+ /**
+ * Returns the item at the specified index.
+ *
+ * @param enableEpisodeFilter true if this method should ignore items without episdodes if
+ * the episodes filter has been enabled by the user.
+ */
+ public FeedItem getItemAtIndex(boolean enableEpisodeFilter, int position) {
+ if (enableEpisodeFilter && UserPreferences.isDisplayOnlyEpisodes()) {
+ return EpisodeFilter.accessEpisodeByIndex(items, position);
+ } else {
+ return items.get(position);
+ }
+ }
+
+ /**
+ * Returns the value that uniquely identifies this Feed. If the
+ * feedIdentifier attribute is not null, it will be returned. Else it will
+ * try to return the title. If the title is not given, it will use the link
+ * of the feed.
+ */
+ public String getIdentifyingValue() {
+ if (feedIdentifier != null && !feedIdentifier.isEmpty()) {
+ return feedIdentifier;
+ } else if (download_url != null && !download_url.isEmpty()) {
+ return download_url;
+ } else if (title != null && !title.isEmpty()) {
+ return title;
+ } else {
+ return link;
+ }
+ }
+
+ @Override
+ public String getHumanReadableIdentifier() {
+ if (title != null) {
+ return title;
+ } else {
+ return download_url;
+ }
+ }
+
+ public void updateFromOther(Feed other) {
+ super.updateFromOther(other);
+ if (other.title != null) {
+ title = other.title;
+ }
+ if (other.feedIdentifier != null) {
+ feedIdentifier = other.feedIdentifier;
+ }
+ if (other.link != null) {
+ link = other.link;
+ }
+ if (other.description != null) {
+ description = other.description;
+ }
+ if (other.language != null) {
+ language = other.language;
+ }
+ if (other.author != null) {
+ author = other.author;
+ }
+ if (other.paymentLink != null) {
+ paymentLink = other.paymentLink;
+ }
+ if (other.flattrStatus != null) {
+ flattrStatus = other.flattrStatus;
+ }
+ }
+
+ public boolean compareWithOther(Feed other) {
+ if (super.compareWithOther(other)) {
+ return true;
+ }
+ if (!title.equals(other.title)) {
+ return true;
+ }
+ if (other.feedIdentifier != null) {
+ if (feedIdentifier == null
+ || !feedIdentifier.equals(other.feedIdentifier)) {
+ return true;
+ }
+ }
+ if (other.link != null) {
+ if (link == null || !link.equals(other.link)) {
+ return true;
+ }
+ }
+ if (other.description != null) {
+ if (description == null || !description.equals(other.description)) {
+ return true;
+ }
+ }
+ if (other.language != null) {
+ if (language == null || !language.equals(other.language)) {
+ return true;
+ }
+ }
+ if (other.author != null) {
+ if (author == null || !author.equals(other.author)) {
+ return true;
+ }
+ }
+ if (other.paymentLink != null) {
+ if (paymentLink == null || !paymentLink.equals(other.paymentLink)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public int getTypeAsInt() {
+ return FEEDFILETYPE_FEED;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getLink() {
+ return link;
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public FeedImage getImage() {
+ return image;
+ }
+
+ public void setImage(FeedImage image) {
+ this.image = image;
+ }
+
+ public List<FeedItem> getItems() {
+ return items;
+ }
+
+ public void setItems(List<FeedItem> list) {
+ this.items = list;
+ }
+
+ public Date getLastUpdate() {
+ return (lastUpdate != null) ? (Date) lastUpdate.clone() : null;
+ }
+
+ public void setLastUpdate(Date lastUpdate) {
+ this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null;
+ }
+
+ public String getFeedIdentifier() {
+ return feedIdentifier;
+ }
+
+ public void setFeedIdentifier(String feedIdentifier) {
+ this.feedIdentifier = feedIdentifier;
+ }
+
+ public void setFlattrStatus(FlattrStatus status) {
+ this.flattrStatus = status;
+ }
+
+ public FlattrStatus getFlattrStatus() {
+ return flattrStatus;
+ }
+
+ public String getPaymentLink() {
+ return paymentLink;
+ }
+
+ public void setPaymentLink(String paymentLink) {
+ this.paymentLink = paymentLink;
+ }
+
+ public String getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(String language) {
+ this.language = language;
+ }
+
+ public String getAuthor() {
+ return author;
+ }
+
+ public void setAuthor(String author) {
+ this.author = author;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public void setPreferences(FeedPreferences preferences) {
+ this.preferences = preferences;
+ }
+
+ public FeedPreferences getPreferences() {
+ return preferences;
+ }
+
+ public void savePreferences(Context context) {
+ DBWriter.setFeedPreferences(context, preferences);
+ }
+
+ @Override
+ public void setId(long id) {
+ super.setId(id);
+ if (preferences != null) {
+ preferences.setFeedID(id);
+ }
+ }
+
+ @Override
+ public Uri getImageUri() {
+ if (image != null) {
+ return image.getImageUri();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java
new file mode 100644
index 000000000..48b243770
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedComponent.java
@@ -0,0 +1,66 @@
+package de.danoeh.antennapod.feed;
+
+/**
+ * Represents every possible component of a feed
+ *
+ * @author daniel
+ */
+public abstract class FeedComponent {
+
+ protected long id;
+
+ public FeedComponent() {
+ super();
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+
+ /**
+ * Update this FeedComponent's attributes with the attributes from another
+ * FeedComponent. This method should only update attributes which where read from
+ * the feed.
+ */
+ public void updateFromOther(FeedComponent other) {
+ }
+
+ /**
+ * Compare's this FeedComponent's attribute values with another FeedComponent's
+ * attribute values. This method will only compare attributes which were
+ * read from the feed.
+ *
+ * @return true if attribute values are different, false otherwise
+ */
+ public boolean compareWithOther(FeedComponent other) {
+ return false;
+ }
+
+
+ /**
+ * Should return a non-null, human-readable String so that the item can be
+ * identified by the user. Can be title, download-url, etc.
+ */
+ public abstract String getHumanReadableIdentifier();
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ FeedComponent that = (FeedComponent) o;
+
+ if (id != that.id) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return (int) (id ^ (id >>> 32));
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java
new file mode 100644
index 000000000..a05533ebc
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedFile.java
@@ -0,0 +1,105 @@
+package de.danoeh.antennapod.feed;
+
+import java.io.File;
+
+/**
+ * Represents a component of a Feed that has to be downloaded
+ */
+public abstract class FeedFile extends FeedComponent {
+
+ protected String file_url;
+ protected String download_url;
+ protected boolean downloaded;
+
+ /**
+ * Creates a new FeedFile object.
+ *
+ * @param file_url The location of the FeedFile. If this is null, the downloaded-attribute
+ * will automatically be set to false.
+ * @param download_url The location where the FeedFile can be downloaded.
+ * @param downloaded true if the FeedFile has been downloaded, false otherwise. This parameter
+ * will automatically be interpreted as false if the file_url is null.
+ */
+ public FeedFile(String file_url, String download_url, boolean downloaded) {
+ super();
+ this.file_url = file_url;
+ this.download_url = download_url;
+ this.downloaded = (file_url != null) && downloaded;
+ }
+
+ public FeedFile() {
+ this(null, null, false);
+ }
+
+ public abstract int getTypeAsInt();
+
+ /**
+ * Update this FeedFile's attributes with the attributes from another
+ * FeedFile. This method should only update attributes which where read from
+ * the feed.
+ */
+ public void updateFromOther(FeedFile other) {
+ super.updateFromOther(other);
+ this.download_url = other.download_url;
+ }
+
+ /**
+ * Compare's this FeedFile's attribute values with another FeedFile's
+ * attribute values. This method will only compare attributes which were
+ * read from the feed.
+ *
+ * @return true if attribute values are different, false otherwise
+ */
+ public boolean compareWithOther(FeedFile other) {
+ if (super.compareWithOther(other)) {
+ return true;
+ }
+ if (!download_url.equals(other.download_url)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the file exists at file_url.
+ */
+ public boolean fileExists() {
+ if (file_url == null) {
+ return false;
+ } else {
+ File f = new File(file_url);
+ return f.exists();
+ }
+ }
+
+ public String getFile_url() {
+ return file_url;
+ }
+
+ /**
+ * Changes the file_url of this FeedFile. Setting this value to
+ * null will also set the downloaded-attribute to false.
+ */
+ public void setFile_url(String file_url) {
+ this.file_url = file_url;
+ if (file_url == null) {
+ downloaded = false;
+ }
+ }
+
+ public String getDownload_url() {
+ return download_url;
+ }
+
+ public void setDownload_url(String download_url) {
+ this.download_url = download_url;
+ }
+
+ public boolean isDownloaded() {
+ return downloaded;
+ }
+
+ public void setDownloaded(boolean downloaded) {
+ this.downloaded = downloaded;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java
new file mode 100644
index 000000000..c588f5e71
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedImage.java
@@ -0,0 +1,77 @@
+package de.danoeh.antennapod.feed;
+
+import android.net.Uri;
+
+import de.danoeh.antennapod.asynctask.PicassoImageResource;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+
+
+
+public class FeedImage extends FeedFile implements PicassoImageResource {
+ public static final int FEEDFILETYPE_FEEDIMAGE = 1;
+
+ protected String title;
+ protected FeedComponent owner;
+
+ public FeedImage(String download_url, String title) {
+ super(null, download_url, false);
+ this.download_url = download_url;
+ this.title = title;
+ }
+
+ public FeedImage(long id, String title, String file_url,
+ String download_url, boolean downloaded) {
+ super(file_url, download_url, downloaded);
+ this.id = id;
+ this.title = title;
+ }
+
+ @Override
+ public String getHumanReadableIdentifier() {
+ if (owner != null && owner.getHumanReadableIdentifier() != null) {
+ return owner.getHumanReadableIdentifier();
+ } else {
+ return download_url;
+ }
+ }
+
+ @Override
+ public int getTypeAsInt() {
+ return FEEDFILETYPE_FEEDIMAGE;
+ }
+
+ public FeedImage() {
+ super();
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public FeedComponent getOwner() {
+ return owner;
+ }
+
+ public void setOwner(FeedComponent owner) {
+ this.owner = owner;
+ }
+
+ @Override
+ public Uri getImageUri() {
+ if (file_url != null && downloaded) {
+ return Uri.fromFile(new File(file_url));
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java
new file mode 100644
index 000000000..78091ea33
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedItem.java
@@ -0,0 +1,333 @@
+package de.danoeh.antennapod.feed;
+
+import android.net.Uri;
+
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.asynctask.PicassoImageResource;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.ShownotesProvider;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+import de.danoeh.antennapod.util.flattr.FlattrThing;
+
+import java.io.InputStream;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+/**
+ * Data Object for a XML message
+ *
+ * @author daniel
+ */
+public class FeedItem extends FeedComponent implements ShownotesProvider, FlattrThing, PicassoImageResource {
+
+ /**
+ * The id/guid that can be found in the rss/atom feed. Might not be set.
+ */
+ private String itemIdentifier;
+ private String title;
+ /**
+ * The description of a feeditem.
+ */
+ private String description;
+ /**
+ * The content of the content-encoded tag of a feeditem.
+ */
+ private String contentEncoded;
+
+ private String link;
+ private Date pubDate;
+ private FeedMedia media;
+
+ private Feed feed;
+ private long feedId;
+
+ private boolean read;
+ private String paymentLink;
+ private FlattrStatus flattrStatus;
+ private List<Chapter> chapters;
+ private FeedImage image;
+
+ public FeedItem() {
+ this.read = true;
+ this.flattrStatus = new FlattrStatus();
+ }
+
+ /**
+ * This constructor should be used for creating test objects.
+ */
+ public FeedItem(long id, String title, String itemIdentifier, String link, Date pubDate, boolean read, Feed feed) {
+ this.id = id;
+ this.title = title;
+ this.itemIdentifier = itemIdentifier;
+ this.link = link;
+ this.pubDate = (pubDate != null) ? (Date) pubDate.clone() : null;
+ this.read = read;
+ this.feed = feed;
+ this.flattrStatus = new FlattrStatus();
+ }
+
+ public void updateFromOther(FeedItem other) {
+ super.updateFromOther(other);
+ if (other.title != null) {
+ title = other.title;
+ }
+ if (other.getDescription() != null) {
+ description = other.getDescription();
+ }
+ if (other.getContentEncoded() != null) {
+ contentEncoded = other.contentEncoded;
+ }
+ if (other.link != null) {
+ link = other.link;
+ }
+ if (other.pubDate != null && other.pubDate != pubDate) {
+ pubDate = other.pubDate;
+ }
+ if (other.media != null) {
+ if (media == null) {
+ setMedia(other.media);
+ } else if (media.compareWithOther(other)) {
+ media.updateFromOther(other);
+ }
+ }
+ if (other.paymentLink != null) {
+ paymentLink = other.paymentLink;
+ }
+ if (other.chapters != null) {
+ if (chapters == null) {
+ chapters = other.chapters;
+ }
+ }
+ if (image == null) {
+ image = other.image;
+ }
+ }
+
+ /**
+ * Returns the value that uniquely identifies this FeedItem. If the
+ * itemIdentifier attribute is not null, it will be returned. Else it will
+ * try to return the title. If the title is not given, it will use the link
+ * of the entry.
+ */
+ public String getIdentifyingValue() {
+ if (itemIdentifier != null && !itemIdentifier.isEmpty()) {
+ return itemIdentifier;
+ } else if (title != null && !title.isEmpty()) {
+ return title;
+ } else {
+ return link;
+ }
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getLink() {
+ return link;
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ public Date getPubDate() {
+ if (pubDate != null) {
+ return (Date) pubDate.clone();
+ } else {
+ return null;
+ }
+ }
+
+ public void setPubDate(Date pubDate) {
+ if (pubDate != null) {
+ this.pubDate = (Date) pubDate.clone();
+ } else {
+ this.pubDate = null;
+ }
+ }
+
+ public FeedMedia getMedia() {
+ return media;
+ }
+
+ /**
+ * Sets the media object of this FeedItem. If the given
+ * FeedMedia object is not null, it's 'item'-attribute value
+ * will also be set to this item.
+ */
+ public void setMedia(FeedMedia media) {
+ this.media = media;
+ if (media != null && media.getItem() != this) {
+ media.setItem(this);
+ }
+ }
+
+ public Feed getFeed() {
+ return feed;
+ }
+
+ public void setFeed(Feed feed) {
+ this.feed = feed;
+ }
+
+ public boolean isRead() {
+ return read || isInProgress();
+ }
+
+ public void setRead(boolean read) {
+ this.read = read;
+ }
+
+ private boolean isInProgress() {
+ return (media != null && media.isInProgress());
+ }
+
+ public String getContentEncoded() {
+ return contentEncoded;
+ }
+
+ public void setContentEncoded(String contentEncoded) {
+ this.contentEncoded = contentEncoded;
+ }
+
+ public void setFlattrStatus(FlattrStatus status) {
+ this.flattrStatus = status;
+ }
+
+ public FlattrStatus getFlattrStatus() {
+ return flattrStatus;
+ }
+
+ public String getPaymentLink() {
+ return paymentLink;
+ }
+
+ public void setPaymentLink(String paymentLink) {
+ this.paymentLink = paymentLink;
+ }
+
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+ public void setChapters(List<Chapter> chapters) {
+ this.chapters = chapters;
+ }
+
+ public String getItemIdentifier() {
+ return itemIdentifier;
+ }
+
+ public void setItemIdentifier(String itemIdentifier) {
+ this.itemIdentifier = itemIdentifier;
+ }
+
+ public boolean hasMedia() {
+ return media != null;
+ }
+
+ private boolean isPlaying() {
+ if (media != null) {
+ return media.isPlaying();
+ }
+ return false;
+ }
+
+ @Override
+ public Callable<String> loadShownotes() {
+ return new Callable<String>() {
+ @Override
+ public String call() throws Exception {
+
+ if (contentEncoded == null || description == null) {
+ DBReader.loadExtraInformationOfFeedItem(PodcastApp.getInstance(), FeedItem.this);
+
+ }
+ return (contentEncoded != null) ? contentEncoded : description;
+ }
+ };
+ }
+
+ @Override
+ public Uri getImageUri() {
+ if (hasMedia()) {
+ return media.getImageUri();
+ } else if (feed != null) {
+ return feed.getImageUri();
+ } else {
+ return null;
+ }
+ }
+
+ public enum State {
+ NEW, IN_PROGRESS, READ, PLAYING
+ }
+
+ public State getState() {
+ if (hasMedia()) {
+ if (isPlaying()) {
+ return State.PLAYING;
+ }
+ if (isInProgress()) {
+ return State.IN_PROGRESS;
+ }
+ }
+ return (isRead() ? State.READ : State.NEW);
+ }
+
+ public long getFeedId() {
+ return feedId;
+ }
+
+ public void setFeedId(long feedId) {
+ this.feedId = feedId;
+ }
+
+ /**
+ * Returns the image of this item or the image of the feed if this item does
+ * not have its own image.
+ */
+ public FeedImage getImage() {
+ return (hasItemImage()) ? image : feed.getImage();
+ }
+
+ public void setImage(FeedImage image) {
+ this.image = image;
+ if (image != null) {
+ image.setOwner(this);
+ }
+ }
+
+ /**
+ * Returns true if this FeedItem has its own image, false otherwise.
+ */
+ public boolean hasItemImage() {
+ return image != null;
+ }
+
+ /**
+ * Returns true if this FeedItem has its own image and the image has been downloaded.
+ */
+ public boolean hasItemImageDownloaded() {
+ return image != null && image.isDownloaded();
+ }
+
+ @Override
+ public String getHumanReadableIdentifier() {
+ return title;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java
new file mode 100644
index 000000000..9298ebe8a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedMedia.java
@@ -0,0 +1,411 @@
+package de.danoeh.antennapod.feed;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.ChapterUtils;
+import de.danoeh.antennapod.util.playback.Playable;
+
+public class FeedMedia extends FeedFile implements Playable {
+ private static final String TAG = "FeedMedia";
+
+ public static final int FEEDFILETYPE_FEEDMEDIA = 2;
+ public static final int PLAYABLE_TYPE_FEEDMEDIA = 1;
+
+ public static final String PREF_MEDIA_ID = "FeedMedia.PrefMediaId";
+ public static final String PREF_FEED_ID = "FeedMedia.PrefFeedId";
+
+ private int duration;
+ private int position; // Current position in file
+ private int played_duration; // How many ms of this file have been played (for autoflattring)
+ private long size; // File size in Byte
+ private String mime_type;
+ private volatile FeedItem item;
+ private Date playbackCompletionDate;
+
+ /* Used for loading item when restoring from parcel. */
+ private long itemID;
+
+ public FeedMedia(FeedItem i, String download_url, long size,
+ String mime_type) {
+ super(null, download_url, false);
+ this.item = i;
+ this.size = size;
+ this.mime_type = mime_type;
+ }
+
+ public FeedMedia(long id, FeedItem item, int duration, int position,
+ long size, String mime_type, String file_url, String download_url,
+ boolean downloaded, Date playbackCompletionDate, int played_duration) {
+ super(file_url, download_url, downloaded);
+ this.id = id;
+ this.item = item;
+ this.duration = duration;
+ this.position = position;
+ this.played_duration = played_duration;
+ this.size = size;
+ this.mime_type = mime_type;
+ this.playbackCompletionDate = playbackCompletionDate == null
+ ? null : (Date) playbackCompletionDate.clone();
+ }
+
+ public FeedMedia(long id, FeedItem item) {
+ super();
+ this.id = id;
+ this.item = item;
+ }
+
+ @Override
+ public String getHumanReadableIdentifier() {
+ if (item != null && item.getTitle() != null) {
+ return item.getTitle();
+ } else {
+ return download_url;
+ }
+ }
+
+ /**
+ * Uses mimetype to determine the type of media.
+ */
+ public MediaType getMediaType() {
+ if (mime_type == null || mime_type.isEmpty()) {
+ return MediaType.UNKNOWN;
+ } else {
+ if (mime_type.startsWith("audio")) {
+ return MediaType.AUDIO;
+ } else if (mime_type.startsWith("video")) {
+ return MediaType.VIDEO;
+ } else if (mime_type.equals("application/ogg")) {
+ return MediaType.AUDIO;
+ }
+ }
+ return MediaType.UNKNOWN;
+ }
+
+ public void updateFromOther(FeedMedia other) {
+ super.updateFromOther(other);
+ if (other.size > 0) {
+ size = other.size;
+ }
+ if (other.mime_type != null) {
+ mime_type = other.mime_type;
+ }
+ }
+
+ public boolean compareWithOther(FeedMedia other) {
+ if (super.compareWithOther(other)) {
+ return true;
+ }
+ if (other.mime_type != null) {
+ if (mime_type == null || !mime_type.equals(other.mime_type)) {
+ return true;
+ }
+ }
+ if (other.size > 0 && other.size != size) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Reads playback preferences to determine whether this FeedMedia object is
+ * currently being played.
+ */
+ public boolean isPlaying() {
+ return PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA
+ && PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == id;
+ }
+
+ @Override
+ public int getTypeAsInt() {
+ return FEEDFILETYPE_FEEDMEDIA;
+ }
+
+ public int getDuration() {
+ return duration;
+ }
+
+ public void setDuration(int duration) {
+ this.duration = duration;
+ }
+
+ public int getPlayedDuration() {
+ return played_duration;
+ }
+
+ public void setPlayedDuration(int played_duration) {
+ this.played_duration = played_duration;
+ }
+
+ public int getPosition() {
+ return position;
+ }
+
+ public void setPosition(int position) {
+ this.position = position;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ public String getMime_type() {
+ return mime_type;
+ }
+
+ public void setMime_type(String mime_type) {
+ this.mime_type = mime_type;
+ }
+
+ public FeedItem getItem() {
+ return item;
+ }
+
+ /**
+ * Sets the item object of this FeedMedia. If the given
+ * FeedItem object is not null, it's 'media'-attribute value
+ * will also be set to this media object.
+ */
+ public void setItem(FeedItem item) {
+ this.item = item;
+ if (item != null && item.getMedia() != this) {
+ item.setMedia(this);
+ }
+ }
+
+ public Date getPlaybackCompletionDate() {
+ return playbackCompletionDate == null
+ ? null : (Date) playbackCompletionDate.clone();
+ }
+
+ public void setPlaybackCompletionDate(Date playbackCompletionDate) {
+ this.playbackCompletionDate = playbackCompletionDate == null
+ ? null : (Date) playbackCompletionDate.clone();
+ }
+
+ public boolean isInProgress() {
+ return (this.position > 0);
+ }
+
+ public FeedImage getImage() {
+ if (item != null) {
+ return (item.hasItemImageDownloaded()) ? item.getImage() : item.getFeed().getImage();
+ }
+ return null;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeLong(id);
+ dest.writeLong(item.getId());
+
+ dest.writeInt(duration);
+ dest.writeInt(position);
+ dest.writeLong(size);
+ dest.writeString(mime_type);
+ dest.writeString(file_url);
+ dest.writeString(download_url);
+ dest.writeByte((byte) ((downloaded) ? 1 : 0));
+ dest.writeLong((playbackCompletionDate != null) ? playbackCompletionDate.getTime() : 0);
+ dest.writeInt(played_duration);
+ }
+
+ @Override
+ public void writeToPreferences(Editor prefEditor) {
+ prefEditor.putLong(PREF_FEED_ID, item.getFeed().getId());
+ prefEditor.putLong(PREF_MEDIA_ID, id);
+ }
+
+ @Override
+ public void loadMetadata() throws PlayableException {
+ if (item == null && itemID != 0) {
+ item = DBReader.getFeedItem(PodcastApp.getInstance(), itemID);
+ }
+ }
+
+ @Override
+ public void loadChapterMarks() {
+ if (getChapters() == null && !localFileAvailable()) {
+ ChapterUtils.loadChaptersFromStreamUrl(this);
+ if (getChapters() != null && item != null) {
+ DBWriter.setFeedItem(PodcastApp.getInstance(),
+ item);
+ }
+ }
+
+ }
+
+ @Override
+ public String getEpisodeTitle() {
+ if (item == null) {
+ return null;
+ }
+ if (getItem().getTitle() != null) {
+ return getItem().getTitle();
+ } else {
+ return getItem().getIdentifyingValue();
+ }
+ }
+
+ @Override
+ public List<Chapter> getChapters() {
+ if (item == null) {
+ return null;
+ }
+ return getItem().getChapters();
+ }
+
+ @Override
+ public String getWebsiteLink() {
+ if (item == null) {
+ return null;
+ }
+ return getItem().getLink();
+ }
+
+ @Override
+ public String getFeedTitle() {
+ if (item == null) {
+ return null;
+ }
+ return getItem().getFeed().getTitle();
+ }
+
+ @Override
+ public Object getIdentifier() {
+ return id;
+ }
+
+ @Override
+ public String getLocalMediaUrl() {
+ return file_url;
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return download_url;
+ }
+
+ @Override
+ public String getPaymentLink() {
+ if (item == null) {
+ return null;
+ }
+ return getItem().getPaymentLink();
+ }
+
+ @Override
+ public boolean localFileAvailable() {
+ return isDownloaded() && file_url != null;
+ }
+
+ @Override
+ public boolean streamAvailable() {
+ return download_url != null;
+ }
+
+ @Override
+ public void saveCurrentPosition(SharedPreferences pref, int newPosition) {
+ setPosition(newPosition);
+ DBWriter.setFeedMediaPlaybackInformation(PodcastApp.getInstance(), this);
+ }
+
+ @Override
+ public void onPlaybackStart() {
+ }
+
+ @Override
+ public void onPlaybackCompleted() {
+
+ }
+
+ @Override
+ public int getPlayableType() {
+ return PLAYABLE_TYPE_FEEDMEDIA;
+ }
+
+ @Override
+ public void setChapters(List<Chapter> chapters) {
+ getItem().setChapters(chapters);
+ }
+
+ @Override
+ public Callable<String> loadShownotes() {
+ return new Callable<String>() {
+ @Override
+ public String call() throws Exception {
+ if (item == null) {
+ item = DBReader.getFeedItem(PodcastApp.getInstance(), itemID);
+ }
+ if (item.getContentEncoded() == null || item.getDescription() == null) {
+ DBReader.loadExtraInformationOfFeedItem(PodcastApp.getInstance(), item);
+
+ }
+ return (item.getContentEncoded() != null) ? item.getContentEncoded() : item.getDescription();
+ }
+ };
+ }
+
+ public static final Parcelable.Creator<FeedMedia> CREATOR = new Parcelable.Creator<FeedMedia>() {
+ public FeedMedia createFromParcel(Parcel in) {
+ final long id = in.readLong();
+ final long itemID = in.readLong();
+ FeedMedia result = new FeedMedia(id, null, in.readInt(), in.readInt(), in.readLong(), in.readString(), in.readString(),
+ in.readString(), in.readByte() != 0, new Date(in.readLong()), in.readInt());
+ result.itemID = itemID;
+ return result;
+ }
+
+ public FeedMedia[] newArray(int size) {
+ return new FeedMedia[size];
+ }
+ };
+
+ @Override
+ public Uri getImageUri() {
+ final Uri feedImgUri = getFeedImageUri();
+
+ if (localFileAvailable()) {
+ Uri.Builder builder = new Uri.Builder();
+ builder.scheme(SCHEME_MEDIA)
+ .encodedPath(getLocalMediaUrl());
+ if (feedImgUri != null) {
+ builder.appendQueryParameter(PARAM_FALLBACK, feedImgUri.toString());
+ }
+ return builder.build();
+ } else {
+ return feedImgUri;
+ }
+ }
+
+ private Uri getFeedImageUri() {
+ if (item != null && item.getFeed() != null) {
+ return item.getFeed().getImageUri();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java b/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java
new file mode 100644
index 000000000..29bc5ef0c
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/FeedPreferences.java
@@ -0,0 +1,89 @@
+package de.danoeh.antennapod.feed;
+
+import android.content.Context;
+import de.danoeh.antennapod.storage.DBWriter;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Contains preferences for a single feed.
+ */
+public class FeedPreferences {
+
+ private long feedID;
+ private boolean autoDownload;
+ private String username;
+ private String password;
+
+ public FeedPreferences(long feedID, boolean autoDownload, String username, String password) {
+ this.feedID = feedID;
+ this.autoDownload = autoDownload;
+ this.username = username;
+ this.password = password;
+ }
+
+
+ /**
+ * Compare another FeedPreferences with this one. The feedID and autoDownload attribute are excluded from the
+ * comparison.
+ *
+ * @return True if the two objects are different.
+ */
+ public boolean compareWithOther(FeedPreferences other) {
+ if (other == null)
+ return true;
+ if (!StringUtils.equals(username, other.username)) {
+ return true;
+ }
+ if (!StringUtils.equals(password, other.password)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Update this FeedPreferences object from another one. The feedID and autoDownload attributes are excluded
+ * from the update.
+ */
+ public void updateFromOther(FeedPreferences other) {
+ if (other == null)
+ return;
+ this.username = other.username;
+ this.password = other.password;
+ }
+
+ public long getFeedID() {
+ return feedID;
+ }
+
+ public void setFeedID(long feedID) {
+ this.feedID = feedID;
+ }
+
+ public boolean getAutoDownload() {
+ return autoDownload;
+ }
+
+ public void setAutoDownload(boolean autoDownload) {
+ this.autoDownload = autoDownload;
+ }
+
+ public void save(Context context) {
+ DBWriter.setFeedPreferences(context, this);
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java b/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java
new file mode 100644
index 000000000..6dde7854e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/ID3Chapter.java
@@ -0,0 +1,36 @@
+package de.danoeh.antennapod.feed;
+
+public class ID3Chapter extends Chapter {
+ public static final int CHAPTERTYPE_ID3CHAPTER = 2;
+
+ /**
+ * Identifies the chapter in its ID3 tag. This attribute does not have to be
+ * store in the DB and is only used for parsing.
+ */
+ private String id3ID;
+
+ public ID3Chapter(String id3ID, long start) {
+ super(start);
+ this.id3ID = id3ID;
+ }
+
+ public ID3Chapter(long start, String title, FeedItem item, String link) {
+ super(start, title, item, link);
+ }
+
+ @Override
+ public String toString() {
+ return "ID3Chapter [id3ID=" + id3ID + ", title=" + title + ", start="
+ + start + ", url=" + link + "]";
+ }
+
+ @Override
+ public int getChapterType() {
+ return CHAPTERTYPE_ID3CHAPTER;
+ }
+
+ public String getId3ID() {
+ return id3ID;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java b/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java
new file mode 100644
index 000000000..324d0a221
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/MediaType.java
@@ -0,0 +1,5 @@
+package de.danoeh.antennapod.feed;
+
+public enum MediaType {
+ AUDIO, VIDEO, UNKNOWN
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java b/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java
new file mode 100644
index 000000000..1cba389ec
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/SearchResult.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.feed;
+
+public class SearchResult {
+ private FeedComponent component;
+ /** Additional information (e.g. where it was found) */
+ private String subtitle;
+ /** Higher value means more importance */
+ private int value;
+
+ public SearchResult(FeedComponent component, int value, String subtitle) {
+ super();
+ this.component = component;
+ this.value = value;
+ this.subtitle = subtitle;
+ }
+
+ public FeedComponent getComponent() {
+ return component;
+ }
+
+ public String getSubtitle() {
+ return subtitle;
+ }
+
+ public void setSubtitle(String subtitle) {
+ this.subtitle = subtitle;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java b/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java
new file mode 100644
index 000000000..3dab1b74d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/SimpleChapter.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.feed;
+
+public class SimpleChapter extends Chapter {
+ public static final int CHAPTERTYPE_SIMPLECHAPTER = 0;
+
+ public SimpleChapter(long start, String title, FeedItem item, String link) {
+ super(start, title, item, link);
+ }
+
+ @Override
+ public int getChapterType() {
+ return CHAPTERTYPE_SIMPLECHAPTER;
+ }
+
+ public void updateFromOther(SimpleChapter other) {
+ super.updateFromOther(other);
+ start = other.start;
+ if (other.title != null) {
+ title = other.title;
+ }
+ if (other.link != null) {
+ link = other.link;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java b/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java
new file mode 100644
index 000000000..59844ae1f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/feed/VorbisCommentChapter.java
@@ -0,0 +1,109 @@
+package de.danoeh.antennapod.feed;
+
+import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentReaderException;
+
+import java.util.concurrent.TimeUnit;
+
+public class VorbisCommentChapter extends Chapter {
+ public static final int CHAPTERTYPE_VORBISCOMMENT_CHAPTER = 3;
+
+ private static final int CHAPTERXXX_LENGTH = "chapterxxx".length();
+
+ private int vorbisCommentId;
+
+ public VorbisCommentChapter(int vorbisCommentId) {
+ this.vorbisCommentId = vorbisCommentId;
+ }
+
+ public VorbisCommentChapter(long start, String title, FeedItem item,
+ String link) {
+ super(start, title, item, link);
+ }
+
+ @Override
+ public String toString() {
+ return "VorbisCommentChapter [id=" + id + ", title=" + title
+ + ", link=" + link + ", start=" + start + "]";
+ }
+
+ public static long getStartTimeFromValue(String value)
+ throws VorbisCommentReaderException {
+ String[] parts = value.split(":");
+ if (parts.length >= 3) {
+ try {
+ long hours = TimeUnit.MILLISECONDS.convert(
+ Long.parseLong(parts[0]), TimeUnit.HOURS);
+ long minutes = TimeUnit.MILLISECONDS.convert(
+ Long.parseLong(parts[1]), TimeUnit.MINUTES);
+ if (parts[2].contains("-->")) {
+ parts[2] = parts[2].substring(0, parts[2].indexOf("-->"));
+ }
+ long seconds = TimeUnit.MILLISECONDS.convert(
+ ((long) Float.parseFloat(parts[2])), TimeUnit.SECONDS);
+ return hours + minutes + seconds;
+ } catch (NumberFormatException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ } else {
+ throw new VorbisCommentReaderException("Invalid time string");
+ }
+ }
+
+ /**
+ * Return the id of a vorbiscomment chapter from a string like CHAPTERxxx*
+ *
+ * @return the id of the chapter key or -1 if the id couldn't be read.
+ * @throws VorbisCommentReaderException
+ * */
+ public static int getIDFromKey(String key)
+ throws VorbisCommentReaderException {
+ if (key.length() >= CHAPTERXXX_LENGTH) { // >= CHAPTERxxx
+ try {
+ String strId = key.substring(8, 10);
+ return Integer.parseInt(strId);
+ } catch (NumberFormatException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ }
+ throw new VorbisCommentReaderException("key is too short (" + key + ")");
+ }
+
+ /**
+ * Get the string that comes after 'CHAPTERxxx', for example 'name' or
+ * 'url'.
+ */
+ public static String getAttributeTypeFromKey(String key) {
+ if (key.length() > CHAPTERXXX_LENGTH) {
+ return key.substring(CHAPTERXXX_LENGTH, key.length());
+ }
+ return null;
+ }
+
+ @Override
+ public int getChapterType() {
+ return CHAPTERTYPE_VORBISCOMMENT_CHAPTER;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ public void setStart(long start) {
+ this.start = start;
+ }
+
+ public int getVorbisCommentId() {
+ return vorbisCommentId;
+ }
+
+ public void setVorbisCommentId(int vorbisCommentId) {
+ this.vorbisCommentId = vorbisCommentId;
+ }
+
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java
new file mode 100644
index 000000000..f5ae5a777
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java
@@ -0,0 +1,76 @@
+package de.danoeh.antennapod.fragment;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.DefaultOnlineFeedViewActivity;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.activity.OnlineFeedViewActivity;
+import de.danoeh.antennapod.activity.OpmlImportFromPathActivity;
+import de.danoeh.antennapod.fragment.gpodnet.GpodnetMainFragment;
+
+/**
+ * Provides actions for adding new podcast subscriptions
+ */
+public class AddFeedFragment extends Fragment {
+ private static final String TAG = "AddFeedFragment";
+
+ /**
+ * Preset value for url text field.
+ */
+ public static final String ARG_FEED_URL = "feedurl";
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View root = inflater.inflate(R.layout.addfeed, container, false);
+
+ final EditText etxtFeedurl = (EditText) root.findViewById(R.id.etxtFeedurl);
+
+ Bundle args = getArguments();
+ if (args != null && args.getString(ARG_FEED_URL) != null) {
+ etxtFeedurl.setText(args.getString(ARG_FEED_URL));
+ }
+
+ Button butBrowserGpoddernet = (Button) root.findViewById(R.id.butBrowseGpoddernet);
+ Button butOpmlImport = (Button) root.findViewById(R.id.butOpmlImport);
+ Button butConfirm = (Button) root.findViewById(R.id.butConfirm);
+
+ final MainActivity activity = (MainActivity) getActivity();
+ activity.getMainActivtyActionBar().setTitle(R.string.add_feed_label);
+
+ butBrowserGpoddernet.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ activity.loadChildFragment(new GpodnetMainFragment());
+ }
+ });
+
+ butOpmlImport.setOnClickListener(new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(getActivity(),
+ OpmlImportFromPathActivity.class));
+ }
+ });
+
+ butConfirm.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(getActivity(), DefaultOnlineFeedViewActivity.class);
+ intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, etxtFeedurl.getText().toString());
+ intent.putExtra(OnlineFeedViewActivity.ARG_TITLE, getString(R.string.add_feed_label));
+ startActivity(intent);
+ }
+ });
+
+ return root;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java
new file mode 100644
index 000000000..082fe93fc
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java
@@ -0,0 +1,196 @@
+package de.danoeh.antennapod.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.ListFragment;
+import android.view.View;
+import android.widget.ListView;
+import de.danoeh.antennapod.adapter.DownloadedEpisodesListAdapter;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.QueueAccess;
+
+import java.util.List;
+
+/**
+ * Displays all running downloads and provides a button to delete them
+ */
+public class CompletedDownloadsFragment extends ListFragment {
+ private static final int EVENTS =
+ EventDistributor.DOWNLOAD_HANDLED |
+ EventDistributor.DOWNLOADLOG_UPDATE |
+ EventDistributor.QUEUE_UPDATE |
+ EventDistributor.UNREAD_ITEMS_UPDATE;
+
+ private List<FeedItem> items;
+ private QueueAccess queue;
+ private DownloadedEpisodesListAdapter listAdapter;
+
+ private boolean viewCreated = false;
+ private boolean itemsLoaded = false;
+
+ private FeedItemDialog feedItemDialog;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ startItemLoader();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ stopItemLoader();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ listAdapter = null;
+ viewCreated = false;
+ feedItemDialog = null;
+ stopItemLoader();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ if (viewCreated && itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ viewCreated = true;
+ if (itemsLoaded && getActivity() != null) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onListItemClick(ListView l, View v, int position, long id) {
+ super.onListItemClick(l, v, position, id);
+ FeedItem item = listAdapter.getItem(position - l.getHeaderViewsCount());
+ if (item != null) {
+ feedItemDialog = FeedItemDialog.newInstance(getActivity(), item, queue);
+ feedItemDialog.show();
+ }
+
+ }
+
+ private void onFragmentLoaded() {
+ if (listAdapter == null) {
+ listAdapter = new DownloadedEpisodesListAdapter(getActivity(), itemAccess);
+ setListAdapter(listAdapter);
+ }
+ setListShown(true);
+ listAdapter.notifyDataSetChanged();
+ if (feedItemDialog != null) {
+ boolean res = feedItemDialog.updateContent(queue, items);
+ if (!res && feedItemDialog.isShowing()) {
+ feedItemDialog.dismiss();
+ }
+ }
+ }
+
+ private DownloadedEpisodesListAdapter.ItemAccess itemAccess = new DownloadedEpisodesListAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ return (items != null) ? items.size() : 0;
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ return (items != null) ? items.get(position) : null;
+ }
+
+ @Override
+ public void onFeedItemSecondaryAction(FeedItem item) {
+ DBWriter.deleteFeedMediaOfItem(getActivity(), item.getMedia().getId());
+ }
+ };
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EventDistributor.DOWNLOAD_QUEUED) != 0) {
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ } else if ((arg & EVENTS) != 0) {
+ startItemLoader();
+ }
+ }
+ };
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute();
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Void, Void, Object[]> {
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (!itemsLoaded && viewCreated) {
+ setListShown(false);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object[] results) {
+ super.onPostExecute(results);
+ if (results != null) {
+ items = (List<FeedItem>) results[0];
+ queue = (QueueAccess) results[1];
+ itemsLoaded = true;
+ if (viewCreated && getActivity() != null) {
+ onFragmentLoaded();
+ }
+ }
+ }
+
+ @Override
+ protected Object[] doInBackground(Void... params) {
+ Context context = getActivity();
+ if (context != null) {
+ return new Object[]{DBReader.getDownloadedItems(context),
+ QueueAccess.IDListAccess(DBReader.getQueueIDList(context))};
+ }
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java
new file mode 100644
index 000000000..ffce518bf
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java
@@ -0,0 +1,105 @@
+package de.danoeh.antennapod.fragment;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.AudioplayerActivity.AudioplayerContentFragment;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.util.playback.Playable;
+
+/**
+ * Displays the cover and the title of a FeedItem.
+ */
+public class CoverFragment extends Fragment implements
+ AudioplayerContentFragment {
+ private static final String TAG = "CoverFragment";
+ private static final String ARG_PLAYABLE = "arg.playable";
+
+ private Playable media;
+
+ private ImageView imgvCover;
+
+ private boolean viewCreated = false;
+
+ public static CoverFragment newInstance(Playable item) {
+ CoverFragment f = new CoverFragment();
+ if (item != null) {
+ Bundle args = new Bundle();
+ args.putParcelable(ARG_PLAYABLE, item);
+ f.setArguments(args);
+ }
+ return f;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ Bundle args = getArguments();
+ if (args != null) {
+ media = args.getParcelable(ARG_PLAYABLE);
+ } else {
+ Log.e(TAG, TAG + " was called with invalid arguments");
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.cover_fragment, container, false);
+ imgvCover = (ImageView) root.findViewById(R.id.imgvCover);
+ viewCreated = true;
+ return root;
+ }
+
+ private void loadMediaInfo() {
+ if (media != null) {
+ imgvCover.post(new Runnable() {
+
+ @Override
+ public void run() {
+ Context c = getActivity();
+ if (c != null) {
+ PicassoProvider.getMediaMetadataPicassoInstance(c)
+ .load(media.getImageUri())
+ .into(imgvCover);
+ }
+ }
+ });
+ } else {
+ Log.w(TAG, "loadMediaInfo was called while media was null");
+ }
+ }
+
+ @Override
+ public void onStart() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "On Start");
+ super.onStart();
+ if (media != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading media info");
+ loadMediaInfo();
+ } else {
+ Log.w(TAG, "Unable to load media info: media was null");
+ }
+ }
+
+ @Override
+ public void onDataSetChanged(Playable media) {
+ this.media = media;
+ if (viewCreated) {
+ loadMediaInfo();
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java
new file mode 100644
index 000000000..d81ba4b86
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java
@@ -0,0 +1,121 @@
+package de.danoeh.antennapod.fragment;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.ListFragment;
+import android.view.View;
+import de.danoeh.antennapod.adapter.DownloadLogAdapter;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.storage.DBReader;
+
+import java.util.List;
+
+/**
+ * Shows the download log
+ */
+public class DownloadLogFragment extends ListFragment {
+
+ private List<DownloadStatus> downloadLog;
+ private DownloadLogAdapter adapter;
+
+ private boolean viewsCreated = false;
+ private boolean itemsLoaded = false;
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ startItemLoader();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ viewsCreated = true;
+ if (itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ private void onFragmentLoaded() {
+ if (adapter == null) {
+ adapter = new DownloadLogAdapter(getActivity(), itemAccess);
+ setListAdapter(adapter);
+ }
+ setListShown(true);
+ adapter.notifyDataSetChanged();
+
+ }
+
+ private DownloadLogAdapter.ItemAccess itemAccess = new DownloadLogAdapter.ItemAccess() {
+
+ @Override
+ public int getCount() {
+ return (downloadLog != null) ? downloadLog.size() : 0;
+ }
+
+ @Override
+ public DownloadStatus getItem(int position) {
+ return (downloadLog != null) ? downloadLog.get(position) : null;
+ }
+ };
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EventDistributor.DOWNLOADLOG_UPDATE) != 0) {
+ startItemLoader();
+ }
+ }
+ };
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute();
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Void, Void, List<DownloadStatus>> {
+
+ @Override
+ protected void onPostExecute(List<DownloadStatus> downloadStatuses) {
+ super.onPostExecute(downloadStatuses);
+ if (downloadStatuses != null) {
+ downloadLog = downloadStatuses;
+ itemsLoaded = true;
+ if (viewsCreated) {
+ onFragmentLoaded();
+ }
+ }
+ }
+
+ @Override
+ protected List<DownloadStatus> doInBackground(Void... params) {
+ Context context = getActivity();
+ if (context != null) {
+ return DBReader.getDownloadLog(context);
+ }
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java
new file mode 100644
index 000000000..5a71cb36b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadsFragment.java
@@ -0,0 +1,145 @@
+package de.danoeh.antennapod.fragment;
+
+import android.app.Activity;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentPagerAdapter;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v4.view.ViewPager;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+
+/**
+ * Shows the CompletedDownloadsFragment and the RunningDownloadsFragment
+ */
+public class DownloadsFragment extends Fragment {
+
+ public static final String ARG_SELECTED_TAB = "selected_tab";
+
+ public static final int POS_RUNNING = 0;
+ public static final int POS_COMPLETED = 1;
+ public static final int POS_LOG = 2;
+
+ private ViewPager pager;
+ private MainActivity activity;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View root = inflater.inflate(R.layout.pager_fragment, container, false);
+ pager = (ViewPager) root.findViewById(R.id.pager);
+ DownloadsPagerAdapter pagerAdapter = new DownloadsPagerAdapter(getChildFragmentManager(), getResources());
+ pager.setAdapter(pagerAdapter);
+ final ActionBar actionBar = activity.getMainActivtyActionBar();
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
+ ActionBar.TabListener tabListener = new ActionBar.TabListener() {
+ @Override
+ public void onTabSelected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+ pager.setCurrentItem(tab.getPosition());
+ }
+
+ @Override
+ public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+
+ }
+
+ @Override
+ public void onTabReselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+
+ }
+ };
+ actionBar.removeAllTabs();
+ actionBar.addTab(actionBar.newTab()
+ .setText(R.string.downloads_running_label)
+ .setTabListener(tabListener));
+ actionBar.addTab(actionBar.newTab()
+ .setText(R.string.downloads_completed_label)
+ .setTabListener(tabListener));
+ actionBar.addTab(actionBar.newTab()
+ .setText(R.string.downloads_log_label)
+ .setTabListener(tabListener));
+
+ pager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+ actionBar.setSelectedNavigationItem(position);
+ }
+ });
+ return root;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ if (getArguments() != null) {
+ int tab = getArguments().getInt(ARG_SELECTED_TAB);
+ pager.setCurrentItem(tab, false);
+ }
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.activity = (MainActivity) activity;
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ activity.getMainActivtyActionBar().removeAllTabs();
+ activity.getMainActivtyActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ }
+
+ public class DownloadsPagerAdapter extends FragmentPagerAdapter {
+
+
+
+
+ Resources resources;
+
+ public DownloadsPagerAdapter(FragmentManager fm, Resources resources) {
+ super(fm);
+ this.resources = resources;
+ }
+
+ @Override
+ public Fragment getItem(int position) {
+ switch (position) {
+ case POS_RUNNING:
+ return new RunningDownloadsFragment();
+ case POS_COMPLETED:
+ return new CompletedDownloadsFragment();
+ case POS_LOG:
+ return new DownloadLogFragment();
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return 3;
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ switch (position) {
+ case POS_RUNNING:
+ return resources.getString(R.string.downloads_running_label);
+ case POS_COMPLETED:
+ return resources.getString(R.string.downloads_completed_label);
+ case POS_LOG:
+ return resources.getString(R.string.downloads_log_label);
+ default:
+ return super.getPageTitle(position);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java
new file mode 100644
index 000000000..985673dd3
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java
@@ -0,0 +1,238 @@
+package de.danoeh.antennapod.fragment;
+
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.playback.PlaybackController;
+
+/**
+ * Fragment which is supposed to be displayed outside of the MediaplayerActivity
+ * if the PlaybackService is running
+ */
+public class ExternalPlayerFragment extends Fragment {
+ private static final String TAG = "ExternalPlayerFragment";
+
+ private ViewGroup fragmentLayout;
+ private ImageView imgvCover;
+ private ViewGroup layoutInfo;
+ private TextView txtvTitle;
+ private ImageButton butPlay;
+
+ private PlaybackController controller;
+
+ public ExternalPlayerFragment() {
+ super();
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.external_player_fragment,
+ container, false);
+ fragmentLayout = (ViewGroup) root.findViewById(R.id.fragmentLayout);
+ imgvCover = (ImageView) root.findViewById(R.id.imgvCover);
+ layoutInfo = (ViewGroup) root.findViewById(R.id.layoutInfo);
+ txtvTitle = (TextView) root.findViewById(R.id.txtvTitle);
+ butPlay = (ImageButton) root.findViewById(R.id.butPlay);
+
+ layoutInfo.setOnClickListener(new OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "layoutInfo was clicked");
+
+ if (controller.getMedia() != null) {
+ startActivity(PlaybackService.getPlayerActivityIntent(
+ getActivity(), controller.getMedia()));
+ }
+ }
+ });
+ return root;
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ controller = setupPlaybackController();
+ butPlay.setOnClickListener(controller.newOnPlayButtonClickListener());
+ }
+
+ private PlaybackController setupPlaybackController() {
+ return new PlaybackController(getActivity(), true) {
+
+ @Override
+ public void setupGUI() {
+ }
+
+ @Override
+ public void onPositionObserverUpdate() {
+ }
+
+ @Override
+ public void onReloadNotification(int code) {
+ }
+
+ @Override
+ public void onBufferStart() {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void onBufferEnd() {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void onBufferUpdate(float progress) {
+ }
+
+ @Override
+ public void onSleepTimerUpdate() {
+ }
+
+ @Override
+ public void handleError(int code) {
+ }
+
+ @Override
+ public ImageButton getPlayButton() {
+ return butPlay;
+ }
+
+ @Override
+ public void postStatusMsg(int msg) {
+ }
+
+ @Override
+ public void clearStatusMsg() {
+ }
+
+ @Override
+ public boolean loadMediaInfo() {
+ ExternalPlayerFragment fragment = ExternalPlayerFragment.this;
+ if (fragment != null) {
+ return fragment.loadMediaInfo();
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void onAwaitingVideoSurface() {
+ }
+
+ @Override
+ public void onServiceQueried() {
+ }
+
+ @Override
+ public void onShutdownNotification() {
+ if (fragmentLayout != null) {
+ fragmentLayout.setVisibility(View.GONE);
+ }
+ controller = setupPlaybackController();
+ if (butPlay != null) {
+ butPlay.setOnClickListener(controller
+ .newOnPlayButtonClickListener());
+ }
+
+ }
+
+ @Override
+ public void onPlaybackEnd() {
+ if (fragmentLayout != null) {
+ fragmentLayout.setVisibility(View.GONE);
+ }
+ controller = setupPlaybackController();
+ if (butPlay != null) {
+ butPlay.setOnClickListener(controller
+ .newOnPlayButtonClickListener());
+ }
+ }
+
+ @Override
+ public void onPlaybackSpeedChange() {
+ // TODO Auto-generated method stub
+
+ }
+ };
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ controller.init();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Fragment is about to be destroyed");
+ if (controller != null) {
+ controller.release();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (controller != null) {
+ controller.pause();
+ }
+ }
+
+ private boolean loadMediaInfo() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading media info");
+ if (controller.serviceAvailable()) {
+ Playable media = controller.getMedia();
+ if (media != null) {
+ txtvTitle.setText(media.getEpisodeTitle());
+
+ PicassoProvider.getMediaMetadataPicassoInstance(getActivity())
+ .load(media.getImageUri())
+ .fit()
+ .into(imgvCover);
+
+ fragmentLayout.setVisibility(View.VISIBLE);
+ if (controller.isPlayingVideo()) {
+ butPlay.setVisibility(View.GONE);
+ } else {
+ butPlay.setVisibility(View.VISIBLE);
+ }
+ return true;
+ } else {
+ Log.w(TAG,
+ "loadMediaInfo was called while the media object of playbackService was null!");
+ return false;
+ }
+ } else {
+ Log.w(TAG,
+ "loadMediaInfo was called while playbackService was null!");
+ return false;
+ }
+ }
+
+ private String getPositionString(int position, int duration) {
+ return Converter.getDurationStringLong(position) + " / "
+ + Converter.getDurationStringLong(duration);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java
new file mode 100644
index 000000000..04c7fbf8e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemDescriptionFragment.java
@@ -0,0 +1,476 @@
+package de.danoeh.antennapod.fragment;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v7.app.ActionBarActivity;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebSettings.LayoutAlgorithm;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.Toast;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.ShareUtils;
+import de.danoeh.antennapod.util.ShownotesProvider;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.playback.PlaybackController;
+import de.danoeh.antennapod.util.playback.Timeline;
+
+/**
+ * Displays the description of a Playable object in a Webview.
+ */
+public class ItemDescriptionFragment extends Fragment {
+
+ private static final String TAG = "ItemDescriptionFragment";
+
+ private static final String PREF = "ItemDescriptionFragmentPrefs";
+ private static final String PREF_SCROLL_Y = "prefScrollY";
+ private static final String PREF_PLAYABLE_ID = "prefPlayableId";
+
+ private static final String ARG_PLAYABLE = "arg.playable";
+ private static final String ARG_FEEDITEM_ID = "arg.feeditem";
+
+ private static final String ARG_SAVE_STATE = "arg.saveState";
+ private static final String ARG_HIGHLIGHT_TIMECODES = "arg.highlightTimecodes";
+
+ private WebView webvDescription;
+
+ private ShownotesProvider shownotesProvider;
+ private Playable media;
+
+
+ private AsyncTask<Void, Void, Void> webViewLoader;
+
+ /**
+ * URL that was selected via long-press.
+ */
+ private String selectedURL;
+
+ /**
+ * True if Fragment should save its state (e.g. scrolling position) in a
+ * shared preference.
+ */
+ private boolean saveState;
+
+ /**
+ * True if Fragment should highlight timecodes (e.g. time codes in the HH:MM:SS format).
+ */
+ private boolean highlightTimecodes;
+
+ public static ItemDescriptionFragment newInstance(Playable media,
+ boolean saveState,
+ boolean highlightTimecodes) {
+ ItemDescriptionFragment f = new ItemDescriptionFragment();
+ Bundle args = new Bundle();
+ args.putParcelable(ARG_PLAYABLE, media);
+ args.putBoolean(ARG_SAVE_STATE, saveState);
+ args.putBoolean(ARG_HIGHLIGHT_TIMECODES, highlightTimecodes);
+ f.setArguments(args);
+ return f;
+ }
+
+ public static ItemDescriptionFragment newInstance(FeedItem item, boolean saveState, boolean highlightTimecodes) {
+ ItemDescriptionFragment f = new ItemDescriptionFragment();
+ Bundle args = new Bundle();
+ args.putLong(ARG_FEEDITEM_ID, item.getId());
+ args.putBoolean(ARG_SAVE_STATE, saveState);
+ args.putBoolean(ARG_HIGHLIGHT_TIMECODES, highlightTimecodes);
+ f.setArguments(args);
+ return f;
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating view");
+ webvDescription = new WebView(getActivity());
+ if (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) {
+ if (Build.VERSION.SDK_INT >= 11
+ && Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
+ webvDescription.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+ }
+ webvDescription.setBackgroundColor(getResources().getColor(
+ R.color.black));
+ }
+ webvDescription.getSettings().setUseWideViewPort(false);
+ webvDescription.getSettings().setLayoutAlgorithm(
+ LayoutAlgorithm.NARROW_COLUMNS);
+ webvDescription.getSettings().setLoadWithOverviewMode(true);
+ webvDescription.setOnLongClickListener(webViewLongClickListener);
+ webvDescription.setWebViewClient(new WebViewClient() {
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ if (Timeline.isTimecodeLink(url)) {
+ onTimecodeLinkSelected(url);
+ } else {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ try {
+ startActivity(intent);
+ } catch (ActivityNotFoundException e) {
+ e.printStackTrace();
+ return true;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ super.onPageFinished(view, url);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Page finished");
+ // Restoring the scroll position might not always work
+ view.postDelayed(new Runnable() {
+
+ @Override
+ public void run() {
+ restoreFromPreference();
+ }
+
+ }, 50);
+ }
+
+ });
+
+ registerForContextMenu(webvDescription);
+ return webvDescription;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Fragment attached");
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Fragment detached");
+ if (webViewLoader != null) {
+ webViewLoader.cancel(true);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Fragment destroyed");
+ if (webViewLoader != null) {
+ webViewLoader.cancel(true);
+ }
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating fragment");
+ Bundle args = getArguments();
+ saveState = args.getBoolean(ARG_SAVE_STATE, false);
+ highlightTimecodes = args.getBoolean(ARG_HIGHLIGHT_TIMECODES, false);
+
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ Bundle args = getArguments();
+ if (args.containsKey(ARG_PLAYABLE)) {
+ media = args.getParcelable(ARG_PLAYABLE);
+ shownotesProvider = media;
+ startLoader();
+ } else if (args.containsKey(ARG_FEEDITEM_ID)) {
+ AsyncTask<Void, Void, FeedItem> itemLoadTask = new AsyncTask<Void, Void, FeedItem>() {
+
+ @Override
+ protected FeedItem doInBackground(Void... voids) {
+ return DBReader.getFeedItem(getActivity(), getArguments().getLong(ARG_FEEDITEM_ID));
+ }
+
+ @Override
+ protected void onPostExecute(FeedItem feedItem) {
+ super.onPostExecute(feedItem);
+ shownotesProvider = feedItem;
+ startLoader();
+ }
+ };
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ itemLoadTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ itemLoadTask.execute();
+ }
+ }
+
+
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @SuppressLint("NewApi")
+ private void startLoader() {
+ webViewLoader = createLoader();
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ webViewLoader.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ webViewLoader.execute();
+ }
+ }
+
+ private View.OnLongClickListener webViewLongClickListener = new View.OnLongClickListener() {
+
+ @Override
+ public boolean onLongClick(View v) {
+ WebView.HitTestResult r = webvDescription.getHitTestResult();
+ if (r != null
+ && r.getType() == WebView.HitTestResult.SRC_ANCHOR_TYPE) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Link of webview was long-pressed. Extra: "
+ + r.getExtra()
+ );
+ selectedURL = r.getExtra();
+ webvDescription.showContextMenu();
+ return true;
+ }
+ selectedURL = null;
+ return false;
+ }
+ };
+
+ @SuppressWarnings("deprecation")
+ @SuppressLint("NewApi")
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ boolean handled = selectedURL != null;
+ if (selectedURL != null) {
+ switch (item.getItemId()) {
+ case R.id.open_in_browser_item:
+ Uri uri = Uri.parse(selectedURL);
+ getActivity()
+ .startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ break;
+ case R.id.share_url_item:
+ ShareUtils.shareLink(getActivity(), selectedURL);
+ break;
+ case R.id.copy_url_item:
+ if (android.os.Build.VERSION.SDK_INT >= 11) {
+ ClipData clipData = ClipData.newPlainText(selectedURL,
+ selectedURL);
+ android.content.ClipboardManager cm = (android.content.ClipboardManager) getActivity()
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ cm.setPrimaryClip(clipData);
+ } else {
+ android.text.ClipboardManager cm = (android.text.ClipboardManager) getActivity()
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ cm.setText(selectedURL);
+ }
+ Toast t = Toast.makeText(getActivity(),
+ R.string.copied_url_msg, Toast.LENGTH_SHORT);
+ t.show();
+ break;
+ case R.id.go_to_position_item:
+ if (Timeline.isTimecodeLink(selectedURL)) {
+ onTimecodeLinkSelected(selectedURL);
+ } else {
+ Log.e(TAG, "Selected go_to_position_item, but URL was no timecode link: " + selectedURL);
+ }
+ break;
+ default:
+ handled = false;
+ break;
+
+ }
+ selectedURL = null;
+ }
+ return handled;
+
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v,
+ ContextMenuInfo menuInfo) {
+ if (selectedURL != null) {
+ super.onCreateContextMenu(menu, v, menuInfo);
+ if (Timeline.isTimecodeLink(selectedURL)) {
+ menu.add(Menu.NONE, R.id.go_to_position_item, Menu.NONE,
+ R.string.go_to_position_label);
+ menu.setHeaderTitle(Converter.getDurationStringLong(Timeline.getTimecodeLinkTime(selectedURL)));
+ } else {
+ menu.add(Menu.NONE, R.id.open_in_browser_item, Menu.NONE,
+ R.string.open_in_browser_label);
+ menu.add(Menu.NONE, R.id.copy_url_item, Menu.NONE,
+ R.string.copy_url_label);
+ menu.add(Menu.NONE, R.id.share_url_item, Menu.NONE,
+ R.string.share_url_label);
+ menu.setHeaderTitle(selectedURL);
+ }
+ }
+ }
+
+ private AsyncTask<Void, Void, Void> createLoader() {
+ return new AsyncTask<Void, Void, Void>() {
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+ if (getActivity() != null) {
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(false);
+ }
+ webViewLoader = null;
+ }
+
+ String data;
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ // /webvDescription.loadData(url, "text/html", "utf-8");
+ webvDescription.loadDataWithBaseURL(null, data, "text/html",
+ "utf-8", "about:blank");
+ if (getActivity() != null) {
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(false);
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Webview loaded");
+ webViewLoader = null;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (getActivity() != null) {
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(false);
+ }
+ }
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading Webview");
+ try {
+ Activity activity = getActivity();
+ if (activity != null) {
+ Timeline timeline = new Timeline(activity, shownotesProvider);
+ data = timeline.processShownotes(highlightTimecodes);
+ } else {
+ cancel(true);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ };
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ savePreference();
+ }
+
+ private void savePreference() {
+ if (saveState) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Saving preferences");
+ SharedPreferences prefs = getActivity().getSharedPreferences(PREF,
+ Activity.MODE_PRIVATE);
+ SharedPreferences.Editor editor = prefs.edit();
+ if (media != null && webvDescription != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Saving scroll position: "
+ + webvDescription.getScrollY()
+ );
+ editor.putInt(PREF_SCROLL_Y, webvDescription.getScrollY());
+ editor.putString(PREF_PLAYABLE_ID, media.getIdentifier()
+ .toString());
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "savePreferences was called while media or webview was null");
+ editor.putInt(PREF_SCROLL_Y, -1);
+ editor.putString(PREF_PLAYABLE_ID, "");
+ }
+ editor.commit();
+ }
+ }
+
+ private boolean restoreFromPreference() {
+ if (saveState) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Restoring from preferences");
+ Activity activity = getActivity();
+ if (activity != null) {
+ SharedPreferences prefs = activity.getSharedPreferences(
+ PREF, Activity.MODE_PRIVATE);
+ String id = prefs.getString(PREF_PLAYABLE_ID, "");
+ int scrollY = prefs.getInt(PREF_SCROLL_Y, -1);
+ if (scrollY != -1 && media != null
+ && id.equals(media.getIdentifier().toString())
+ && webvDescription != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Restored scroll Position: " + scrollY);
+ webvDescription.scrollTo(webvDescription.getScrollX(),
+ scrollY);
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void onTimecodeLinkSelected(String link) {
+ int time = Timeline.getTimecodeLinkTime(link);
+ if (getActivity() != null && getActivity() instanceof ItemDescriptionFragmentCallback) {
+ PlaybackController pc = ((ItemDescriptionFragmentCallback) getActivity()).getPlaybackController();
+ if (pc != null) {
+ pc.seekTo(time);
+ }
+ }
+ }
+
+ public interface ItemDescriptionFragmentCallback {
+ public PlaybackController getPlaybackController();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java
new file mode 100644
index 000000000..909774467
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemlistFragment.java
@@ -0,0 +1,456 @@
+package de.danoeh.antennapod.fragment;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.ListFragment;
+import android.support.v7.app.ActionBarActivity;
+import android.support.v7.widget.SearchView;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.List;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.FeedInfoActivity;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.adapter.DefaultActionButtonCallback;
+import de.danoeh.antennapod.adapter.FeedItemlistAdapter;
+import de.danoeh.antennapod.asynctask.DownloadObserver;
+import de.danoeh.antennapod.asynctask.FeedRemover;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.dialog.ConfirmationDialog;
+import de.danoeh.antennapod.dialog.DownloadRequestErrorDialogCreator;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.service.download.DownloadService;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.menuhandler.FeedMenuHandler;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+/**
+ * Displays a list of FeedItems.
+ */
+@SuppressLint("ValidFragment")
+public class ItemlistFragment extends ListFragment {
+ private static final String TAG = "ItemlistFragment";
+
+ private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED
+ | EventDistributor.DOWNLOAD_QUEUED
+ | EventDistributor.QUEUE_UPDATE
+ | EventDistributor.UNREAD_ITEMS_UPDATE;
+
+ public static final String EXTRA_SELECTED_FEEDITEM = "extra.de.danoeh.antennapod.activity.selected_feeditem";
+ public static final String ARGUMENT_FEED_ID = "argument.de.danoeh.antennapod.feed_id";
+
+ protected FeedItemlistAdapter adapter;
+
+ private long feedID;
+ private Feed feed;
+ protected QueueAccess queue;
+
+ private boolean itemsLoaded = false;
+ private boolean viewsCreated = false;
+
+ private DownloadObserver downloadObserver;
+ private List<Downloader> downloaderList;
+
+ private FeedItemDialog feedItemDialog;
+ private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance;
+
+
+ /**
+ * Creates new ItemlistFragment which shows the Feeditems of a specific
+ * feed. Sets 'showFeedtitle' to false
+ *
+ * @param feedId The id of the feed to show
+ * @return the newly created instance of an ItemlistFragment
+ */
+ public static ItemlistFragment newInstance(long feedId) {
+ ItemlistFragment i = new ItemlistFragment();
+ Bundle b = new Bundle();
+ b.putLong(ARGUMENT_FEED_ID, feedId);
+ i.setArguments(b);
+ return i;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ setHasOptionsMenu(true);
+
+ Bundle args = getArguments();
+ Validate.notNull(args);
+ feedID = args.getLong(ARGUMENT_FEED_ID);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ if (downloadObserver != null) {
+ downloadObserver.setActivity(getActivity());
+ downloadObserver.onResume();
+ }
+ if (viewsCreated && itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ updateProgressBarVisibility();
+ startItemLoader();
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ stopItemLoader();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ resetViewState();
+ }
+
+ private void resetViewState() {
+ adapter = null;
+ viewsCreated = false;
+ if (downloadObserver != null) {
+ downloadObserver.onPause();
+ }
+ if (feedItemDialog != null) {
+ feedItemDialogSavedInstance = feedItemDialog.save();
+ }
+ feedItemDialog = null;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ FeedMenuHandler.onCreateOptionsMenu(inflater, menu);
+
+ final SearchView sv = new SearchView(getActivity());
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.search_hint));
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ sv.clearFocus();
+ if (itemsLoaded) {
+ ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s, feed.getId()));
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ FeedMenuHandler.onPrepareOptionsMenu(menu, feed);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (!super.onOptionsItemSelected(item)) {
+ try {
+ if (!FeedMenuHandler.onOptionsItemClicked(getActivity(), item, feed)) {
+ switch (item.getItemId()) {
+ case R.id.remove_item:
+ final FeedRemover remover = new FeedRemover(
+ getActivity(), feed) {
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ ((MainActivity) getActivity()).loadNavFragment(MainActivity.POS_NEW, null);
+ }
+ };
+ ConfirmationDialog conDialog = new ConfirmationDialog(getActivity(),
+ R.string.remove_feed_label,
+ R.string.feed_delete_confirmation_msg) {
+
+ @Override
+ public void onConfirmButtonPressed(
+ DialogInterface dialog) {
+ dialog.dismiss();
+ remover.executeAsync();
+ }
+ };
+ conDialog.createNewDialog().show();
+ return true;
+ default:
+ return false;
+
+ }
+ } else {
+ return true;
+ }
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DownloadRequestErrorDialogCreator.newRequestErrorDialog(getActivity(), e.getMessage());
+ return true;
+ }
+ } else {
+ return true;
+ }
+
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ ((ActionBarActivity) getActivity()).getSupportActionBar().setTitle("");
+
+ viewsCreated = true;
+ if (itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onListItemClick(ListView l, View v, int position, long id) {
+ FeedItem selection = adapter.getItem(position - l.getHeaderViewsCount());
+ feedItemDialog = FeedItemDialog.newInstance(getActivity(), selection, queue);
+ feedItemDialog.show();
+ }
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((EVENTS & arg) != 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received contentUpdate Intent.");
+ if ((EventDistributor.DOWNLOAD_QUEUED & arg) != 0) {
+ updateProgressBarVisibility();
+ } else {
+ startItemLoader();
+ updateProgressBarVisibility();
+ }
+ }
+ }
+ };
+
+ private void updateProgressBarVisibility() {
+ if (feed != null) {
+ if (DownloadService.isRunning
+ && DownloadRequester.getInstance().isDownloadingFile(feed)) {
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(true);
+ } else {
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(false);
+ }
+ getActivity().supportInvalidateOptionsMenu();
+ }
+ }
+
+ private void onFragmentLoaded() {
+ if (adapter == null) {
+ getListView().setAdapter(null);
+ setupHeaderView();
+ adapter = new FeedItemlistAdapter(getActivity(), itemAccess, new DefaultActionButtonCallback(getActivity()), false);
+ setListAdapter(adapter);
+ downloadObserver = new DownloadObserver(getActivity(), new Handler(), downloadObserverCallback);
+ downloadObserver.onResume();
+ }
+ setListShown(true);
+ adapter.notifyDataSetChanged();
+
+ if (feedItemDialog != null) {
+ feedItemDialog.updateContent(queue, feed.getItems());
+ } else if (feedItemDialogSavedInstance != null) {
+ feedItemDialog = FeedItemDialog.newInstance(getActivity(), feedItemDialogSavedInstance);
+ }
+ getActivity().supportInvalidateOptionsMenu();
+ }
+
+ private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() {
+ @Override
+ public void onContentChanged() {
+ if (adapter != null) {
+ adapter.notifyDataSetChanged();
+ }
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ }
+
+ @Override
+ public void onDownloadDataAvailable(List<Downloader> downloaderList) {
+ ItemlistFragment.this.downloaderList = downloaderList;
+ if (adapter != null) {
+ adapter.notifyDataSetChanged();
+ }
+ }
+ };
+
+ private void setupHeaderView() {
+ if (getListView() == null || feed == null) {
+ Log.e(TAG, "Unable to setup listview: listView = null or feed = null");
+ return;
+ }
+ ListView lv = getListView();
+ LayoutInflater inflater = (LayoutInflater)
+ getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View header = inflater.inflate(R.layout.feeditemlist_header, lv, false);
+ lv.addHeaderView(header);
+
+ TextView txtvTitle = (TextView) header.findViewById(R.id.txtvTitle);
+ TextView txtvAuthor = (TextView) header.findViewById(R.id.txtvAuthor);
+ ImageView imgvCover = (ImageView) header.findViewById(R.id.imgvCover);
+ ImageButton butShowInfo = (ImageButton) header.findViewById(R.id.butShowInfo);
+ ImageButton butVisitWebsite = (ImageButton) header.findViewById(R.id.butVisitWebsite);
+
+ txtvTitle.setText(feed.getTitle());
+ txtvAuthor.setText(feed.getAuthor());
+
+ PicassoProvider.getDefaultPicassoInstance(getActivity())
+ .load(feed.getImageUri())
+ .fit()
+ .into(imgvCover);
+
+ if (feed.getLink() == null) {
+ butVisitWebsite.setVisibility(View.INVISIBLE);
+ } else {
+ butVisitWebsite.setVisibility(View.VISIBLE);
+ butVisitWebsite.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Uri uri = Uri.parse(feed.getLink());
+ startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ }
+ });
+ }
+ butShowInfo.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (viewsCreated && itemsLoaded) {
+ Intent startIntent = new Intent(getActivity(), FeedInfoActivity.class);
+ startIntent.putExtra(FeedInfoActivity.EXTRA_FEED_ID,
+ feed.getId());
+ startActivity(startIntent);
+ }
+ }
+ });
+ }
+
+ private FeedItemlistAdapter.ItemAccess itemAccess = new FeedItemlistAdapter.ItemAccess() {
+
+ @Override
+ public FeedItem getItem(int position) {
+ return (feed != null) ? feed.getItemAtIndex(true, position) : null;
+ }
+
+ @Override
+ public int getCount() {
+ return (feed != null) ? feed.getNumOfItems(true) : 0;
+ }
+
+ @Override
+ public boolean isInQueue(FeedItem item) {
+ return (queue != null) && queue.contains(item.getId());
+ }
+
+ @Override
+ public int getItemDownloadProgressPercent(FeedItem item) {
+ if (downloaderList != null) {
+ for (Downloader downloader : downloaderList) {
+ if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) {
+ return downloader.getDownloadRequest().getProgressPercent();
+ }
+ }
+ }
+ return 0;
+ }
+ };
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute(feedID);
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Long, Void, Object[]> {
+ @Override
+ protected Object[] doInBackground(Long... params) {
+ long feedID = params[0];
+ Context context = getActivity();
+ if (context != null) {
+ return new Object[]{DBReader.getFeed(context, feedID),
+ QueueAccess.IDListAccess(DBReader.getQueueIDList(context))};
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object[] res) {
+ super.onPostExecute(res);
+ if (res != null) {
+ feed = (Feed) res[0];
+ queue = (QueueAccess) res[1];
+ itemsLoaded = true;
+ if (viewsCreated) {
+ onFragmentLoaded();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java
new file mode 100644
index 000000000..fe995256b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/NewEpisodesFragment.java
@@ -0,0 +1,425 @@
+package de.danoeh.antennapod.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.Fragment;
+import android.support.v7.app.ActionBarActivity;
+import android.support.v7.widget.SearchView;
+import android.view.*;
+import android.widget.AdapterView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import android.widget.Toast;
+import com.mobeta.android.dslv.DragSortListView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.adapter.DefaultActionButtonCallback;
+import de.danoeh.antennapod.adapter.NewEpisodesListAdapter;
+import de.danoeh.antennapod.asynctask.DownloadObserver;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.download.DownloadService;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Shows unread or recently published episodes
+ */
+public class NewEpisodesFragment extends Fragment {
+ private static final String TAG = "NewEpisodesFragment";
+ private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED |
+ EventDistributor.DOWNLOAD_QUEUED |
+ EventDistributor.QUEUE_UPDATE |
+ EventDistributor.UNREAD_ITEMS_UPDATE;
+
+ private static final int RECENT_EPISODES_LIMIT = 150;
+ private static final String PREF_NAME = "PrefNewEpisodesFragment";
+ private static final String PREF_EPISODE_FILTER_BOOL = "newEpisodeFilterEnabled";
+
+
+ private DragSortListView listView;
+ private NewEpisodesListAdapter listAdapter;
+ private TextView txtvEmpty;
+ private ProgressBar progLoading;
+
+ private List<FeedItem> unreadItems;
+ private List<FeedItem> recentItems;
+ private QueueAccess queueAccess;
+ private List<Downloader> downloaderList;
+
+ private boolean itemsLoaded = false;
+ private boolean viewsCreated = false;
+ private boolean showOnlyNewEpisodes = false;
+
+ private AtomicReference<MainActivity> activity = new AtomicReference<MainActivity>();
+
+ private DownloadObserver downloadObserver = null;
+
+ private FeedItemDialog feedItemDialog;
+ private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ setHasOptionsMenu(true);
+
+ updateShowOnlyEpisodes();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ startItemLoader();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ this.activity.set((MainActivity) getActivity());
+ if (downloadObserver != null) {
+ downloadObserver.setActivity(getActivity());
+ downloadObserver.onResume();
+ }
+ if (viewsCreated && itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.activity.set((MainActivity) getActivity());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ resetViewState();
+ }
+
+ private void resetViewState() {
+ listAdapter = null;
+ activity.set(null);
+ viewsCreated = false;
+ if (downloadObserver != null) {
+ downloadObserver.onPause();
+ }
+ if (feedItemDialog != null) {
+ feedItemDialogSavedInstance = feedItemDialog.save();
+ }
+ feedItemDialog = null;
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ inflater.inflate(R.menu.new_episodes, menu);
+
+ final SearchView sv = new SearchView(getActivity());
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.search_hint));
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ sv.clearFocus();
+ ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s));
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ menu.findItem(R.id.mark_all_read_item).setVisible(unreadItems != null && !unreadItems.isEmpty());
+ menu.findItem(R.id.episode_filter_item).setChecked(showOnlyNewEpisodes);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (!super.onOptionsItemSelected(item)) {
+ switch (item.getItemId()) {
+ case R.id.refresh_item:
+ List<Feed> feeds = ((MainActivity) getActivity()).getFeeds();
+ if (feeds != null) {
+ DBTasks.refreshAllFeeds(getActivity(), feeds);
+ }
+ return true;
+ case R.id.mark_all_read_item:
+ DBWriter.markAllItemsRead(getActivity());
+ Toast.makeText(getActivity(), R.string.mark_all_read_msg, Toast.LENGTH_SHORT).show();
+ return true;
+ case R.id.episode_filter_item:
+ boolean newVal = !item.isChecked();
+ setShowOnlyNewEpisodes(newVal);
+ item.setChecked(newVal);
+ return true;
+ default:
+ return false;
+ }
+ } else {
+ return true;
+ }
+
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ ((MainActivity) getActivity()).getSupportActionBar().setTitle(R.string.all_episodes_label);
+
+ View root = inflater.inflate(R.layout.new_episodes_fragment, container, false);
+
+ listView = (DragSortListView) root.findViewById(android.R.id.list);
+ txtvEmpty = (TextView) root.findViewById(android.R.id.empty);
+ progLoading = (ProgressBar) root.findViewById(R.id.progLoading);
+
+ listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ FeedItem item = (FeedItem) listAdapter.getItem(position - listView.getHeaderViewsCount());
+ if (item != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, queueAccess);
+ feedItemDialog.show();
+ }
+
+ }
+ });
+
+ final int secondColor = (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) ? R.color.swipe_refresh_secondary_color_dark : R.color.swipe_refresh_secondary_color_light;
+
+ if (!itemsLoaded) {
+ progLoading.setVisibility(View.VISIBLE);
+ txtvEmpty.setVisibility(View.GONE);
+ }
+
+ viewsCreated = true;
+
+ if (itemsLoaded && activity.get() != null) {
+ onFragmentLoaded();
+ }
+
+ return root;
+ }
+
+ private void onFragmentLoaded() {
+ if (listAdapter == null) {
+ listAdapter = new NewEpisodesListAdapter(activity.get(), itemAccess, new DefaultActionButtonCallback(activity.get()));
+ listView.setAdapter(listAdapter);
+ listView.setEmptyView(txtvEmpty);
+ downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback);
+ downloadObserver.onResume();
+ }
+ if (feedItemDialog != null) {
+ feedItemDialog.updateContent(queueAccess, unreadItems, recentItems);
+ } else if (feedItemDialogSavedInstance != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance);
+ }
+ listAdapter.notifyDataSetChanged();
+ getActivity().supportInvalidateOptionsMenu();
+ updateProgressBarVisibility();
+ updateShowOnlyEpisodesListViewState();
+ }
+
+ private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() {
+ @Override
+ public void onContentChanged() {
+ if (listAdapter != null) {
+ listAdapter.notifyDataSetChanged();
+ }
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ }
+
+ @Override
+ public void onDownloadDataAvailable(List<Downloader> downloaderList) {
+ NewEpisodesFragment.this.downloaderList = downloaderList;
+ if (listAdapter != null) {
+ listAdapter.notifyDataSetChanged();
+ }
+ }
+ };
+
+ private NewEpisodesListAdapter.ItemAccess itemAccess = new NewEpisodesListAdapter.ItemAccess() {
+
+ @Override
+ public int getCount() {
+ if (itemsLoaded) {
+ return (showOnlyNewEpisodes) ? unreadItems.size() : recentItems.size();
+ }
+ return 0;
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ if (itemsLoaded) {
+ return (showOnlyNewEpisodes) ? unreadItems.get(position) : recentItems.get(position);
+ }
+ return null;
+ }
+
+ @Override
+ public int getItemDownloadProgressPercent(FeedItem item) {
+ if (downloaderList != null) {
+ for (Downloader downloader : downloaderList) {
+ if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) {
+ return downloader.getDownloadRequest().getProgressPercent();
+ }
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public boolean isInQueue(FeedItem item) {
+ if (itemsLoaded) {
+ return queueAccess.contains(item.getId());
+ } else {
+ return false;
+ }
+ }
+
+
+ };
+
+ private void updateProgressBarVisibility() {
+ if (!viewsCreated) {
+ return;
+ }
+ ((ActionBarActivity) getActivity())
+ .setSupportProgressBarIndeterminateVisibility(DownloadService.isRunning
+ && DownloadRequester.getInstance().isDownloadingFeeds());
+ }
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EVENTS) != 0) {
+ startItemLoader();
+ updateProgressBarVisibility();
+ }
+ }
+ };
+
+ private void updateShowOnlyEpisodes() {
+ SharedPreferences prefs = getActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
+ showOnlyNewEpisodes = prefs.getBoolean(PREF_EPISODE_FILTER_BOOL, false);
+ }
+
+ private void setShowOnlyNewEpisodes(boolean newVal) {
+ showOnlyNewEpisodes = newVal;
+ SharedPreferences prefs = getActivity().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(PREF_EPISODE_FILTER_BOOL, showOnlyNewEpisodes);
+ editor.commit();
+ if (itemsLoaded && viewsCreated) {
+ listAdapter.notifyDataSetChanged();
+ activity.get().supportInvalidateOptionsMenu();
+ updateShowOnlyEpisodesListViewState();
+ }
+ }
+
+ private void updateShowOnlyEpisodesListViewState() {
+ if (showOnlyNewEpisodes) {
+ listView.setEmptyView(null);
+ txtvEmpty.setVisibility(View.GONE);
+ } else {
+ listView.setEmptyView(txtvEmpty);
+ }
+ }
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute();
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Void, Void, Object[]> {
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (viewsCreated && !itemsLoaded) {
+ listView.setVisibility(View.GONE);
+ txtvEmpty.setVisibility(View.GONE);
+ progLoading.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ protected Object[] doInBackground(Void... params) {
+ Context context = activity.get();
+ if (context != null) {
+ return new Object[]{DBReader.getUnreadItemsList(context),
+ DBReader.getRecentlyPublishedEpisodes(context, RECENT_EPISODES_LIMIT),
+ QueueAccess.IDListAccess(DBReader.getQueueIDList(context))};
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object[] lists) {
+ super.onPostExecute(lists);
+ listView.setVisibility(View.VISIBLE);
+ progLoading.setVisibility(View.GONE);
+
+ if (lists != null) {
+ unreadItems = (List<FeedItem>) lists[0];
+ recentItems = (List<FeedItem>) lists[1];
+ queueAccess = (QueueAccess) lists[2];
+ itemsLoaded = true;
+ if (viewsCreated && activity.get() != null) {
+ onFragmentLoaded();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java
new file mode 100644
index 000000000..470186180
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java
@@ -0,0 +1,288 @@
+package de.danoeh.antennapod.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.ListFragment;
+import android.support.v4.view.MenuItemCompat;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ListView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.adapter.DefaultActionButtonCallback;
+import de.danoeh.antennapod.adapter.FeedItemlistAdapter;
+import de.danoeh.antennapod.asynctask.DownloadObserver;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class PlaybackHistoryFragment extends ListFragment {
+ private static final String TAG = "PlaybackHistoryFragment";
+
+ private List<FeedItem> playbackHistory;
+ private QueueAccess queue;
+ private FeedItemlistAdapter adapter;
+
+ private boolean itemsLoaded = false;
+ private boolean viewsCreated = false;
+
+ private AtomicReference<Activity> activity = new AtomicReference<Activity>();
+
+ private DownloadObserver downloadObserver;
+ private List<Downloader> downloaderList;
+
+ private FeedItemDialog feedItemDialog;
+ private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ startItemLoader();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ stopItemLoader();
+ activity.set(null);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.activity.set(activity);
+ if (downloadObserver != null) {
+ downloadObserver.setActivity(activity);
+ downloadObserver.onResume();
+ }
+ if (viewsCreated && itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ adapter = null;
+ viewsCreated = false;
+ if (downloadObserver != null) {
+ downloadObserver.onPause();
+ }
+ if (feedItemDialog != null) {
+ feedItemDialogSavedInstance = feedItemDialog.save();
+ }
+ feedItemDialog = null;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ viewsCreated = true;
+ if (itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onListItemClick(ListView l, View v, int position, long id) {
+ super.onListItemClick(l, v, position, id);
+ FeedItem item = adapter.getItem(position - l.getHeaderViewsCount());
+ if (item != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, queue);
+ feedItemDialog.show();
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ MenuItem clearHistory = menu.add(Menu.NONE, R.id.clear_history_item, Menu.CATEGORY_CONTAINER, R.string.clear_history_label);
+ MenuItemCompat.setShowAsAction(clearHistory, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
+ TypedArray drawables = getActivity().obtainStyledAttributes(new int[]{R.attr.content_discard});
+ clearHistory.setIcon(drawables.getDrawable(0));
+ drawables.recycle();
+ }
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ menu.findItem(R.id.clear_history_item).setVisible(playbackHistory != null && !playbackHistory.isEmpty());
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (!super.onOptionsItemSelected(item)) {
+ switch(item.getItemId()) {
+ case R.id.clear_history_item:
+ DBWriter.clearPlaybackHistory(getActivity());
+ return true;
+ default:
+ return false;
+ }
+ } else {
+ return true;
+ }
+ }
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EventDistributor.PLAYBACK_HISTORY_UPDATE) != 0) {
+ startItemLoader();
+ getActivity().supportInvalidateOptionsMenu();
+ }
+ }
+ };
+
+ private void onFragmentLoaded() {
+ if (adapter == null) {
+ adapter = new FeedItemlistAdapter(getActivity(), itemAccess, new DefaultActionButtonCallback(activity.get()), true);
+ setListAdapter(adapter);
+ downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback);
+ downloadObserver.onResume();
+ }
+ setListShown(true);
+ adapter.notifyDataSetChanged();
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateContent(queue, playbackHistory);
+ } else if (feedItemDialogSavedInstance != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance);
+ }
+ getActivity().supportInvalidateOptionsMenu();
+ }
+
+ private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() {
+ @Override
+ public void onContentChanged() {
+ if (adapter != null) {
+ adapter.notifyDataSetChanged();
+ }
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ }
+
+ @Override
+ public void onDownloadDataAvailable(List<Downloader> downloaderList) {
+ PlaybackHistoryFragment.this.downloaderList = downloaderList;
+ if (adapter != null) {
+ adapter.notifyDataSetChanged();
+ }
+ }
+ };
+
+ private FeedItemlistAdapter.ItemAccess itemAccess = new FeedItemlistAdapter.ItemAccess() {
+ @Override
+ public boolean isInQueue(FeedItem item) {
+ return (queue != null) ? queue.contains(item.getId()) : false;
+ }
+
+ @Override
+ public int getItemDownloadProgressPercent(FeedItem item) {
+ if (downloaderList != null) {
+ for (Downloader downloader : downloaderList) {
+ if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) {
+ return downloader.getDownloadRequest().getProgressPercent();
+ }
+ }
+ }
+ return 0;
+ }
+
+ @Override
+ public int getCount() {
+ return (playbackHistory != null) ? playbackHistory.size() : 0;
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ return (playbackHistory != null) ? playbackHistory.get(position) : null;
+ }
+ };
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute();
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Void, Void, Object[]> {
+
+ @Override
+ protected Object[] doInBackground(Void... params) {
+ Context context = activity.get();
+ if (context != null) {
+ List<FeedItem> ph = DBReader.getPlaybackHistory(context);
+ DBReader.loadFeedDataOfFeedItemlist(context, ph);
+ return new Object[]{ph,
+ QueueAccess.IDListAccess(DBReader.getQueueIDList(context))};
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object[] res) {
+ super.onPostExecute(res);
+ if (res != null) {
+ playbackHistory = (List<FeedItem>) res[0];
+ queue = (QueueAccess) res[1];
+ itemsLoaded = true;
+ if (viewsCreated) {
+ onFragmentLoaded();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java
new file mode 100644
index 000000000..2f322f75b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java
@@ -0,0 +1,383 @@
+package de.danoeh.antennapod.fragment;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.Fragment;
+import android.support.v7.widget.SearchView;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.mobeta.android.dslv.DragSortListView;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.adapter.DefaultActionButtonCallback;
+import de.danoeh.antennapod.adapter.QueueListAdapter;
+import de.danoeh.antennapod.asynctask.DownloadObserver;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+/**
+ * Shows all items in the queue
+ */
+public class QueueFragment extends Fragment {
+ private static final String TAG = "QueueFragment";
+ private static final int EVENTS = EventDistributor.DOWNLOAD_HANDLED |
+ EventDistributor.DOWNLOAD_QUEUED |
+ EventDistributor.QUEUE_UPDATE;
+
+ private DragSortListView listView;
+ private QueueListAdapter listAdapter;
+ private TextView txtvEmpty;
+ private ProgressBar progLoading;
+
+ private List<FeedItem> queue;
+ private List<Downloader> downloaderList;
+
+ private boolean itemsLoaded = false;
+ private boolean viewsCreated = false;
+
+ private AtomicReference<Activity> activity = new AtomicReference<Activity>();
+
+ private DownloadObserver downloadObserver = null;
+
+ private FeedItemDialog feedItemDialog;
+ private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance;
+
+ /**
+ * Download observer updates won't result in an upate of the list adapter if this is true.
+ */
+ private boolean blockDownloadObserverUpdate = false;
+
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ startItemLoader();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ this.activity.set((MainActivity) getActivity());
+ if (downloadObserver != null) {
+ downloadObserver.setActivity(getActivity());
+ downloadObserver.onResume();
+ }
+ if (viewsCreated && itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ stopItemLoader();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.activity.set((MainActivity) activity);
+ }
+
+ private void resetViewState() {
+ unregisterForContextMenu(listView);
+ listAdapter = null;
+ activity.set(null);
+ viewsCreated = false;
+ blockDownloadObserverUpdate = false;
+ if (downloadObserver != null) {
+ downloadObserver.onPause();
+ }
+ if (feedItemDialog != null) {
+ feedItemDialogSavedInstance = feedItemDialog.save();
+ }
+ feedItemDialog = null;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ resetViewState();
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ final SearchView sv = new SearchView(getActivity());
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.search_hint));
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ sv.clearFocus();
+ ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(s));
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+ super.onCreateContextMenu(menu, v, menuInfo);
+ AdapterView.AdapterContextMenuInfo adapterInfo = (AdapterView.AdapterContextMenuInfo) menuInfo;
+ FeedItem item = itemAccess.getItem(adapterInfo.position);
+
+ MenuInflater inflater = getActivity().getMenuInflater();
+ inflater.inflate(R.menu.queue_context, menu);
+
+ if (item != null) {
+ menu.setHeaderTitle(item.getTitle());
+ }
+
+ menu.findItem(R.id.move_to_top_item).setEnabled(!queue.isEmpty() && queue.get(0) != item);
+ menu.findItem(R.id.move_to_bottom_item).setEnabled(!queue.isEmpty() && queue.get(queue.size() - 1) != item);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ AdapterView.AdapterContextMenuInfo menuInfo = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo();
+ FeedItem selectedItem = itemAccess.getItem(menuInfo.position);
+
+ if (selectedItem == null) {
+ Log.i(TAG, "Selected item at position " + menuInfo.position + " was null, ignoring selection");
+ return super.onContextItemSelected(item);
+ }
+
+ switch (item.getItemId()) {
+ case R.id.move_to_top_item:
+ DBWriter.moveQueueItemToTop(getActivity(), selectedItem.getId(), true);
+ return true;
+ case R.id.move_to_bottom_item:
+ DBWriter.moveQueueItemToBottom(getActivity(), selectedItem.getId(), true);
+ return true;
+ case R.id.remove_from_queue_item:
+ DBWriter.removeQueueItem(getActivity(), selectedItem.getId(), false);
+ return true;
+ default:
+ return super.onContextItemSelected(item);
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ ((MainActivity) getActivity()).getSupportActionBar().setTitle(R.string.queue_label);
+
+ View root = inflater.inflate(R.layout.queue_fragment, container, false);
+ listView = (DragSortListView) root.findViewById(android.R.id.list);
+ txtvEmpty = (TextView) root.findViewById(android.R.id.empty);
+ progLoading = (ProgressBar) root.findViewById(R.id.progLoading);
+ listView.setEmptyView(txtvEmpty);
+
+ listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ FeedItem item = (FeedItem) listAdapter.getItem(position - listView.getHeaderViewsCount());
+ if (item != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), item, QueueAccess.ItemListAccess(queue));
+ feedItemDialog.show();
+ }
+ }
+ });
+
+ listView.setDragSortListener(new DragSortListView.DragSortListener() {
+ @Override
+ public void drag(int from, int to) {
+ Log.d(TAG, "drag");
+ blockDownloadObserverUpdate = true;
+ }
+
+ @Override
+ public void drop(int from, int to) {
+ Log.d(TAG, "drop");
+ blockDownloadObserverUpdate = false;
+ stopItemLoader();
+ final FeedItem item = queue.remove(from);
+ queue.add(to, item);
+ listAdapter.notifyDataSetChanged();
+ DBWriter.moveQueueItem(getActivity(), from, to, true);
+ }
+
+ @Override
+ public void remove(int which) {
+ }
+ });
+
+ registerForContextMenu(listView);
+
+ if (!itemsLoaded) {
+ progLoading.setVisibility(View.VISIBLE);
+ txtvEmpty.setVisibility(View.GONE);
+ }
+
+ viewsCreated = true;
+
+ if (itemsLoaded && activity.get() != null) {
+ onFragmentLoaded();
+ }
+
+ return root;
+ }
+
+ private void onFragmentLoaded() {
+ if (listAdapter == null) {
+ listAdapter = new QueueListAdapter(activity.get(), itemAccess, new DefaultActionButtonCallback(activity.get()));
+ listView.setAdapter(listAdapter);
+ downloadObserver = new DownloadObserver(activity.get(), new Handler(), downloadObserverCallback);
+ downloadObserver.onResume();
+ }
+ listAdapter.notifyDataSetChanged();
+ if (feedItemDialog != null) {
+ feedItemDialog.updateContent(QueueAccess.ItemListAccess(queue), queue);
+ } else if (feedItemDialogSavedInstance != null) {
+ feedItemDialog = FeedItemDialog.newInstance(activity.get(), feedItemDialogSavedInstance);
+ }
+ }
+
+ private DownloadObserver.Callback downloadObserverCallback = new DownloadObserver.Callback() {
+ @Override
+ public void onContentChanged() {
+ if (listAdapter != null && !blockDownloadObserverUpdate) {
+ listAdapter.notifyDataSetChanged();
+ }
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ }
+
+ @Override
+ public void onDownloadDataAvailable(List<Downloader> downloaderList) {
+ QueueFragment.this.downloaderList = downloaderList;
+ if (listAdapter != null && !blockDownloadObserverUpdate) {
+ listAdapter.notifyDataSetChanged();
+ }
+ }
+ };
+
+ private QueueListAdapter.ItemAccess itemAccess = new QueueListAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ return (itemsLoaded) ? queue.size() : 0;
+ }
+
+ @Override
+ public FeedItem getItem(int position) {
+ return (itemsLoaded) ? queue.get(position) : null;
+ }
+
+ @Override
+ public int getItemDownloadProgressPercent(FeedItem item) {
+ if (downloaderList != null) {
+ for (Downloader downloader : downloaderList) {
+ if (downloader.getDownloadRequest().getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && downloader.getDownloadRequest().getFeedfileId() == item.getMedia().getId()) {
+ return downloader.getDownloadRequest().getProgressPercent();
+ }
+ }
+ }
+ return 0;
+ }
+ };
+
+ private EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & EVENTS) != 0) {
+ startItemLoader();
+ }
+ }
+ };
+
+ private ItemLoader itemLoader;
+
+ private void startItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ itemLoader = new ItemLoader();
+ itemLoader.execute();
+ }
+
+ private void stopItemLoader() {
+ if (itemLoader != null) {
+ itemLoader.cancel(true);
+ }
+ }
+
+ private class ItemLoader extends AsyncTask<Void, Void, List<FeedItem>> {
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (viewsCreated && !itemsLoaded) {
+ listView.setVisibility(View.GONE);
+ txtvEmpty.setVisibility(View.GONE);
+ progLoading.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(List<FeedItem> feedItems) {
+ super.onPostExecute(feedItems);
+ listView.setVisibility(View.VISIBLE);
+ progLoading.setVisibility(View.GONE);
+
+ if (feedItems != null) {
+ queue = feedItems;
+ itemsLoaded = true;
+ if (viewsCreated && activity.get() != null) {
+ onFragmentLoaded();
+ }
+ }
+ }
+
+ @Override
+ protected List<FeedItem> doInBackground(Void... params) {
+ Context context = activity.get();
+ if (context != null) {
+ return DBReader.getQueue(context);
+ }
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java
new file mode 100644
index 000000000..89c30e34b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/RunningDownloadsFragment.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.fragment;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.support.v4.app.ListFragment;
+import android.view.View;
+import de.danoeh.antennapod.adapter.DownloadlistAdapter;
+import de.danoeh.antennapod.asynctask.DownloadObserver;
+import de.danoeh.antennapod.service.download.Downloader;
+import de.danoeh.antennapod.storage.DownloadRequester;
+
+import java.util.List;
+
+/**
+ * Displays all running downloads and provides actions to cancel them
+ */
+public class RunningDownloadsFragment extends ListFragment {
+ private static final String TAG = "RunningDownloadsFragment";
+
+ private DownloadObserver downloadObserver;
+ private List<Downloader> downloaderList;
+
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ if (downloadObserver != null) {
+ downloadObserver.onPause();
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ final DownloadlistAdapter downloadlistAdapter = new DownloadlistAdapter(getActivity(), itemAccess);
+ setListAdapter(downloadlistAdapter);
+
+ downloadObserver = new DownloadObserver(getActivity(), new Handler(), new DownloadObserver.Callback() {
+ @Override
+ public void onContentChanged() {
+ downloadlistAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void onDownloadDataAvailable(List<Downloader> downloaderList) {
+ RunningDownloadsFragment.this.downloaderList = downloaderList;
+ downloadlistAdapter.notifyDataSetChanged();
+ }
+ });
+ downloadObserver.onResume();
+ }
+
+ private DownloadlistAdapter.ItemAccess itemAccess = new DownloadlistAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ return (downloaderList != null) ? downloaderList.size() : 0;
+ }
+
+ @Override
+ public Downloader getItem(int position) {
+ return (downloaderList != null) ? downloaderList.get(position) : null;
+ }
+
+ @Override
+ public void onSecondaryActionClick(Downloader downloader) {
+ DownloadRequester.getInstance().cancelDownload(getActivity(), downloader.getDownloadRequest().getSource());
+ }
+ };
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java
new file mode 100644
index 000000000..b3ade4d70
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java
@@ -0,0 +1,258 @@
+package de.danoeh.antennapod.fragment;
+
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.ListFragment;
+import android.support.v4.view.MenuItemCompat;
+import android.support.v7.app.ActionBarActivity;
+import android.support.v7.widget.SearchView;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.ListView;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.adapter.SearchlistAdapter;
+import de.danoeh.antennapod.dialog.FeedItemDialog;
+import de.danoeh.antennapod.feed.*;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.FeedSearcher;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+import java.util.List;
+
+/**
+ * Performs a search operation on all feeds or one specific feed and displays the search result.
+ */
+public class SearchFragment extends ListFragment {
+ private static final String TAG = "SearchFragment";
+
+ private static final String ARG_QUERY = "query";
+ private static final String ARG_FEED = "feed";
+
+ private SearchlistAdapter searchAdapter;
+ private List<SearchResult> searchResults;
+
+ private boolean viewCreated = false;
+ private boolean itemsLoaded = false;
+
+ private QueueAccess queue;
+
+ private FeedItemDialog feedItemDialog;
+ private FeedItemDialog.FeedItemDialogSavedInstance feedItemDialogSavedInstance;
+
+ /**
+ * Create a new SearchFragment that searches all feeds.
+ */
+ public static SearchFragment newInstance(String query) {
+ if (query == null) query = "";
+ SearchFragment fragment = new SearchFragment();
+ Bundle args = new Bundle();
+ args.putString(ARG_QUERY, query);
+ args.putLong(ARG_FEED, 0);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ /**
+ * Create a new SearchFragment that searches one specific feed.
+ */
+ public static SearchFragment newInstance(String query, long feed) {
+ SearchFragment fragment = newInstance(query);
+ fragment.getArguments().putLong(ARG_FEED, feed);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setRetainInstance(true);
+ setHasOptionsMenu(true);
+ startSearchTask();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ EventDistributor.getInstance().register(contentUpdate);
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ stopSearchTask();
+ EventDistributor.getInstance().unregister(contentUpdate);
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ stopSearchTask();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ searchAdapter = null;
+ viewCreated = false;
+ if (feedItemDialog != null) {
+ feedItemDialogSavedInstance = feedItemDialog.save();
+ }
+ feedItemDialog = null;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ ((ActionBarActivity) getActivity()).getSupportActionBar().setTitle(R.string.search_label);
+ viewCreated = true;
+ if (itemsLoaded) {
+ onFragmentLoaded();
+ }
+ }
+
+ @Override
+ public void onListItemClick(ListView l, View v, int position, long id) {
+ super.onListItemClick(l, v, position, id);
+ SearchResult result = (SearchResult) l.getAdapter().getItem(position);
+ FeedComponent comp = result.getComponent();
+ if (comp.getClass() == Feed.class) {
+ ((MainActivity)getActivity()).loadFeedFragment(comp.getId());
+ } else {
+ if (comp.getClass() == FeedItem.class) {
+ feedItemDialog = FeedItemDialog.newInstance(getActivity(), (FeedItem) comp, queue);
+ feedItemDialog.show();
+ }
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (itemsLoaded && !MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ MenuItem item = menu.add(Menu.NONE, R.id.search_item, Menu.NONE, R.string.search_label);
+ MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
+ final SearchView sv = new SearchView(getActivity());
+ sv.setQueryHint(getString(R.string.search_hint));
+ sv.setQuery(getArguments().getString(ARG_QUERY), false);
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ getArguments().putString(ARG_QUERY, s);
+ itemsLoaded = false;
+ startSearchTask();
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ MenuItemCompat.setActionView(item, sv);
+ }
+ }
+
+ private final EventDistributor.EventListener contentUpdate = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((arg & (EventDistributor.DOWNLOAD_QUEUED)) != 0) {
+ feedItemDialog.updateMenuAppearance();
+ }
+ if ((arg & (EventDistributor.UNREAD_ITEMS_UPDATE
+ | EventDistributor.DOWNLOAD_HANDLED
+ | EventDistributor.QUEUE_UPDATE)) != 0) {
+ startSearchTask();
+ }
+ }
+ };
+
+ private void onFragmentLoaded() {
+ if (searchAdapter == null) {
+ searchAdapter = new SearchlistAdapter(getActivity(), itemAccess);
+ setListAdapter(searchAdapter);
+ }
+ searchAdapter.notifyDataSetChanged();
+ setListShown(true);
+ if (feedItemDialog != null && feedItemDialog.isShowing()) {
+ feedItemDialog.setQueue(queue);
+ for (SearchResult result : searchResults) {
+ FeedComponent comp = result.getComponent();
+ if (comp.getClass() == FeedItem.class && ((FeedItem) comp).getId() == feedItemDialog.getItem().getId()) {
+ feedItemDialog.setItem((FeedItem) comp);
+ }
+ }
+ feedItemDialog.updateMenuAppearance();
+ } else if (feedItemDialogSavedInstance != null) {
+ feedItemDialog = FeedItemDialog.newInstance(getActivity(), feedItemDialogSavedInstance);
+ }
+ }
+
+ private final SearchlistAdapter.ItemAccess itemAccess = new SearchlistAdapter.ItemAccess() {
+ @Override
+ public int getCount() {
+ return (searchResults != null) ? searchResults.size() : 0;
+ }
+
+ @Override
+ public SearchResult getItem(int position) {
+ return (searchResults != null) ? searchResults.get(position) : null;
+ }
+ };
+
+ private SearchTask searchTask;
+
+ private void startSearchTask() {
+ if (searchTask != null) {
+ searchTask.cancel(true);
+ }
+ searchTask = new SearchTask();
+ searchTask.execute(getArguments());
+ }
+
+ private void stopSearchTask() {
+ if (searchTask != null) {
+ searchTask.cancel(true);
+ }
+ }
+
+ private class SearchTask extends AsyncTask<Bundle, Void, Object[]> {
+ @Override
+ protected Object[] doInBackground(Bundle... params) {
+ String query = params[0].getString(ARG_QUERY);
+ long feed = params[0].getLong(ARG_FEED);
+ Context context = getActivity();
+ if (context != null) {
+ return new Object[]{FeedSearcher.performSearch(context, query, feed),
+ QueueAccess.IDListAccess(DBReader.getQueueIDList(context))};
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ if (viewCreated && !itemsLoaded) {
+ setListShown(false);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(Object[] results) {
+ super.onPostExecute(results);
+ if (results != null) {
+ itemsLoaded = true;
+ searchResults = (List<SearchResult>) results[0];
+ queue = (QueueAccess) results[1];
+ if (viewCreated) {
+ onFragmentLoaded();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java
new file mode 100644
index 000000000..ec8f69368
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/GpodnetMainFragment.java
@@ -0,0 +1,131 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import android.app.Activity;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentPagerAdapter;
+import android.support.v4.app.FragmentTransaction;
+import android.support.v4.view.ViewPager;
+import android.support.v7.app.ActionBar;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+
+/**
+ * Main navigation hub for gpodder.net podcast directory
+ */
+public class GpodnetMainFragment extends Fragment {
+
+ private ViewPager pager;
+ private MainActivity activity;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ super.onCreateView(inflater, container, savedInstanceState);
+ View root = inflater.inflate(R.layout.pager_fragment, container, false);
+ pager = (ViewPager) root.findViewById(R.id.pager);
+ GpodnetPagerAdapter pagerAdapter = new GpodnetPagerAdapter(getChildFragmentManager(), getResources());
+ pager.setAdapter(pagerAdapter);
+ final ActionBar actionBar = activity.getMainActivtyActionBar();
+ actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
+ ActionBar.TabListener tabListener = new ActionBar.TabListener() {
+ @Override
+ public void onTabSelected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+ pager.setCurrentItem(tab.getPosition());
+ }
+
+ @Override
+ public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+
+ }
+
+ @Override
+ public void onTabReselected(ActionBar.Tab tab, FragmentTransaction fragmentTransaction) {
+
+ }
+ };
+ actionBar.removeAllTabs();
+ actionBar.addTab(actionBar.newTab()
+ .setText(R.string.gpodnet_taglist_header)
+ .setTabListener(tabListener));
+ actionBar.addTab(actionBar.newTab()
+ .setText(R.string.gpodnet_toplist_header)
+ .setTabListener(tabListener));
+ actionBar.setTitle(R.string.gpodnet_main_label);
+
+ pager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
+ @Override
+ public void onPageSelected(int position) {
+ super.onPageSelected(position);
+ actionBar.setSelectedNavigationItem(position);
+ }
+ });
+ return root;
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ activity.getMainActivtyActionBar().removeAllTabs();
+ activity.getMainActivtyActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ this.activity = (MainActivity) activity;
+ }
+
+ public class GpodnetPagerAdapter extends FragmentPagerAdapter {
+
+
+ private static final int NUM_PAGES = 2;
+ private static final int POS_TAGS = 0;
+ private static final int POS_TOPLIST = 1;
+ private static final int POS_SUGGESTIONS = 2;
+
+ Resources resources;
+
+ public GpodnetPagerAdapter(FragmentManager fm, Resources resources) {
+ super(fm);
+ this.resources = resources;
+ }
+
+ @Override
+ public Fragment getItem(int i) {
+ switch (i) {
+ case POS_TAGS:
+ return new TagListFragment();
+ case POS_TOPLIST:
+ return new PodcastTopListFragment();
+ case POS_SUGGESTIONS:
+ return new SuggestionListFragment();
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public CharSequence getPageTitle(int position) {
+ switch (position) {
+ case POS_TAGS:
+ return getString(R.string.gpodnet_taglist_header);
+ case POS_TOPLIST:
+ return getString(R.string.gpodnet_toplist_header);
+ case POS_SUGGESTIONS:
+ return getString(R.string.gpodnet_suggestions_header);
+ default:
+ return super.getPageTitle(position);
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return NUM_PAGES;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java
new file mode 100644
index 000000000..1b4616207
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java
@@ -0,0 +1,169 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.Fragment;
+import android.support.v7.widget.*;
+import android.util.Log;
+import android.view.*;
+import android.widget.*;
+import android.widget.SearchView;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.DefaultOnlineFeedViewActivity;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.activity.OnlineFeedViewActivity;
+import de.danoeh.antennapod.adapter.gpodnet.PodcastListAdapter;
+import de.danoeh.antennapod.fragment.SearchFragment;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+import java.util.List;
+
+/**
+ * Displays a list of GPodnetPodcast-Objects in a GridView
+ */
+public abstract class PodcastListFragment extends Fragment {
+ private static final String TAG = "PodcastListFragment";
+
+ private GridView gridView;
+ private ProgressBar progressBar;
+ private TextView txtvError;
+ private Button butRetry;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ final android.support.v7.widget.SearchView sv = new android.support.v7.widget.SearchView(getActivity());
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.gpodnet_search_hint));
+ sv.setOnQueryTextListener(new android.support.v7.widget.SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ sv.clearFocus();
+ ((MainActivity) getActivity()).loadChildFragment(SearchListFragment.newInstance(s));
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View root = inflater.inflate(R.layout.gpodnet_podcast_list, container, false);
+
+ gridView = (GridView) root.findViewById(R.id.gridView);
+ progressBar = (ProgressBar) root.findViewById(R.id.progressBar);
+ txtvError = (TextView) root.findViewById(R.id.txtvError);
+ butRetry = (Button) root.findViewById(R.id.butRetry);
+
+ gridView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ onPodcastSelected((GpodnetPodcast) gridView.getAdapter().getItem(position));
+ }
+ });
+ butRetry.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ loadData();
+ }
+ });
+
+ loadData();
+ return root;
+ }
+
+ protected void onPodcastSelected(GpodnetPodcast selection) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Selected podcast: " + selection.toString());
+ Intent intent = new Intent(getActivity(), DefaultOnlineFeedViewActivity.class);
+ intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, selection.getUrl());
+ intent.putExtra(DefaultOnlineFeedViewActivity.ARG_TITLE, getString(R.string.gpodnet_main_label));
+ startActivity(intent);
+ }
+
+ protected abstract List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException;
+
+ protected final void loadData() {
+ AsyncTask<Void, Void, List<GpodnetPodcast>> loaderTask = new AsyncTask<Void, Void, List<GpodnetPodcast>>() {
+ volatile Exception exception = null;
+
+ @Override
+ protected List<GpodnetPodcast> doInBackground(Void... params) {
+ GpodnetService service = null;
+ try {
+ service = new GpodnetService();
+ return loadPodcastData(service);
+ } catch (GpodnetServiceException e) {
+ exception = e;
+ e.printStackTrace();
+ return null;
+ } finally {
+ if (service != null) {
+ service.shutdown();
+ }
+ }
+ }
+
+ @Override
+ protected void onPostExecute(List<GpodnetPodcast> gpodnetPodcasts) {
+ super.onPostExecute(gpodnetPodcasts);
+ final Context context = getActivity();
+ if (context != null && gpodnetPodcasts != null && gpodnetPodcasts.size() > 0) {
+ PodcastListAdapter listAdapter = new PodcastListAdapter(context, 0, gpodnetPodcasts);
+ gridView.setAdapter(listAdapter);
+ listAdapter.notifyDataSetChanged();
+
+ progressBar.setVisibility(View.GONE);
+ gridView.setVisibility(View.VISIBLE);
+ txtvError.setVisibility(View.GONE);
+ butRetry.setVisibility(View.GONE);
+ } else if (context != null && gpodnetPodcasts != null) {
+ gridView.setVisibility(View.GONE);
+ progressBar.setVisibility(View.GONE);
+ txtvError.setText(getString(R.string.search_status_no_results));
+ txtvError.setVisibility(View.VISIBLE);
+ butRetry.setVisibility(View.GONE);
+ } else if (context != null) {
+ gridView.setVisibility(View.GONE);
+ progressBar.setVisibility(View.GONE);
+ txtvError.setText(getString(R.string.error_msg_prefix) + exception.getMessage());
+ txtvError.setVisibility(View.VISIBLE);
+ butRetry.setVisibility(View.VISIBLE);
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ gridView.setVisibility(View.GONE);
+ progressBar.setVisibility(View.VISIBLE);
+ txtvError.setVisibility(View.GONE);
+ butRetry.setVisibility(View.GONE);
+ }
+ };
+
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ loaderTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ loaderTask.execute();
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java
new file mode 100644
index 000000000..5717a74e7
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastTopListFragment.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+
+import java.util.List;
+
+/**
+ *
+ */
+public class PodcastTopListFragment extends PodcastListFragment {
+ private static final String TAG = "PodcastTopListFragment";
+ private static final int PODCAST_COUNT = 50;
+
+ @Override
+ protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException {
+ return service.getPodcastToplist(PODCAST_COUNT);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java
new file mode 100644
index 000000000..801024787
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SearchListFragment.java
@@ -0,0 +1,80 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import android.os.Bundle;
+import android.support.v7.widget.SearchView;
+import android.view.Menu;
+import android.view.MenuInflater;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.List;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+/**
+ * Performs a search on the gpodder.net directory and displays the results.
+ */
+public class SearchListFragment extends PodcastListFragment {
+ private static final String ARG_QUERY = "query";
+
+ private String query;
+
+ public static SearchListFragment newInstance(String query) {
+ SearchListFragment fragment = new SearchListFragment();
+ Bundle args = new Bundle();
+ args.putString(ARG_QUERY, query);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (getArguments() != null && getArguments().containsKey(ARG_QUERY)) {
+ this.query = getArguments().getString(ARG_QUERY);
+ } else {
+ this.query = "";
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ final SearchView sv = new SearchView(getActivity());
+ if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.gpodnet_search_hint));
+ sv.setQuery(query, false);
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ sv.clearFocus();
+ changeQuery(s);
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException {
+ return service.searchPodcasts(query, 0);
+ }
+
+ public void changeQuery(String query) {
+ Validate.notNull(query);
+
+ this.query = query;
+ loadData();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java
new file mode 100644
index 000000000..45fe25580
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/SuggestionListFragment.java
@@ -0,0 +1,26 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Displays suggestions from gpodder.net
+ */
+public class SuggestionListFragment extends PodcastListFragment {
+ private static final int SUGGESTIONS_COUNT = 50;
+
+ @Override
+ protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException {
+ if (GpodnetPreferences.loggedIn()) {
+ service.authenticate(GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
+ return service.getSuggestions(SUGGESTIONS_COUNT);
+ } else {
+ return new ArrayList<GpodnetPodcast>();
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java
new file mode 100644
index 000000000..204dda992
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagFragment.java
@@ -0,0 +1,50 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import android.os.Bundle;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetTag;
+
+import java.util.List;
+
+/**
+ * Shows all podcasts from gpodder.net that belong to a specific tag.
+ * Use the newInstance method of this class to create a new TagFragment.
+ */
+public class TagFragment extends PodcastListFragment {
+
+ private static final String TAG = "TagFragment";
+ private static final int PODCAST_COUNT = 50;
+
+ private GpodnetTag tag;
+
+ public static TagFragment newInstance(String tagName) {
+ Validate.notNull(tagName);
+ TagFragment fragment = new TagFragment();
+ Bundle args = new Bundle();
+ args.putString("tag", tagName);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Bundle args = getArguments();
+ Validate.isTrue(args != null && args.getString("tag") != null, "args invalid");
+
+ tag = new GpodnetTag(args.getString("tag"));
+ ((MainActivity) getActivity()).getMainActivtyActionBar().setTitle(tag.getName());
+ }
+
+ @Override
+ protected List<GpodnetPodcast> loadPodcastData(GpodnetService service) throws GpodnetServiceException {
+ return service.getPodcastsForTag(tag, PODCAST_COUNT);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java
new file mode 100644
index 000000000..a7e1033df
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java
@@ -0,0 +1,146 @@
+package de.danoeh.antennapod.fragment.gpodnet;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.support.v4.app.ListFragment;
+import android.support.v7.widget.SearchView;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetTag;
+import de.danoeh.antennapod.util.menuhandler.MenuItemUtils;
+import de.danoeh.antennapod.util.menuhandler.NavDrawerActivity;
+
+public class TagListFragment extends ListFragment {
+ private static final String TAG = "TagListFragment";
+ private static final int COUNT = 50;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ super.onCreateOptionsMenu(menu, inflater);
+ if (!MenuItemUtils.isActivityDrawerOpen((NavDrawerActivity) getActivity())) {
+ final SearchView sv = new SearchView(getActivity());
+ MenuItemUtils.addSearchItem(menu, sv);
+ sv.setQueryHint(getString(R.string.gpodnet_search_hint));
+ sv.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String s) {
+ Activity activity = getActivity();
+ if (activity != null) {
+ sv.clearFocus();
+ ((MainActivity) activity).loadChildFragment(SearchListFragment.newInstance(s));
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String s) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+ String selectedTag = (String) getListAdapter().getItem(position);
+ MainActivity activity = (MainActivity) getActivity();
+ activity.loadChildFragment(TagFragment.newInstance(selectedTag));
+ }
+ });
+
+ startLoadTask();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ cancelLoadTask();
+ }
+
+ private AsyncTask<Void, Void, List<GpodnetTag>> loadTask;
+
+ private void cancelLoadTask() {
+ if (loadTask != null && !loadTask.isCancelled()) {
+ loadTask.cancel(true);
+ }
+ }
+
+ private void startLoadTask() {
+ cancelLoadTask();
+ loadTask = new AsyncTask<Void, Void, List<GpodnetTag>>() {
+ private Exception exception;
+
+ @Override
+ protected List<GpodnetTag> doInBackground(Void... params) {
+ GpodnetService service = new GpodnetService();
+ try {
+ return service.getTopTags(COUNT);
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ exception = e;
+ return null;
+ } finally {
+ service.shutdown();
+ }
+ }
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+ setListShown(false);
+ }
+
+ @Override
+ protected void onPostExecute(List<GpodnetTag> gpodnetTags) {
+ super.onPostExecute(gpodnetTags);
+ final Context context = getActivity();
+ if (context != null) {
+ if (gpodnetTags != null) {
+ List<String> tagNames = new ArrayList<String>();
+ for (GpodnetTag tag : gpodnetTags) {
+ tagNames.add(tag.getName());
+ }
+ setListAdapter(new ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, tagNames));
+ } else if (exception != null) {
+ TextView txtvError = new TextView(getActivity());
+ txtvError.setText(exception.getMessage());
+ getListView().setEmptyView(txtvError);
+ }
+ setListShown(true);
+
+ }
+ }
+ };
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ loadTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ loadTask.execute();
+ }
+ }
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java
new file mode 100644
index 000000000..038b2a367
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetService.java
@@ -0,0 +1,718 @@
+package de.danoeh.antennapod.gpoddernet;
+
+import org.apache.commons.lang3.Validate;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.auth.AuthenticationException;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.auth.BasicScheme;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+
+import de.danoeh.antennapod.gpoddernet.model.GpodnetDevice;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetSubscriptionChange;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetTag;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+import de.danoeh.antennapod.service.download.AntennapodHttpClient;
+
+/**
+ * Communicates with the gpodder.net service.
+ */
+public class GpodnetService {
+
+ private static final String BASE_SCHEME = "https";
+
+ public static final String DEFAULT_BASE_HOST = "gpodder.net";
+ private final String BASE_HOST;
+
+ private final HttpClient httpClient;
+
+ public GpodnetService() {
+ httpClient = AntennapodHttpClient.getHttpClient();
+ BASE_HOST = GpodnetPreferences.getHostname();
+ }
+
+ /**
+ * Returns the [count] most used tags.
+ */
+ public List<GpodnetTag> getTopTags(int count)
+ throws GpodnetServiceException {
+ URI uri;
+ try {
+ uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/tags/%d.json", count), null);
+ } catch (URISyntaxException e1) {
+ e1.printStackTrace();
+ throw new IllegalStateException(e1);
+ }
+
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+ try {
+ JSONArray jsonTagList = new JSONArray(response);
+ List<GpodnetTag> tagList = new ArrayList<GpodnetTag>(
+ jsonTagList.length());
+ for (int i = 0; i < jsonTagList.length(); i++) {
+ JSONObject jObj = jsonTagList.getJSONObject(i);
+ String name = jObj.getString("tag");
+ int usage = jObj.getInt("usage");
+ tagList.add(new GpodnetTag(name, usage));
+ }
+ return tagList;
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns the [count] most subscribed podcasts for the given tag.
+ *
+ * @throws IllegalArgumentException if tag is null
+ */
+ public List<GpodnetPodcast> getPodcastsForTag(GpodnetTag tag, int count)
+ throws GpodnetServiceException {
+ Validate.notNull(tag);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/tag/%s/%d.json", tag.getName(), count), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+
+ JSONArray jsonArray = new JSONArray(response);
+ return readPodcastListFromJSONArray(jsonArray);
+
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+
+ }
+ }
+
+ /**
+ * Returns the toplist of podcast.
+ *
+ * @param count of elements that should be returned. Must be in range 1..100.
+ * @throws IllegalArgumentException if count is out of range.
+ */
+ public List<GpodnetPodcast> getPodcastToplist(int count)
+ throws GpodnetServiceException {
+ Validate.isTrue(count >= 1 && count <= 100, "Count must be in range 1..100");
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/toplist/%d.json", count), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+
+ JSONArray jsonArray = new JSONArray(response);
+ return readPodcastListFromJSONArray(jsonArray);
+
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+
+ }
+ }
+
+ /**
+ * Returns a list of suggested podcasts for the user that is currently
+ * logged in.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param count The
+ * number of elements that should be returned. Must be in range
+ * 1..100.
+ * @throws IllegalArgumentException if count is out of range.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<GpodnetPodcast> getSuggestions(int count) throws GpodnetServiceException {
+ Validate.isTrue(count >= 1 && count <= 100, "Count must be in range 1..100");
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/suggestions/%d.json", count), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+
+ JSONArray jsonArray = new JSONArray(response);
+ return readPodcastListFromJSONArray(jsonArray);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Searches the podcast directory for a given string.
+ *
+ * @param query The search query
+ * @param scaledLogoSize The size of the logos that are returned by the search query.
+ * Must be in range 1..256. If the value is out of range, the
+ * default value defined by the gpodder.net API will be used.
+ */
+ public List<GpodnetPodcast> searchPodcasts(String query, int scaledLogoSize)
+ throws GpodnetServiceException {
+ String parameters = (scaledLogoSize > 0 && scaledLogoSize <= 256) ? String
+ .format("q=%s&scale_logo=%d", query, scaledLogoSize) : String
+ .format("q=%s", query);
+ try {
+ URI uri = new URI(BASE_SCHEME, null, BASE_HOST, -1, "/search.json",
+ parameters, null);
+ System.out.println(uri.toASCIIString());
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+
+ JSONArray jsonArray = new JSONArray(response);
+ return readPodcastListFromJSONArray(jsonArray);
+
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+
+ }
+ }
+
+ /**
+ * Returns all devices of a given user.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @throws IllegalArgumentException If username is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<GpodnetDevice> getDevices(String username)
+ throws GpodnetServiceException {
+ Validate.notNull(username);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/devices/%s.json", username), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+ JSONArray devicesArray = new JSONArray(response);
+ List<GpodnetDevice> result = readDeviceListFromJSONArray(devicesArray);
+
+ return result;
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Configures the device of a given user.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @param deviceId The ID of the device that should be configured.
+ * @throws IllegalArgumentException If username or deviceId is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public void configureDevice(String username, String deviceId,
+ String caption, GpodnetDevice.DeviceType type)
+ throws GpodnetServiceException {
+ Validate.notNull(username);
+ Validate.notNull(deviceId);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/devices/%s/%s.json", username, deviceId), null);
+ HttpPost request = new HttpPost(uri);
+ if (caption != null || type != null) {
+ JSONObject jsonContent = new JSONObject();
+ if (caption != null) {
+ jsonContent.put("caption", caption);
+ }
+ if (type != null) {
+ jsonContent.put("type", type.toString());
+ }
+ StringEntity strEntity = new StringEntity(
+ jsonContent.toString(), "UTF-8");
+ strEntity.setContentType("application/json");
+ request.setEntity(strEntity);
+ }
+ executeRequest(request);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalArgumentException(e);
+ } catch (UnsupportedEncodingException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns the subscriptions of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @param deviceId The ID of the device whose subscriptions should be returned.
+ * @return A list of subscriptions in OPML format.
+ * @throws IllegalArgumentException If username or deviceId is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public String getSubscriptionsOfDevice(String username, String deviceId)
+ throws GpodnetServiceException {
+ Validate.notNull(username);
+ Validate.notNull(deviceId);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/subscriptions/%s/%s.opml", username, deviceId), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+ return response;
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ /**
+ * Returns all subscriptions of a specific user.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @return A list of subscriptions in OPML format.
+ * @throws IllegalArgumentException If username is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public String getSubscriptionsOfUser(String username)
+ throws GpodnetServiceException {
+ Validate.notNull(username);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/subscriptions/%s.opml", username), null);
+ HttpGet request = new HttpGet(uri);
+ String response = executeRequest(request);
+ return response;
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ /**
+ * Uploads the subscriptions of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @param deviceId The ID of the device whose subscriptions should be updated.
+ * @param subscriptions A list of feed URLs containing all subscriptions of the
+ * device.
+ * @throws IllegalArgumentException If username, deviceId or subscriptions is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public void uploadSubscriptions(String username, String deviceId,
+ List<String> subscriptions) throws GpodnetServiceException {
+ if (username == null || deviceId == null || subscriptions == null) {
+ throw new IllegalArgumentException(
+ "Username, device ID and subscriptions must not be null");
+ }
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/subscriptions/%s/%s.txt", username, deviceId), null);
+ HttpPut request = new HttpPut(uri);
+ StringBuilder builder = new StringBuilder();
+ for (String s : subscriptions) {
+ builder.append(s);
+ builder.append("\n");
+ }
+ StringEntity entity = new StringEntity(builder.toString(), "UTF-8");
+ request.setEntity(entity);
+
+ executeRequest(request);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (UnsupportedEncodingException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * Updates the subscription list of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @param deviceId The ID of the device whose subscriptions should be updated.
+ * @param added Collection of feed URLs of added feeds. This Collection MUST NOT contain any duplicates
+ * @param removed Collection of feed URLs of removed feeds. This Collection MUST NOT contain any duplicates
+ * @return a GpodnetUploadChangesResponse. See {@link de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse}
+ * for details.
+ * @throws java.lang.IllegalArgumentException if username, deviceId, added or removed is null.
+ * @throws de.danoeh.antennapod.gpoddernet.GpodnetServiceException if added or removed contain duplicates or if there
+ * is an authentication error.
+ */
+ public GpodnetUploadChangesResponse uploadChanges(String username, String deviceId, Collection<String> added,
+ Collection<String> removed) throws GpodnetServiceException {
+ Validate.notNull(username);
+ Validate.notNull(deviceId);
+ Validate.notNull(added);
+ Validate.notNull(removed);
+
+ try {
+ URI uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/subscriptions/%s/%s.json", username, deviceId), null);
+
+ final JSONObject requestObject = new JSONObject();
+ requestObject.put("add", new JSONArray(added));
+ requestObject.put("remove", new JSONArray(removed));
+
+ HttpPost request = new HttpPost(uri);
+ StringEntity entity = new StringEntity(requestObject.toString(), "UTF-8");
+ request.setEntity(entity);
+
+ final String response = executeRequest(request);
+ return GpodnetUploadChangesResponse.fromJSONObject(response);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (UnsupportedEncodingException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ }
+
+ }
+
+ /**
+ * Returns all subscription changes of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param username The username. Must be the same user as the one which is
+ * currently logged in.
+ * @param deviceId The ID of the device whose subscription changes should be
+ * downloaded.
+ * @param timestamp A timestamp that can be used to receive all changes since a
+ * specific point in time.
+ * @throws IllegalArgumentException If username or deviceId is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public GpodnetSubscriptionChange getSubscriptionChanges(String username,
+ String deviceId, long timestamp) throws GpodnetServiceException {
+ Validate.notNull(username);
+ Validate.notNull(deviceId);
+
+ String params = String.format("since=%d", timestamp);
+ String path = String.format("/api/2/subscriptions/%s/%s.json",
+ username, deviceId);
+ try {
+ URI uri = new URI(BASE_SCHEME, null, BASE_HOST, -1, path, params,
+ null);
+ HttpGet request = new HttpGet(uri);
+
+ String response = executeRequest(request);
+ JSONObject changes = new JSONObject(response);
+ return readSubscriptionChangesFromJSONObject(changes);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+
+ }
+
+ /**
+ * Logs in a specific user. This method must be called if any of the methods
+ * that require authentication is used.
+ *
+ * @throws IllegalArgumentException If username or password is null.
+ */
+ public void authenticate(String username, String password)
+ throws GpodnetServiceException {
+ Validate.notNull(username);
+ Validate.notNull(password);
+
+ URI uri;
+ try {
+ uri = new URI(BASE_SCHEME, BASE_HOST, String.format(
+ "/api/2/auth/%s/login.json", username), null);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException();
+ }
+ HttpPost request = new HttpPost(uri);
+ executeRequestWithAuthentication(request, username, password);
+ }
+
+ /**
+ * Shuts down the GpodnetService's HTTP client. The service will be shut down in a separate thread to avoid
+ * NetworkOnMainThreadExceptions.
+ */
+ public void shutdown() {
+ new Thread() {
+ @Override
+ public void run() {
+ AntennapodHttpClient.cleanup();
+ }
+ }.start();
+ }
+
+ private String executeRequest(HttpRequestBase request)
+ throws GpodnetServiceException {
+ Validate.notNull(request);
+
+ String responseString = null;
+ HttpResponse response = null;
+ try {
+ response = httpClient.execute(request);
+ checkStatusCode(response);
+ responseString = getStringFromEntity(response.getEntity());
+ } catch (ClientProtocolException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } finally {
+ if (response != null) {
+ try {
+ response.getEntity().consumeContent();
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ }
+ return responseString;
+ }
+
+ private String executeRequestWithAuthentication(HttpRequestBase request,
+ String username, String password) throws GpodnetServiceException {
+ if (request == null || username == null || password == null) {
+ throw new IllegalArgumentException(
+ "request and credentials must not be null");
+ }
+ String result = null;
+ HttpResponse response = null;
+ try {
+ Header auth = new BasicScheme().authenticate(
+ new UsernamePasswordCredentials(username, password),
+ request);
+ request.addHeader(auth);
+ response = httpClient.execute(request);
+ checkStatusCode(response);
+ result = getStringFromEntity(response.getEntity());
+ } catch (ClientProtocolException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } catch (AuthenticationException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } finally {
+ if (response != null) {
+ try {
+ response.getEntity().consumeContent();
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+ }
+ return result;
+ }
+
+ private String getStringFromEntity(HttpEntity entity)
+ throws GpodnetServiceException {
+ Validate.notNull(entity);
+
+ ByteArrayOutputStream outputStream;
+ int contentLength = (int) entity.getContentLength();
+ if (contentLength > 0) {
+ outputStream = new ByteArrayOutputStream(contentLength);
+ } else {
+ outputStream = new ByteArrayOutputStream();
+ }
+ try {
+ byte[] buffer = new byte[8 * 1024];
+ InputStream in = entity.getContent();
+ int count;
+ while ((count = in.read(buffer)) > 0) {
+ outputStream.write(buffer, 0, count);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ // System.out.println(outputStream.toString());
+ return outputStream.toString();
+ }
+
+ private void checkStatusCode(HttpResponse response)
+ throws GpodnetServiceException {
+ Validate.notNull(response);
+ int responseCode = response.getStatusLine().getStatusCode();
+ if (responseCode != HttpStatus.SC_OK) {
+ if (responseCode == HttpStatus.SC_UNAUTHORIZED) {
+ throw new GpodnetServiceAuthenticationException("Wrong username or password");
+ } else {
+ throw new GpodnetServiceBadStatusCodeException(
+ "Bad response code: " + responseCode, responseCode);
+ }
+ }
+ }
+
+ private List<GpodnetPodcast> readPodcastListFromJSONArray(JSONArray array)
+ throws JSONException {
+ Validate.notNull(array);
+
+ List<GpodnetPodcast> result = new ArrayList<GpodnetPodcast>(
+ array.length());
+ for (int i = 0; i < array.length(); i++) {
+ result.add(readPodcastFromJSONObject(array.getJSONObject(i)));
+ }
+ return result;
+
+ }
+
+ private GpodnetPodcast readPodcastFromJSONObject(JSONObject object)
+ throws JSONException {
+ String url = object.getString("url");
+
+ String title;
+ Object titleObj = object.opt("title");
+ if (titleObj != null && titleObj instanceof String) {
+ title = (String) titleObj;
+ } else {
+ title = url;
+ }
+
+ String description;
+ Object descriptionObj = object.opt("description");
+ if (descriptionObj != null && descriptionObj instanceof String) {
+ description = (String) descriptionObj;
+ } else {
+ description = "";
+ }
+
+ int subscribers = object.getInt("subscribers");
+
+ Object logoUrlObj = object.opt("logo_url");
+ String logoUrl = (logoUrlObj instanceof String) ? (String) logoUrlObj
+ : null;
+ if (logoUrl == null) {
+ Object scaledLogoUrl = object.opt("scaled_logo_url");
+ if (scaledLogoUrl != null && scaledLogoUrl instanceof String) {
+ logoUrl = (String) scaledLogoUrl;
+ }
+ }
+
+ String website = null;
+ Object websiteObj = object.opt("website");
+ if (websiteObj != null && websiteObj instanceof String) {
+ website = (String) websiteObj;
+ }
+ String mygpoLink = object.getString("mygpo_link");
+ return new GpodnetPodcast(url, title, description, subscribers,
+ logoUrl, website, mygpoLink);
+ }
+
+ private List<GpodnetDevice> readDeviceListFromJSONArray(JSONArray array)
+ throws JSONException {
+ Validate.notNull(array);
+
+ List<GpodnetDevice> result = new ArrayList<GpodnetDevice>(
+ array.length());
+ for (int i = 0; i < array.length(); i++) {
+ result.add(readDeviceFromJSONObject(array.getJSONObject(i)));
+ }
+ return result;
+ }
+
+ private GpodnetDevice readDeviceFromJSONObject(JSONObject object)
+ throws JSONException {
+ String id = object.getString("id");
+ String caption = object.getString("caption");
+ String type = object.getString("type");
+ int subscriptions = object.getInt("subscriptions");
+ return new GpodnetDevice(id, caption, type, subscriptions);
+ }
+
+ private GpodnetSubscriptionChange readSubscriptionChangesFromJSONObject(
+ JSONObject object) throws JSONException {
+ Validate.notNull(object);
+
+ List<String> added = new LinkedList<String>();
+ JSONArray jsonAdded = object.getJSONArray("add");
+ for (int i = 0; i < jsonAdded.length(); i++) {
+ added.add(jsonAdded.getString(i));
+ }
+
+ List<String> removed = new LinkedList<String>();
+ JSONArray jsonRemoved = object.getJSONArray("remove");
+ for (int i = 0; i < jsonRemoved.length(); i++) {
+ removed.add(jsonRemoved.getString(i));
+ }
+
+ long timestamp = object.getLong("timestamp");
+ return new GpodnetSubscriptionChange(added, removed, timestamp);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java
new file mode 100644
index 000000000..3b0140826
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceAuthenticationException.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.gpoddernet;
+
+public class GpodnetServiceAuthenticationException extends GpodnetServiceException {
+
+ public GpodnetServiceAuthenticationException() {
+ super();
+ }
+
+ public GpodnetServiceAuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public GpodnetServiceAuthenticationException(String message) {
+ super(message);
+ }
+
+ public GpodnetServiceAuthenticationException(Throwable cause) {
+ super(cause);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java
new file mode 100644
index 000000000..a32e9357b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceBadStatusCodeException.java
@@ -0,0 +1,12 @@
+package de.danoeh.antennapod.gpoddernet;
+
+public class GpodnetServiceBadStatusCodeException extends GpodnetServiceException {
+ int statusCode;
+
+ public GpodnetServiceBadStatusCodeException(String message, int statusCode) {
+ super(message);
+ this.statusCode = statusCode;
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java
new file mode 100644
index 000000000..bdb394454
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/GpodnetServiceException.java
@@ -0,0 +1,19 @@
+package de.danoeh.antennapod.gpoddernet;
+
+public class GpodnetServiceException extends Exception {
+
+ public GpodnetServiceException() {
+ }
+
+ public GpodnetServiceException(String message) {
+ super(message);
+ }
+
+ public GpodnetServiceException(Throwable cause) {
+ super(cause);
+ }
+
+ public GpodnetServiceException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java
new file mode 100644
index 000000000..86a2171fa
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetDevice.java
@@ -0,0 +1,72 @@
+package de.danoeh.antennapod.gpoddernet.model;
+
+import org.apache.commons.lang3.Validate;
+
+public class GpodnetDevice {
+
+ private String id;
+ private String caption;
+ private DeviceType type;
+ private int subscriptions;
+
+ public GpodnetDevice(String id, String caption, String type,
+ int subscriptions) {
+ Validate.notNull(id);
+
+ this.id = id;
+ this.caption = caption;
+ this.type = DeviceType.fromString(type);
+ this.subscriptions = subscriptions;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetDevice [id=" + id + ", caption=" + caption + ", type="
+ + type + ", subscriptions=" + subscriptions + "]";
+ }
+
+ public static enum DeviceType {
+ DESKTOP, LAPTOP, MOBILE, SERVER, OTHER;
+
+ static DeviceType fromString(String s) {
+ if (s == null) {
+ return OTHER;
+ }
+
+ if (s.equals("desktop")) {
+ return DESKTOP;
+ } else if (s.equals("laptop")) {
+ return LAPTOP;
+ } else if (s.equals("mobile")) {
+ return MOBILE;
+ } else if (s.equals("server")) {
+ return SERVER;
+ } else {
+ return OTHER;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase();
+ }
+
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getCaption() {
+ return caption;
+ }
+
+ public DeviceType getType() {
+ return type;
+ }
+
+ public int getSubscriptions() {
+ return subscriptions;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java
new file mode 100644
index 000000000..b002035c9
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetPodcast.java
@@ -0,0 +1,65 @@
+package de.danoeh.antennapod.gpoddernet.model;
+
+import org.apache.commons.lang3.Validate;
+
+public class GpodnetPodcast {
+ private String url;
+ private String title;
+ private String description;
+ private int subscribers;
+ private String logoUrl;
+ private String website;
+ private String mygpoLink;
+
+ public GpodnetPodcast(String url, String title, String description,
+ int subscribers, String logoUrl, String website, String mygpoLink) {
+ Validate.notNull(url);
+ Validate.notNull(title);
+ Validate.notNull(description);
+
+ this.url = url;
+ this.title = title;
+ this.description = description;
+ this.subscribers = subscribers;
+ this.logoUrl = logoUrl;
+ this.website = website;
+ this.mygpoLink = mygpoLink;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetPodcast [url=" + url + ", title=" + title
+ + ", description=" + description + ", subscribers="
+ + subscribers + ", logoUrl=" + logoUrl + ", website=" + website
+ + ", mygpoLink=" + mygpoLink + "]";
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public int getSubscribers() {
+ return subscribers;
+ }
+
+ public String getLogoUrl() {
+ return logoUrl;
+ }
+
+ public String getWebsite() {
+ return website;
+ }
+
+ public String getMygpoLink() {
+ return mygpoLink;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java
new file mode 100644
index 000000000..a4617118d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetSubscriptionChange.java
@@ -0,0 +1,41 @@
+package de.danoeh.antennapod.gpoddernet.model;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.List;
+
+public class GpodnetSubscriptionChange {
+ private List<String> added;
+ private List<String> removed;
+ private long timestamp;
+
+ public GpodnetSubscriptionChange(List<String> added, List<String> removed,
+ long timestamp) {
+ Validate.notNull(added);
+ Validate.notNull(removed);
+
+ this.added = added;
+ this.removed = removed;
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetSubscriptionChange [added=" + added.toString()
+ + ", removed=" + removed.toString() + ", timestamp="
+ + timestamp + "]";
+ }
+
+ public List<String> getAdded() {
+ return added;
+ }
+
+ public List<String> getRemoved() {
+ return removed;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java
new file mode 100644
index 000000000..80b84095e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetTag.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.gpoddernet.model;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.Comparator;
+
+public class GpodnetTag {
+
+ private String name;
+ private int usage;
+
+ public GpodnetTag(String name, int usage) {
+ Validate.notNull(name);
+
+ this.name = name;
+ this.usage = usage;
+ }
+
+ public GpodnetTag(String name) {
+ super();
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetTag [name=" + name + ", usage=" + usage + "]";
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getUsage() {
+ return usage;
+ }
+
+ public static class UsageComparator implements Comparator<GpodnetTag> {
+
+ @Override
+ public int compare(GpodnetTag o1, GpodnetTag o2) {
+ return o1.usage - o2.usage;
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java
new file mode 100644
index 000000000..fee8c7d28
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/gpoddernet/model/GpodnetUploadChangesResponse.java
@@ -0,0 +1,56 @@
+package de.danoeh.antennapod.gpoddernet.model;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Object returned by {@link de.danoeh.antennapod.gpoddernet.GpodnetService} in uploadChanges method.
+ */
+public class GpodnetUploadChangesResponse {
+
+ /**
+ * timestamp/ID that can be used for requesting changes since this upload.
+ */
+ public final long timestamp;
+
+ /**
+ * URLs that should be updated. The key of the map is the original URL, the value of the map
+ * is the sanitized URL.
+ */
+ public final Map<String, String> updatedUrls;
+
+ public GpodnetUploadChangesResponse(long timestamp, Map<String, String> updatedUrls) {
+ this.timestamp = timestamp;
+ this.updatedUrls = updatedUrls;
+ }
+
+ /**
+ * Creates a new GpodnetUploadChangesResponse-object from a JSON object that was
+ * returned by an uploadChanges call.
+ *
+ * @throws org.json.JSONException If the method could not parse the JSONObject.
+ */
+ public static GpodnetUploadChangesResponse fromJSONObject(String objectString) throws JSONException {
+ final JSONObject object = new JSONObject(objectString);
+ final long timestamp = object.getLong("timestamp");
+ Map<String, String> updatedUrls = new HashMap<String, String>();
+ JSONArray urls = object.getJSONArray("update_urls");
+ for (int i = 0; i < urls.length(); i++) {
+ JSONArray urlPair = urls.getJSONArray(i);
+ updatedUrls.put(urlPair.getString(0), urlPair.getString(1));
+ }
+ return new GpodnetUploadChangesResponse(timestamp, updatedUrls);
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetUploadChangesResponse{" +
+ "timestamp=" + timestamp +
+ ", updatedUrls=" + updatedUrls +
+ '}';
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java
new file mode 100644
index 000000000..4cb563c04
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlElement.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.opml;
+
+/** Represents a single feed in an OPML file. */
+public class OpmlElement {
+ private String text;
+ private String xmlUrl;
+ private String htmlUrl;
+ private String type;
+
+ public OpmlElement() {
+
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ public String getXmlUrl() {
+ return xmlUrl;
+ }
+
+ public void setXmlUrl(String xmlUrl) {
+ this.xmlUrl = xmlUrl;
+ }
+
+ public String getHtmlUrl() {
+ return htmlUrl;
+ }
+
+ public void setHtmlUrl(String htmlUrl) {
+ this.htmlUrl = htmlUrl;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java
new file mode 100644
index 000000000..19a980dee
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlReader.java
@@ -0,0 +1,87 @@
+package de.danoeh.antennapod.opml;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+
+/** Reads OPML documents. */
+public class OpmlReader {
+ private static final String TAG = "OpmlReader";
+
+ // ATTRIBUTES
+ private boolean isInOpml = false;
+ private ArrayList<OpmlElement> elementList;
+
+ /**
+ * Reads an Opml document and returns a list of all OPML elements it can
+ * find
+ *
+ * @throws IOException
+ * @throws XmlPullParserException
+ */
+ public ArrayList<OpmlElement> readDocument(Reader reader)
+ throws XmlPullParserException, IOException {
+ elementList = new ArrayList<OpmlElement>();
+ XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+ XmlPullParser xpp = factory.newPullParser();
+ xpp.setInput(reader);
+ int eventType = xpp.getEventType();
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ switch (eventType) {
+ case XmlPullParser.START_DOCUMENT:
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Reached beginning of document");
+ break;
+ case XmlPullParser.START_TAG:
+ if (xpp.getName().equals(OpmlSymbols.OPML)) {
+ isInOpml = true;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Reached beginning of OPML tree.");
+ } else if (isInOpml && xpp.getName().equals(OpmlSymbols.OUTLINE)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Found new Opml element");
+ OpmlElement element = new OpmlElement();
+
+ final String title = xpp.getAttributeValue(null, OpmlSymbols.TITLE);
+ if (title != null) {
+ Log.i(TAG, "Using title: " + title);
+ element.setText(title);
+ } else {
+ Log.i(TAG, "Title not found, using text");
+ element.setText(xpp.getAttributeValue(null, OpmlSymbols.TEXT));
+ }
+ element.setXmlUrl(xpp.getAttributeValue(null, OpmlSymbols.XMLURL));
+ element.setHtmlUrl(xpp.getAttributeValue(null, OpmlSymbols.HTMLURL));
+ element.setType(xpp.getAttributeValue(null, OpmlSymbols.TYPE));
+ if (element.getXmlUrl() != null) {
+ if (element.getText() == null) {
+ Log.i(TAG, "Opml element has no text attribute.");
+ element.setText(element.getXmlUrl());
+ }
+ elementList.add(element);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Skipping element because of missing xml url");
+ }
+ }
+ break;
+ }
+ eventType = xpp.next();
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Parsing finished.");
+
+ return elementList;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java
new file mode 100644
index 000000000..4b0b7316a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlSymbols.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.opml;
+
+/** Contains symbols for reading and writing OPML documents. */
+public final class OpmlSymbols {
+
+ public static final String OPML = "opml";
+ public static final String BODY = "body";
+ public static final String OUTLINE = "outline";
+ public static final String TEXT = "text";
+ public static final String XMLURL = "xmlUrl";
+ public static final String HTMLURL = "htmlUrl";
+ public static final String TYPE = "type";
+ public static final String VERSION = "version";
+ public static final String HEAD = "head";
+ public static final String TITLE = "title";
+
+ private OpmlSymbols() {
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java b/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java
new file mode 100644
index 000000000..405a5e35a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/opml/OpmlWriter.java
@@ -0,0 +1,65 @@
+package de.danoeh.antennapod.opml;
+
+import android.util.Log;
+import android.util.Xml;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Feed;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.util.List;
+
+/** Writes OPML documents. */
+public class OpmlWriter {
+ private static final String TAG = "OpmlWriter";
+ private static final String ENCODING = "UTF-8";
+ private static final String OPML_VERSION = "2.0";
+ private static final String OPML_TITLE = "AntennaPod Subscriptions";
+
+ /**
+ * Takes a list of feeds and a writer and writes those into an OPML
+ * document.
+ *
+ * @throws IOException
+ * @throws IllegalStateException
+ * @throws IllegalArgumentException
+ */
+ public void writeDocument(List<Feed> feeds, Writer writer)
+ throws IllegalArgumentException, IllegalStateException, IOException {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting to write document");
+ XmlSerializer xs = Xml.newSerializer();
+ xs.setOutput(writer);
+
+ xs.startDocument(ENCODING, false);
+ xs.startTag(null, OpmlSymbols.OPML);
+ xs.attribute(null, OpmlSymbols.VERSION, OPML_VERSION);
+
+ xs.startTag(null, OpmlSymbols.HEAD);
+ xs.startTag(null, OpmlSymbols.TITLE);
+ xs.text(OPML_TITLE);
+ xs.endTag(null, OpmlSymbols.TITLE);
+ xs.endTag(null, OpmlSymbols.HEAD);
+
+ xs.startTag(null, OpmlSymbols.BODY);
+ for (Feed feed : feeds) {
+ xs.startTag(null, OpmlSymbols.OUTLINE);
+ xs.attribute(null, OpmlSymbols.TEXT, feed.getTitle());
+ xs.attribute(null, OpmlSymbols.TITLE, feed.getTitle());
+ if (feed.getType() != null) {
+ xs.attribute(null, OpmlSymbols.TYPE, feed.getType());
+ }
+ xs.attribute(null, OpmlSymbols.XMLURL, feed.getDownload_url());
+ if (feed.getLink() != null) {
+ xs.attribute(null, OpmlSymbols.HTMLURL, feed.getLink());
+ }
+ xs.endTag(null, OpmlSymbols.OUTLINE);
+ }
+ xs.endTag(null, OpmlSymbols.BODY);
+ xs.endTag(null, OpmlSymbols.OPML);
+ xs.endDocument();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Finished writing document");
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java
new file mode 100644
index 000000000..bdfe297a6
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/preferences/GpodnetPreferences.java
@@ -0,0 +1,246 @@
+package de.danoeh.antennapod.preferences;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.service.GpodnetSyncService;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Manages preferences for accessing gpodder.net service
+ */
+public class GpodnetPreferences {
+
+ private static final String TAG = "GpodnetPreferences";
+
+ private static final String PREF_NAME = "gpodder.net";
+ public static final String PREF_GPODNET_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username";
+ public static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password";
+ public static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID";
+ public static final String PREF_GPODNET_HOSTNAME = "prefGpodnetHostname";
+
+
+ public static final String PREF_LAST_SYNC_TIMESTAMP = "de.danoeh.antennapod.preferences.gpoddernet.last_sync_timestamp";
+ public static final String PREF_SYNC_ADDED = "de.danoeh.antennapod.preferences.gpoddernet.sync_added";
+ public static final String PREF_SYNC_REMOVED = "de.danoeh.antennapod.preferences.gpoddernet.sync_removed";
+
+ private static String username;
+ private static String password;
+ private static String deviceID;
+ private static String hostname;
+
+ private static ReentrantLock feedListLock = new ReentrantLock();
+ private static Set<String> addedFeeds;
+ private static Set<String> removedFeeds;
+
+ /**
+ * Last value returned by getSubscriptionChanges call. Will be used for all subsequent calls of getSubscriptionChanges.
+ */
+ private static long lastSyncTimestamp;
+
+ private static boolean preferencesLoaded = false;
+
+ private static SharedPreferences getPreferences() {
+ return PodcastApp.getInstance().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
+ }
+
+ private static synchronized void ensurePreferencesLoaded() {
+ if (!preferencesLoaded) {
+ SharedPreferences prefs = getPreferences();
+ username = prefs.getString(PREF_GPODNET_USERNAME, null);
+ password = prefs.getString(PREF_GPODNET_PASSWORD, null);
+ deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null);
+ lastSyncTimestamp = prefs.getLong(PREF_LAST_SYNC_TIMESTAMP, 0);
+ addedFeeds = readListFromString(prefs.getString(PREF_SYNC_ADDED, ""));
+ removedFeeds = readListFromString(prefs.getString(PREF_SYNC_REMOVED, ""));
+ hostname = checkGpodnetHostname(prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST));
+
+ preferencesLoaded = true;
+ }
+ }
+
+ private static void writePreference(String key, String value) {
+ SharedPreferences.Editor editor = getPreferences().edit();
+ editor.putString(key, value);
+ editor.commit();
+ }
+
+ private static void writePreference(String key, long value) {
+ SharedPreferences.Editor editor = getPreferences().edit();
+ editor.putLong(key, value);
+ editor.commit();
+ }
+
+ private static void writePreference(String key, Collection<String> value) {
+ SharedPreferences.Editor editor = getPreferences().edit();
+ editor.putString(key, writeListToString(value));
+ editor.commit();
+ }
+
+ public static String getUsername() {
+ ensurePreferencesLoaded();
+ return username;
+ }
+
+ public static void setUsername(String username) {
+ GpodnetPreferences.username = username;
+ writePreference(PREF_GPODNET_USERNAME, username);
+ }
+
+ public static String getPassword() {
+ ensurePreferencesLoaded();
+ return password;
+ }
+
+ public static void setPassword(String password) {
+ GpodnetPreferences.password = password;
+ writePreference(PREF_GPODNET_PASSWORD, password);
+ }
+
+ public static String getDeviceID() {
+ ensurePreferencesLoaded();
+ return deviceID;
+ }
+
+ public static void setDeviceID(String deviceID) {
+ GpodnetPreferences.deviceID = deviceID;
+ writePreference(PREF_GPODNET_DEVICEID, deviceID);
+ }
+
+ public static long getLastSyncTimestamp() {
+ ensurePreferencesLoaded();
+ return lastSyncTimestamp;
+ }
+
+ public static void setLastSyncTimestamp(long lastSyncTimestamp) {
+ GpodnetPreferences.lastSyncTimestamp = lastSyncTimestamp;
+ writePreference(PREF_LAST_SYNC_TIMESTAMP, lastSyncTimestamp);
+ }
+
+ public static String getHostname() {
+ ensurePreferencesLoaded();
+ return hostname;
+ }
+
+ public static void setHostname(String value) {
+ value = checkGpodnetHostname(value);
+ if (!value.equals(hostname)) {
+ logout();
+ writePreference(PREF_GPODNET_HOSTNAME, value);
+ hostname = value;
+ }
+ }
+
+ public static void addAddedFeed(String feed) {
+ ensurePreferencesLoaded();
+ feedListLock.lock();
+ if (addedFeeds.add(feed)) {
+ writePreference(PREF_SYNC_ADDED, addedFeeds);
+ }
+ if (removedFeeds.remove(feed)) {
+ writePreference(PREF_SYNC_REMOVED, removedFeeds);
+ }
+ feedListLock.unlock();
+ GpodnetSyncService.sendSyncIntent(PodcastApp.getInstance());
+ }
+
+ public static void addRemovedFeed(String feed) {
+ ensurePreferencesLoaded();
+ feedListLock.lock();
+ if (removedFeeds.add(feed)) {
+ writePreference(PREF_SYNC_REMOVED, removedFeeds);
+ }
+ if (addedFeeds.remove(feed)) {
+ writePreference(PREF_SYNC_ADDED, addedFeeds);
+ }
+ feedListLock.unlock();
+ GpodnetSyncService.sendSyncIntent(PodcastApp.getInstance());
+ }
+
+ public static Set<String> getAddedFeedsCopy() {
+ ensurePreferencesLoaded();
+ Set<String> copy = new HashSet<String>();
+ feedListLock.lock();
+ copy.addAll(addedFeeds);
+ feedListLock.unlock();
+ return copy;
+ }
+
+ public static void removeAddedFeeds(Collection<String> removed) {
+ ensurePreferencesLoaded();
+ feedListLock.lock();
+ addedFeeds.removeAll(removed);
+ writePreference(PREF_SYNC_ADDED, addedFeeds);
+ feedListLock.unlock();
+ }
+
+ public static Set<String> getRemovedFeedsCopy() {
+ ensurePreferencesLoaded();
+ Set<String> copy = new HashSet<String>();
+ feedListLock.lock();
+ copy.addAll(removedFeeds);
+ feedListLock.unlock();
+ return copy;
+ }
+
+ public static void removeRemovedFeeds(Collection<String> removed) {
+ ensurePreferencesLoaded();
+ removedFeeds.removeAll(removed);
+ writePreference(PREF_SYNC_REMOVED, removedFeeds);
+
+ }
+
+ /**
+ * Returns true if device ID, username and password have a non-null value
+ */
+ public static boolean loggedIn() {
+ ensurePreferencesLoaded();
+ return deviceID != null && username != null && password != null;
+ }
+
+ public static synchronized void logout() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Logout: Clearing preferences");
+ setUsername(null);
+ setPassword(null);
+ setDeviceID(null);
+ addedFeeds.clear();
+ writePreference(PREF_SYNC_ADDED, addedFeeds);
+ removedFeeds.clear();
+ writePreference(PREF_SYNC_REMOVED, removedFeeds);
+ setLastSyncTimestamp(0);
+ }
+
+ private static Set<String> readListFromString(String s) {
+ Set<String> result = new HashSet<String>();
+ for (String item : s.split(" ")) {
+ result.add(item);
+ }
+ return result;
+ }
+
+ private static String writeListToString(Collection<String> c) {
+ StringBuilder result = new StringBuilder();
+ for (String item : c) {
+ result.append(item);
+ result.append(" ");
+ }
+ return result.toString().trim();
+ }
+
+ private static String checkGpodnetHostname(String value) {
+ int startIndex = 0;
+ if (value.startsWith("http://")) {
+ startIndex = "http://".length();
+ } else if (value.startsWith("https://")) {
+ startIndex = "https://".length();
+ }
+ return value.substring(startIndex);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java
new file mode 100644
index 000000000..1d1ab052f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/preferences/PlaybackPreferences.java
@@ -0,0 +1,146 @@
+package de.danoeh.antennapod.preferences;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+
+/**
+ * Provides access to preferences set by the playback service. A private
+ * instance of this class must first be instantiated via createInstance() or
+ * otherwise every public method will throw an Exception when called.
+ */
+public class PlaybackPreferences implements
+ SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = "PlaybackPreferences";
+
+ /**
+ * Contains the feed id of the currently playing item if it is a FeedMedia
+ * object.
+ */
+ public static final String PREF_CURRENTLY_PLAYING_FEED_ID = "de.danoeh.antennapod.preferences.lastPlayedFeedId";
+
+ /**
+ * Contains the id of the currently playing FeedMedia object or
+ * NO_MEDIA_PLAYING if the currently playing media is no FeedMedia object.
+ */
+ public static final String PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID = "de.danoeh.antennapod.preferences.lastPlayedFeedMediaId";
+
+ /**
+ * Type of the media object that is currently being played. This preference
+ * is set to NO_MEDIA_PLAYING after playback has been completed and is set
+ * as soon as the 'play' button is pressed.
+ */
+ public static final String PREF_CURRENTLY_PLAYING_MEDIA = "de.danoeh.antennapod.preferences.currentlyPlayingMedia";
+
+ /** True if last played media was streamed. */
+ public static final String PREF_CURRENT_EPISODE_IS_STREAM = "de.danoeh.antennapod.preferences.lastIsStream";
+
+ /** True if last played media was a video. */
+ public static final String PREF_CURRENT_EPISODE_IS_VIDEO = "de.danoeh.antennapod.preferences.lastIsVideo";
+
+ /** Value of PREF_CURRENTLY_PLAYING_MEDIA if no media is playing. */
+ public static final long NO_MEDIA_PLAYING = -1;
+
+ private long currentlyPlayingFeedId;
+ private long currentlyPlayingFeedMediaId;
+ private long currentlyPlayingMedia;
+ private boolean currentEpisodeIsStream;
+ private boolean currentEpisodeIsVideo;
+
+ private static PlaybackPreferences instance;
+ private Context context;
+
+ private PlaybackPreferences(Context context) {
+ this.context = context;
+ loadPreferences();
+ }
+
+ /**
+ * Sets up the UserPreferences class.
+ *
+ * @throws IllegalArgumentException
+ * if context is null
+ * */
+ public static void createInstance(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating new instance of UserPreferences");
+ Validate.notNull(context);
+
+ instance = new PlaybackPreferences(context);
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .registerOnSharedPreferenceChangeListener(instance);
+ }
+
+ private void loadPreferences() {
+ SharedPreferences sp = PreferenceManager
+ .getDefaultSharedPreferences(context);
+ currentlyPlayingFeedId = sp.getLong(PREF_CURRENTLY_PLAYING_FEED_ID, -1);
+ currentlyPlayingFeedMediaId = sp.getLong(
+ PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, NO_MEDIA_PLAYING);
+ currentlyPlayingMedia = sp.getLong(PREF_CURRENTLY_PLAYING_MEDIA,
+ NO_MEDIA_PLAYING);
+ currentEpisodeIsStream = sp.getBoolean(PREF_CURRENT_EPISODE_IS_STREAM, true);
+ currentEpisodeIsVideo = sp.getBoolean(PREF_CURRENT_EPISODE_IS_VIDEO, false);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sp, String key) {
+ if (key.equals(PREF_CURRENTLY_PLAYING_FEED_ID)) {
+ currentlyPlayingFeedId = sp.getLong(PREF_CURRENTLY_PLAYING_FEED_ID,
+ -1);
+
+ } else if (key.equals(PREF_CURRENTLY_PLAYING_MEDIA)) {
+ currentlyPlayingMedia = sp
+ .getLong(PREF_CURRENTLY_PLAYING_MEDIA, -1);
+
+ } else if (key.equals(PREF_CURRENT_EPISODE_IS_STREAM)) {
+ currentEpisodeIsStream = sp.getBoolean(PREF_CURRENT_EPISODE_IS_STREAM, true);
+
+ } else if (key.equals(PREF_CURRENT_EPISODE_IS_VIDEO)) {
+ currentEpisodeIsVideo = sp.getBoolean(PREF_CURRENT_EPISODE_IS_VIDEO, false);
+
+ } else if (key.equals(PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID)) {
+ currentlyPlayingFeedMediaId = sp.getLong(
+ PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, NO_MEDIA_PLAYING);
+ }
+ }
+
+ private static void instanceAvailable() {
+ if (instance == null) {
+ throw new IllegalStateException(
+ "UserPreferences was used before being set up");
+ }
+ }
+
+
+ public static long getLastPlayedFeedId() {
+ instanceAvailable();
+ return instance.currentlyPlayingFeedId;
+ }
+
+ public static long getCurrentlyPlayingMedia() {
+ instanceAvailable();
+ return instance.currentlyPlayingMedia;
+ }
+
+ public static long getCurrentlyPlayingFeedMediaId() {
+ return instance.currentlyPlayingFeedMediaId;
+ }
+
+ public static boolean getCurrentEpisodeIsStream() {
+ instanceAvailable();
+ return instance.currentEpisodeIsStream;
+ }
+
+ public static boolean getCurrentEpisodeIsVideo() {
+ instanceAvailable();
+ return instance.currentEpisodeIsVideo;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java b/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java
new file mode 100644
index 000000000..2020ddfae
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/preferences/UserPreferences.java
@@ -0,0 +1,577 @@
+package de.danoeh.antennapod.preferences;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.OpmlImportFromPathActivity;
+import de.danoeh.antennapod.receiver.FeedUpdateReceiver;
+
+/**
+ * Provides access to preferences set by the user in the settings screen. A
+ * private instance of this class must first be instantiated via
+ * createInstance() or otherwise every public method will throw an Exception
+ * when called.
+ */
+public class UserPreferences implements
+ SharedPreferences.OnSharedPreferenceChangeListener {
+ private static final String TAG = "UserPreferences";
+
+ public static final String PREF_PAUSE_ON_HEADSET_DISCONNECT = "prefPauseOnHeadsetDisconnect";
+ public static final String PREF_FOLLOW_QUEUE = "prefFollowQueue";
+ public static final String PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY = "prefDownloadMediaOnWifiOnly";
+ public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall";
+ public static final String PREF_MOBILE_UPDATE = "prefMobileUpdate";
+ public static final String PREF_DISPLAY_ONLY_EPISODES = "prefDisplayOnlyEpisodes";
+ public static final String PREF_AUTO_DELETE = "prefAutoDelete";
+ public static final String PREF_AUTO_FLATTR = "pref_auto_flattr";
+ public static final String PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD = "prefAutoFlattrPlayedDurationThreshold";
+ public static final String PREF_THEME = "prefTheme";
+ public static final String PREF_DATA_FOLDER = "prefDataFolder";
+ public static final String PREF_ENABLE_AUTODL = "prefEnableAutoDl";
+ public static final String PREF_ENABLE_AUTODL_WIFI_FILTER = "prefEnableAutoDownloadWifiFilter";
+ private static final String PREF_AUTODL_SELECTED_NETWORKS = "prefAutodownloadSelectedNetworks";
+ public static final String PREF_EPISODE_CACHE_SIZE = "prefEpisodeCacheSize";
+ private static final String PREF_PLAYBACK_SPEED = "prefPlaybackSpeed";
+ private static final String PREF_PLAYBACK_SPEED_ARRAY = "prefPlaybackSpeedArray";
+ public static final String PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS = "prefPauseForFocusLoss";
+ private static final String PREF_SEEK_DELTA_SECS = "prefSeekDeltaSecs";
+
+ // TODO: Make this value configurable
+ private static final float PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT = 0.8f;
+
+ private static int EPISODE_CACHE_SIZE_UNLIMITED = -1;
+
+ private static UserPreferences instance;
+ private final Context context;
+
+ // Preferences
+ private boolean pauseOnHeadsetDisconnect;
+ private boolean followQueue;
+ private boolean downloadMediaOnWifiOnly;
+ private long updateInterval;
+ private boolean allowMobileUpdate;
+ private boolean displayOnlyEpisodes;
+ private boolean autoDelete;
+ private boolean autoFlattr;
+ private float autoFlattrPlayedDurationThreshold;
+ private int theme;
+ private boolean enableAutodownload;
+ private boolean enableAutodownloadWifiFilter;
+ private String[] autodownloadSelectedNetworks;
+ private int episodeCacheSize;
+ private String playbackSpeed;
+ private String[] playbackSpeedArray;
+ private boolean pauseForFocusLoss;
+ private int seekDeltaSecs;
+ private boolean isFreshInstall;
+
+ private UserPreferences(Context context) {
+ this.context = context;
+ loadPreferences();
+ }
+
+ /**
+ * Sets up the UserPreferences class.
+ *
+ * @throws IllegalArgumentException if context is null
+ */
+ public static void createInstance(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating new instance of UserPreferences");
+ Validate.notNull(context);
+
+ instance = new UserPreferences(context);
+
+ createImportDirectory();
+ createNoMediaFile();
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .registerOnSharedPreferenceChangeListener(instance);
+
+ }
+
+ private void loadPreferences() {
+ SharedPreferences sp = PreferenceManager
+ .getDefaultSharedPreferences(context);
+ EPISODE_CACHE_SIZE_UNLIMITED = context.getResources().getInteger(
+ R.integer.episode_cache_size_unlimited);
+ pauseOnHeadsetDisconnect = sp.getBoolean(
+ PREF_PAUSE_ON_HEADSET_DISCONNECT, true);
+ followQueue = sp.getBoolean(PREF_FOLLOW_QUEUE, false);
+ downloadMediaOnWifiOnly = sp.getBoolean(
+ PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY, true);
+ updateInterval = readUpdateInterval(sp.getString(PREF_UPDATE_INTERVAL,
+ "0"));
+ allowMobileUpdate = sp.getBoolean(PREF_MOBILE_UPDATE, false);
+ displayOnlyEpisodes = sp.getBoolean(PREF_DISPLAY_ONLY_EPISODES, false);
+ autoDelete = sp.getBoolean(PREF_AUTO_DELETE, false);
+ autoFlattr = sp.getBoolean(PREF_AUTO_FLATTR, false);
+ autoFlattrPlayedDurationThreshold = sp.getFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD,
+ PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT);
+ theme = readThemeValue(sp.getString(PREF_THEME, "0"));
+ enableAutodownloadWifiFilter = sp.getBoolean(
+ PREF_ENABLE_AUTODL_WIFI_FILTER, false);
+ autodownloadSelectedNetworks = StringUtils.split(
+ sp.getString(PREF_AUTODL_SELECTED_NETWORKS, ""), ',');
+ episodeCacheSize = readEpisodeCacheSizeInternal(sp.getString(
+ PREF_EPISODE_CACHE_SIZE, "20"));
+ enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false);
+ playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0");
+ playbackSpeedArray = readPlaybackSpeedArray(sp.getString(
+ PREF_PLAYBACK_SPEED_ARRAY, null));
+ pauseForFocusLoss = sp.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, false);
+ seekDeltaSecs = Integer.valueOf(sp.getString(PREF_SEEK_DELTA_SECS, "30"));
+ }
+
+ private int readThemeValue(String valueFromPrefs) {
+ switch (Integer.parseInt(valueFromPrefs)) {
+ case 0:
+ return R.style.Theme_AntennaPod_Light;
+ case 1:
+ return R.style.Theme_AntennaPod_Dark;
+ default:
+ return R.style.Theme_AntennaPod_Light;
+ }
+ }
+
+ private long readUpdateInterval(String valueFromPrefs) {
+ int hours = Integer.parseInt(valueFromPrefs);
+ return TimeUnit.HOURS.toMillis(hours);
+ }
+
+ private int readEpisodeCacheSizeInternal(String valueFromPrefs) {
+ if (valueFromPrefs.equals(context
+ .getString(R.string.pref_episode_cache_unlimited))) {
+ return EPISODE_CACHE_SIZE_UNLIMITED;
+ } else {
+ return Integer.valueOf(valueFromPrefs);
+ }
+ }
+
+ private String[] readPlaybackSpeedArray(String valueFromPrefs) {
+ String[] selectedSpeeds = null;
+ // If this preference hasn't been set yet, return the default options
+ if (valueFromPrefs == null) {
+ String[] allSpeeds = context.getResources().getStringArray(
+ R.array.playback_speed_values);
+ List<String> speedList = new LinkedList<String>();
+ for (String speedStr : allSpeeds) {
+ float speed = Float.parseFloat(speedStr);
+ if (speed < 2.0001 && speed * 10 % 1 == 0) {
+ speedList.add(speedStr);
+ }
+ }
+ selectedSpeeds = speedList.toArray(new String[speedList.size()]);
+ } else {
+ try {
+ JSONArray jsonArray = new JSONArray(valueFromPrefs);
+ selectedSpeeds = new String[jsonArray.length()];
+ for (int i = 0; i < jsonArray.length(); i++) {
+ selectedSpeeds[i] = jsonArray.getString(i);
+ }
+ } catch (JSONException e) {
+ Log.e(TAG,
+ "Got JSON error when trying to get speeds from JSONArray");
+ e.printStackTrace();
+ }
+ }
+ return selectedSpeeds;
+ }
+
+ private static void instanceAvailable() {
+ if (instance == null) {
+ throw new IllegalStateException(
+ "UserPreferences was used before being set up");
+ }
+ }
+
+ public static boolean isPauseOnHeadsetDisconnect() {
+ instanceAvailable();
+ return instance.pauseOnHeadsetDisconnect;
+ }
+
+ public static boolean isFollowQueue() {
+ instanceAvailable();
+ return instance.followQueue;
+ }
+
+ public static boolean isDownloadMediaOnWifiOnly() {
+ instanceAvailable();
+ return instance.downloadMediaOnWifiOnly;
+ }
+
+ public static long getUpdateInterval() {
+ instanceAvailable();
+ return instance.updateInterval;
+ }
+
+ public static boolean isAllowMobileUpdate() {
+ instanceAvailable();
+ return instance.allowMobileUpdate;
+ }
+
+ public static boolean isDisplayOnlyEpisodes() {
+ instanceAvailable();
+ //return instance.displayOnlyEpisodes;
+ return false;
+ }
+
+ public static boolean isAutoDelete() {
+ instanceAvailable();
+ return instance.autoDelete;
+ }
+
+ public static boolean isAutoFlattr() {
+ instanceAvailable();
+ return instance.autoFlattr;
+ }
+
+ /**
+ * Returns the time after which an episode should be auto-flattr'd in percent of the episode's
+ * duration.
+ */
+ public static float getAutoFlattrPlayedDurationThreshold() {
+ instanceAvailable();
+ return instance.autoFlattrPlayedDurationThreshold;
+ }
+
+ public static int getTheme() {
+ instanceAvailable();
+ return instance.theme;
+ }
+
+ public static boolean isEnableAutodownloadWifiFilter() {
+ instanceAvailable();
+ return instance.enableAutodownloadWifiFilter;
+ }
+
+ public static String[] getAutodownloadSelectedNetworks() {
+ instanceAvailable();
+ return instance.autodownloadSelectedNetworks;
+ }
+
+ public static int getEpisodeCacheSizeUnlimited() {
+ return EPISODE_CACHE_SIZE_UNLIMITED;
+ }
+
+ public static String getPlaybackSpeed() {
+ instanceAvailable();
+ return instance.playbackSpeed;
+ }
+
+ public static String[] getPlaybackSpeedArray() {
+ instanceAvailable();
+ return instance.playbackSpeedArray;
+ }
+
+ public static int getSeekDeltaMs() {
+ instanceAvailable();
+ return 1000 * instance.seekDeltaSecs;
+ }
+
+ /**
+ * Returns the capacity of the episode cache. This method will return the
+ * negative integer EPISODE_CACHE_SIZE_UNLIMITED if the cache size is set to
+ * 'unlimited'.
+ */
+ public static int getEpisodeCacheSize() {
+ instanceAvailable();
+ return instance.episodeCacheSize;
+ }
+
+ public static boolean isEnableAutodownload() {
+ instanceAvailable();
+ return instance.enableAutodownload;
+ }
+
+ public static boolean shouldPauseForFocusLoss() {
+ instanceAvailable();
+ return instance.pauseForFocusLoss;
+ }
+
+ public static boolean isFreshInstall() {
+ instanceAvailable();
+ return instance.isFreshInstall;
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sp, String key) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Registered change of user preferences. Key: " + key);
+
+ if (key.equals(PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY)) {
+ downloadMediaOnWifiOnly = sp.getBoolean(
+ PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY, true);
+
+ } else if (key.equals(PREF_MOBILE_UPDATE)) {
+ allowMobileUpdate = sp.getBoolean(PREF_MOBILE_UPDATE, false);
+
+ } else if (key.equals(PREF_FOLLOW_QUEUE)) {
+ followQueue = sp.getBoolean(PREF_FOLLOW_QUEUE, false);
+
+ } else if (key.equals(PREF_UPDATE_INTERVAL)) {
+ updateInterval = readUpdateInterval(sp.getString(
+ PREF_UPDATE_INTERVAL, "0"));
+ restartUpdateAlarm(updateInterval);
+
+ } else if (key.equals(PREF_AUTO_DELETE)) {
+ autoDelete = sp.getBoolean(PREF_AUTO_DELETE, false);
+
+ } else if (key.equals(PREF_AUTO_FLATTR)) {
+ autoFlattr = sp.getBoolean(PREF_AUTO_FLATTR, false);
+ } else if (key.equals(PREF_DISPLAY_ONLY_EPISODES)) {
+ displayOnlyEpisodes = sp.getBoolean(PREF_DISPLAY_ONLY_EPISODES,
+ false);
+ } else if (key.equals(PREF_THEME)) {
+ theme = readThemeValue(sp.getString(PREF_THEME, ""));
+ } else if (key.equals(PREF_ENABLE_AUTODL_WIFI_FILTER)) {
+ enableAutodownloadWifiFilter = sp.getBoolean(
+ PREF_ENABLE_AUTODL_WIFI_FILTER, false);
+ } else if (key.equals(PREF_AUTODL_SELECTED_NETWORKS)) {
+ autodownloadSelectedNetworks = StringUtils.split(
+ sp.getString(PREF_AUTODL_SELECTED_NETWORKS, ""), ',');
+ } else if (key.equals(PREF_EPISODE_CACHE_SIZE)) {
+ episodeCacheSize = readEpisodeCacheSizeInternal(sp.getString(
+ PREF_EPISODE_CACHE_SIZE, "20"));
+ } else if (key.equals(PREF_ENABLE_AUTODL)) {
+ enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false);
+ } else if (key.equals(PREF_PLAYBACK_SPEED)) {
+ playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0");
+ } else if (key.equals(PREF_PLAYBACK_SPEED_ARRAY)) {
+ playbackSpeedArray = readPlaybackSpeedArray(sp.getString(
+ PREF_PLAYBACK_SPEED_ARRAY, null));
+ } else if (key.equals(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS)) {
+ pauseForFocusLoss = sp.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, false);
+ } else if (key.equals(PREF_SEEK_DELTA_SECS)) {
+ seekDeltaSecs = Integer.valueOf(sp.getString(PREF_SEEK_DELTA_SECS, "30"));
+ } else if (key.equals(PREF_PAUSE_ON_HEADSET_DISCONNECT)) {
+ pauseOnHeadsetDisconnect = sp.getBoolean(PREF_PAUSE_ON_HEADSET_DISCONNECT, true);
+ } else if (key.equals(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD)) {
+ autoFlattrPlayedDurationThreshold = sp.getFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD,
+ PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT);
+ }
+ }
+
+ public static void setPlaybackSpeed(String speed) {
+ PreferenceManager.getDefaultSharedPreferences(instance.context).edit()
+ .putString(PREF_PLAYBACK_SPEED, speed).apply();
+ }
+
+ public static void setPlaybackSpeedArray(String[] speeds) {
+ JSONArray jsonArray = new JSONArray();
+ for (String speed : speeds) {
+ jsonArray.put(speed);
+ }
+ PreferenceManager.getDefaultSharedPreferences(instance.context).edit()
+ .putString(PREF_PLAYBACK_SPEED_ARRAY, jsonArray.toString())
+ .apply();
+ }
+
+ public static void setAutodownloadSelectedNetworks(Context context,
+ String[] value) {
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(context.getApplicationContext())
+ .edit();
+ editor.putString(PREF_AUTODL_SELECTED_NETWORKS,
+ StringUtils.join(value, ','));
+ editor.commit();
+ }
+
+ /**
+ * Sets the update interval value. Should only be used for testing purposes!
+ */
+ public static void setUpdateInterval(Context context, long newValue) {
+ instanceAvailable();
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(context.getApplicationContext())
+ .edit();
+ editor.putString(PREF_UPDATE_INTERVAL,
+ String.valueOf(newValue));
+ editor.commit();
+ instance.updateInterval = newValue;
+ }
+
+ /**
+ * Change the auto-flattr settings
+ *
+ * @param context For accessing the shared preferences
+ * @param enabled Whether automatic flattring should be enabled at all
+ * @param autoFlattrThreshold The percentage of playback time after which an episode should be
+ * flattrd. Must be a value between 0 and 1 (inclusive)
+ * */
+ public static void setAutoFlattrSettings(Context context, boolean enabled, float autoFlattrThreshold) {
+ instanceAvailable();
+ Validate.inclusiveBetween(0.0, 1.0, autoFlattrThreshold);
+ PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext())
+ .edit()
+ .putBoolean(PREF_AUTO_FLATTR, enabled)
+ .putFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD, autoFlattrThreshold)
+ .commit();
+ instance.autoFlattr = enabled;
+ instance.autoFlattrPlayedDurationThreshold = autoFlattrThreshold;
+ }
+
+ /**
+ * Return the folder where the app stores all of its data. This method will
+ * return the standard data folder if none has been set by the user.
+ *
+ * @param type The name of the folder inside the data folder. May be null
+ * when accessing the root of the data folder.
+ * @return The data folder that has been requested or null if the folder
+ * could not be created.
+ */
+ public static File getDataFolder(Context context, String type) {
+ instanceAvailable();
+ SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(context.getApplicationContext());
+ String strDir = prefs.getString(PREF_DATA_FOLDER, null);
+ if (strDir == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Using default data folder");
+ return context.getExternalFilesDir(type);
+ } else {
+ File dataDir = new File(strDir);
+ if (!dataDir.exists()) {
+ if (!dataDir.mkdir()) {
+ Log.w(TAG, "Could not create data folder");
+ return null;
+ }
+ }
+
+ if (type == null) {
+ return dataDir;
+ } else {
+ // handle path separators
+ String[] dirs = type.split("/");
+ for (int i = 0; i < dirs.length; i++) {
+ if (dirs.length > 0) {
+ if (i < dirs.length - 1) {
+ dataDir = getDataFolder(context, dirs[i]);
+ if (dataDir == null) {
+ return null;
+ }
+ }
+ type = dirs[i];
+ }
+ }
+ File typeDir = new File(dataDir, type);
+ if (!typeDir.exists()) {
+ if (dataDir.canWrite()) {
+ if (!typeDir.mkdir()) {
+ Log.e(TAG, "Could not create data folder named "
+ + type);
+ return null;
+ }
+ }
+ }
+ return typeDir;
+ }
+
+ }
+ }
+
+ public static void setDataFolder(String dir) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Result from DirectoryChooser: " + dir);
+ instanceAvailable();
+ SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(instance.context);
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(PREF_DATA_FOLDER, dir);
+ editor.commit();
+ createImportDirectory();
+ }
+
+ /**
+ * Create a .nomedia file to prevent scanning by the media scanner.
+ */
+ private static void createNoMediaFile() {
+ File f = new File(instance.context.getExternalFilesDir(null),
+ ".nomedia");
+ if (!f.exists()) {
+ try {
+ f.createNewFile();
+ } catch (IOException e) {
+ Log.e(TAG, "Could not create .nomedia file");
+ e.printStackTrace();
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, ".nomedia file created");
+ }
+ }
+
+ /**
+ * Creates the import directory if it doesn't exist and if storage is
+ * available
+ */
+ private static void createImportDirectory() {
+ File importDir = getDataFolder(instance.context,
+ OpmlImportFromPathActivity.IMPORT_DIR);
+ if (importDir != null) {
+ if (importDir.exists()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Import directory already exists");
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating import directory");
+ importDir.mkdir();
+ }
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Could not access external storage.");
+ }
+ }
+
+ /**
+ * Updates alarm registered with the AlarmManager service or deactivates it.
+ *
+ * @param millis new value to register with AlarmManager. If millis is 0, the
+ * alarm is deactivated.
+ */
+ public static void restartUpdateAlarm(long millis) {
+ instanceAvailable();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Restarting update alarm. New value: " + millis);
+ AlarmManager alarmManager = (AlarmManager) instance.context
+ .getSystemService(Context.ALARM_SERVICE);
+ PendingIntent updateIntent = PendingIntent.getBroadcast(
+ instance.context, 0, new Intent(
+ FeedUpdateReceiver.ACTION_REFRESH_FEEDS), 0
+ );
+ alarmManager.cancel(updateIntent);
+ if (millis != 0) {
+ alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, millis, millis,
+ updateIntent);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Changed alarm to new interval");
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Automatic update was deactivated");
+ }
+ }
+
+ /**
+ * Reads episode cache size as it is saved in the episode_cache_size_values array.
+ */
+ public static int readEpisodeCacheSize(String valueFromPrefs) {
+ instanceAvailable();
+ return instance.readEpisodeCacheSizeInternal(valueFromPrefs);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java
new file mode 100644
index 000000000..a0539e276
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/AlarmUpdateReceiver.java
@@ -0,0 +1,33 @@
+package de.danoeh.antennapod.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+/** Listens for events that make it necessary to reset the update alarm. */
+public class AlarmUpdateReceiver extends BroadcastReceiver {
+ private static final String TAG = "AlarmUpdateReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received intent");
+ if (StringUtils.equals(intent.getAction(), Intent.ACTION_BOOT_COMPLETED)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resetting update alarm after reboot");
+ } else if (StringUtils.equals(intent.getAction(), Intent.ACTION_PACKAGE_REPLACED)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resetting update alarm after app upgrade");
+ }
+
+ UserPreferences.restartUpdateAlarm(UserPreferences.getUpdateInterval());
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java
new file mode 100644
index 000000000..4dcf0b6aa
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.NetworkUtils;
+
+public class ConnectivityActionReceiver extends BroadcastReceiver {
+ private static final String TAG = "ConnectivityActionReceiver";
+
+ @Override
+ public void onReceive(final Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ConnectivityManager.CONNECTIVITY_ACTION)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received intent");
+
+ if (NetworkUtils.autodownloadNetworkAvailable(context)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "auto-dl network available, starting auto-download");
+ DBTasks.autodownloadUndownloadedItems(context);
+ } else { // if new network is Wi-Fi, finish ongoing downloads,
+ // otherwise cancel all downloads
+ ConnectivityManager cm = (ConnectivityManager) context
+ .getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo ni = cm.getActiveNetworkInfo();
+ if (ni == null || ni.getType() != ConnectivityManager.TYPE_WIFI) {
+ if (BuildConfig.DEBUG)
+ Log.i(TAG,
+ "Device is no longer connected to Wi-Fi. Cancelling ongoing downloads");
+ DownloadRequester.getInstance().cancelAllDownloads(context);
+ }
+
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java
new file mode 100644
index 000000000..3c283a30b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/FeedUpdateReceiver.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.DBTasks;
+
+/** Refreshes all feeds when it receives an intent */
+public class FeedUpdateReceiver extends BroadcastReceiver {
+ private static final String TAG = "FeedUpdateReceiver";
+ public static final String ACTION_REFRESH_FEEDS = "de.danoeh.antennapod.feedupdatereceiver.refreshFeeds";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ACTION_REFRESH_FEEDS)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received intent");
+ boolean mobileUpdate = UserPreferences.isAllowMobileUpdate();
+ if (mobileUpdate || connectedToWifi(context)) {
+ DBTasks.refreshExpiredFeeds(context);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Blocking automatic update: no wifi available / no mobile updates allowed");
+ }
+ }
+ }
+
+ private boolean connectedToWifi(Context context) {
+ ConnectivityManager connManager = (ConnectivityManager) context
+ .getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo mWifi = connManager
+ .getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+
+ return mWifi.isConnected();
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java
new file mode 100644
index 000000000..1edebd275
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/MediaButtonReceiver.java
@@ -0,0 +1,32 @@
+package de.danoeh.antennapod.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import android.view.KeyEvent;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+
+/** Receives media button events. */
+public class MediaButtonReceiver extends BroadcastReceiver {
+ private static final String TAG = "MediaButtonReceiver";
+ public static final String EXTRA_KEYCODE = "de.danoeh.antennapod.service.extra.MediaButtonReceiver.KEYCODE";
+
+ public static final String NOTIFY_BUTTON_RECEIVER = "de.danoeh.antennapod.NOTIFY_BUTTON_RECEIVER";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Received intent");
+ KeyEvent event = (KeyEvent) intent.getExtras().get(
+ Intent.EXTRA_KEY_EVENT);
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ Intent serviceIntent = new Intent(context, PlaybackService.class);
+ int keycode = event.getKeyCode();
+ serviceIntent.putExtra(EXTRA_KEYCODE, keycode);
+ context.startService(serviceIntent);
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java b/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java
new file mode 100644
index 000000000..9f8892181
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java
@@ -0,0 +1,50 @@
+package de.danoeh.antennapod.receiver;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.service.playback.PlayerWidgetService;
+
+public class PlayerWidget extends AppWidgetProvider {
+ private static final String TAG = "PlayerWidget";
+ public static final String FORCE_WIDGET_UPDATE = "de.danoeh.antennapod.FORCE_WIDGET_UPDATE";
+ public static final String STOP_WIDGET_UPDATE = "de.danoeh.antennapod.STOP_WIDGET_UPDATE";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), FORCE_WIDGET_UPDATE)) {
+ startUpdate(context);
+ } else if (StringUtils.equals(intent.getAction(), STOP_WIDGET_UPDATE)) {
+ stopUpdate(context);
+ }
+
+ }
+
+ @Override
+ public void onEnabled(Context context) {
+ super.onEnabled(context);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Widget enabled");
+ }
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager appWidgetManager,
+ int[] appWidgetIds) {
+ startUpdate(context);
+ }
+
+ private void startUpdate(Context context) {
+ context.startService(new Intent(context, PlayerWidgetService.class));
+ }
+
+ private void stopUpdate(Context context) {
+ context.stopService(new Intent(context, PlayerWidgetService.class));
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java
new file mode 100644
index 000000000..b0430d170
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/receiver/SPAReceiver.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import android.widget.Toast;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Arrays;
+import java.util.Date;
+
+/**
+ * Receives intents from AntennaPod Single Purpose apps
+ */
+public class SPAReceiver extends BroadcastReceiver{
+ private static final String TAG = "SPAReceiver";
+
+ public static final String ACTION_SP_APPS_QUERY_FEEDS = "de.danoeh.antennapdsp.intent.SP_APPS_QUERY_FEEDS";
+ public static final String ACTION_SP_APPS_QUERY_FEEDS_REPSONSE = "de.danoeh.antennapdsp.intent.SP_APPS_QUERY_FEEDS_RESPONSE";
+ public static final String ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA = "feeds";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ACTION_SP_APPS_QUERY_FEEDS_REPSONSE)) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Received SP_APPS_QUERY_RESPONSE");
+ if (intent.hasExtra(ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA)) {
+ String[] feedUrls = intent.getStringArrayExtra(ACTION_SP_APPS_QUERY_FEEDS_REPSONSE_FEEDS_EXTRA);
+ if (feedUrls != null) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Received feeds list: " + Arrays.toString(feedUrls));
+ for (String url : feedUrls) {
+ Feed f = new Feed(url, new Date());
+ try {
+ DownloadRequester.getInstance().downloadFeed(context, f);
+ } catch (DownloadRequestException e) {
+ Log.e(TAG, "Error while trying to add feed " + url);
+ e.printStackTrace();
+ }
+ }
+ Toast.makeText(context, R.string.sp_apps_importing_feeds_msg, Toast.LENGTH_LONG).show();
+
+ } else {
+ Log.e(TAG, "Received invalid SP_APPS_QUERY_REPSONSE: extra was null");
+ }
+ } else {
+ Log.e(TAG, "Received invalid SP_APPS_QUERY_RESPONSE: Contains no extra");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java b/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java
new file mode 100644
index 000000000..c8c9fc31e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/GpodnetSyncService.java
@@ -0,0 +1,245 @@
+package de.danoeh.antennapod.service;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.IBinder;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceAuthenticationException;
+import de.danoeh.antennapod.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetSubscriptionChange;
+import de.danoeh.antennapod.gpoddernet.model.GpodnetUploadChangesResponse;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.NetworkUtils;
+
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Synchronizes local subscriptions with gpodder.net service. The service should be started with ACTION_SYNC as an action argument.
+ * This class also provides static methods for starting the GpodnetSyncService.
+ */
+public class GpodnetSyncService extends Service {
+ private static final String TAG = "GpodnetSyncService";
+
+ private static final long WAIT_INTERVAL = 5000L;
+
+ public static final String ARG_ACTION = "action";
+
+ public static final String ACTION_SYNC = "de.danoeh.antennapod.intent.action.sync";
+
+ private GpodnetService service;
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ final String action = (intent != null) ? intent.getStringExtra(ARG_ACTION) : null;
+ if (action != null && action.equals(ACTION_SYNC)) {
+ Log.d(TAG, String.format("Waiting %d milliseconds before uploading changes", WAIT_INTERVAL));
+ syncWaiterThread.restart();
+ } else {
+ Log.e(TAG, "Received invalid intent: action argument is null or invalid");
+ }
+ return START_FLAG_REDELIVERY;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG) Log.d(TAG, "onDestroy");
+ syncWaiterThread.interrupt();
+
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ private synchronized GpodnetService tryLogin() throws GpodnetServiceException {
+ if (service == null) {
+ service = new GpodnetService();
+ service.authenticate(GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
+ }
+ return service;
+ }
+
+ private synchronized void syncChanges() {
+ if (GpodnetPreferences.loggedIn() && NetworkUtils.networkAvailable(this)) {
+ final long timestamp = GpodnetPreferences.getLastSyncTimestamp();
+ try {
+ final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls(this);
+ GpodnetService service = tryLogin();
+
+ if (timestamp == 0) {
+ // first sync: download all subscriptions...
+ GpodnetSubscriptionChange changes =
+ service.getSubscriptionChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), 0);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Downloaded subscription changes: " + changes);
+ processSubscriptionChanges(localSubscriptions, changes);
+
+ // ... then upload all local subscriptions
+ if (BuildConfig.DEBUG) Log.d(TAG, "Uploading subscription list: " + localSubscriptions);
+ GpodnetUploadChangesResponse uploadChangesResponse =
+ service.uploadChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), localSubscriptions, new LinkedList<String>());
+ if (BuildConfig.DEBUG) Log.d(TAG, "Uploading changes response: " + uploadChangesResponse);
+ GpodnetPreferences.removeAddedFeeds(localSubscriptions);
+ GpodnetPreferences.removeRemovedFeeds(GpodnetPreferences.getRemovedFeedsCopy());
+ GpodnetPreferences.setLastSyncTimestamp(uploadChangesResponse.timestamp);
+ } else {
+ Set<String> added = GpodnetPreferences.getAddedFeedsCopy();
+ Set<String> removed = GpodnetPreferences.getRemovedFeedsCopy();
+
+ // download remote changes first...
+ GpodnetSubscriptionChange subscriptionChanges = service.getSubscriptionChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), timestamp);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges);
+ processSubscriptionChanges(localSubscriptions, subscriptionChanges);
+
+ // ... then upload changes local changes
+ if (BuildConfig.DEBUG) Log.d(TAG, String.format("Uploading subscriptions, Added: %s\nRemoved: %s",
+ added.toString(), removed));
+ GpodnetUploadChangesResponse uploadChangesResponse = service.uploadChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), added, removed);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Upload subscriptions response: " + uploadChangesResponse);
+
+ GpodnetPreferences.removeAddedFeeds(added);
+ GpodnetPreferences.removeRemovedFeeds(removed);
+ GpodnetPreferences.setLastSyncTimestamp(uploadChangesResponse.timestamp);
+ }
+ clearErrorNotifications();
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ updateErrorNotification(e);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
+ }
+ stopSelf();
+ }
+
+ private synchronized void processSubscriptionChanges(List<String> localSubscriptions, GpodnetSubscriptionChange changes) throws DownloadRequestException {
+ for (String downloadUrl : changes.getAdded()) {
+ if (!localSubscriptions.contains(downloadUrl)) {
+ Feed feed = new Feed(downloadUrl, new Date());
+ DownloadRequester.getInstance().downloadFeed(this, feed);
+ }
+ }
+ for (String downloadUrl : changes.getRemoved()) {
+ DBTasks.removeFeedWithDownloadUrl(GpodnetSyncService.this, downloadUrl);
+ }
+ }
+
+ private void clearErrorNotifications() {
+ NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+ nm.cancel(R.id.notification_gpodnet_sync_error);
+ nm.cancel(R.id.notification_gpodnet_sync_autherror);
+ }
+
+ private void updateErrorNotification(GpodnetServiceException exception) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Posting error notification");
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
+ final String title;
+ final String description;
+ final int id;
+ if (exception instanceof GpodnetServiceAuthenticationException) {
+ title = getString(R.string.gpodnetsync_auth_error_title);
+ description = getString(R.string.gpodnetsync_auth_error_descr);
+ id = R.id.notification_gpodnet_sync_autherror;
+ } else {
+ title = getString(R.string.gpodnetsync_error_title);
+ description = getString(R.string.gpodnetsync_error_descr) + exception.getMessage();
+ id = R.id.notification_gpodnet_sync_error;
+ }
+
+ PendingIntent activityIntent = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
+
+ Notification notification = builder.setContentTitle(title)
+ .setContentText(description)
+ .setContentIntent(activityIntent)
+ .setSmallIcon(R.drawable.stat_notify_sync_error)
+ .setAutoCancel(true)
+ .build();
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.notify(id, notification);
+ }
+
+ private WaiterThread syncWaiterThread = new WaiterThread(WAIT_INTERVAL) {
+ @Override
+ public void onWaitCompleted() {
+ syncChanges();
+ }
+ };
+
+ private abstract class WaiterThread {
+ private long waitInterval;
+ private Thread thread;
+
+ private WaiterThread(long waitInterval) {
+ this.waitInterval = waitInterval;
+ reinit();
+ }
+
+ public abstract void onWaitCompleted();
+
+ public void exec() {
+ if (!thread.isAlive()) {
+ thread.start();
+ }
+ }
+
+ private void reinit() {
+ if (thread != null && thread.isAlive()) {
+ Log.d(TAG, "Interrupting waiter thread");
+ thread.interrupt();
+ }
+ thread = new Thread() {
+ @Override
+ public void run() {
+ try {
+ Thread.sleep(waitInterval);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ if (!isInterrupted()) {
+ synchronized (this) {
+ onWaitCompleted();
+ }
+ }
+ }
+ };
+ }
+
+ public void restart() {
+ reinit();
+ exec();
+ }
+
+ public void interrupt() {
+ if (thread != null && thread.isAlive()) {
+ thread.interrupt();
+ }
+ }
+ }
+
+ public static void sendSyncIntent(Context context) {
+ if (GpodnetPreferences.loggedIn()) {
+ Intent intent = new Intent(context, GpodnetSyncService.class);
+ intent.putExtra(ARG_ACTION, ACTION_SYNC);
+ context.startService(intent);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java b/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java
new file mode 100644
index 000000000..ddf8d605d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/APRedirectHandler.java
@@ -0,0 +1,54 @@
+package de.danoeh.antennapod.service.download;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.impl.client.DefaultRedirectHandler;
+import org.apache.http.protocol.HttpContext;
+
+import java.net.URI;
+
+public class APRedirectHandler extends DefaultRedirectHandler {
+ // Identifier for logger
+ private static final String TAG = "APRedirectHandler";
+ // Header field, which has to be potentially fixed
+ private static final String LOC = "Location";
+ // Regular expressions for character strings, which should not appear in URLs
+ private static final String CHi[] = { "\\{", "\\}", "\\|", "\\\\", "\\^", "~", "\\[", "\\]", "\\`"};
+ private static final String CHo[] = { "%7B", "%7D", "%7C", "%5C", "%5E", "%7E", "%5B", "%5D", "%60"};
+
+ /**
+ * Workaround for broken URLs in redirection.
+ * Proper solution involves LaxRedirectStrategy() which is not available in
+ * current API yet.
+ */
+ @Override
+ public URI getLocationURI(HttpResponse response, HttpContext context)
+ throws org.apache.http.ProtocolException {
+
+ Header h[] = response.getHeaders(LOC);
+ if (h.length>0) {
+ String s = h[0].getValue();
+
+ // Fix broken URL
+ for(int i=0; i<CHi.length;i++)
+ s = s.replaceAll(CHi[i], CHo[i]);
+
+ // If anything had to be fixed, then replace the header
+ if (!s.equals(h[0].getValue()))
+ {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Original URL: " + h[0].getValue());
+
+ response.setHeader(LOC, s);
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Fixed URL: " + s);
+ }
+ }
+
+ // call DefaultRedirectHandler with fixed URL
+ return super.getLocationURI(response, context);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java b/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java
new file mode 100644
index 000000000..be331ce9b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/AntennapodHttpClient.java
@@ -0,0 +1,96 @@
+package de.danoeh.antennapod.service.download;
+
+import android.util.Log;
+import de.danoeh.antennapod.AppConfig;
+import de.danoeh.antennapod.BuildConfig;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.params.HttpClientParams;
+import org.apache.http.conn.ClientConnectionManager;
+import org.apache.http.conn.params.ConnManagerPNames;
+import org.apache.http.conn.scheme.PlainSocketFactory;
+import org.apache.http.conn.scheme.Scheme;
+import org.apache.http.conn.scheme.SchemeRegistry;
+import org.apache.http.conn.ssl.SSLSocketFactory;
+import org.apache.http.impl.client.AbstractHttpClient;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.CoreProtocolPNames;
+import org.apache.http.params.HttpConnectionParams;
+import org.apache.http.params.HttpParams;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Provides access to a HttpClient singleton.
+ */
+public class AntennapodHttpClient {
+ private static final String TAG = "AntennapodHttpClient";
+
+ public static final long EXPIRED_CONN_TIMEOUT_SEC = 30;
+
+ public static final int MAX_REDIRECTS = 5;
+ public static final int CONNECTION_TIMEOUT = 30000;
+ public static final int SOCKET_TIMEOUT = 30000;
+
+ public static final int MAX_CONNECTIONS = 8;
+
+
+ private static volatile HttpClient httpClient = null;
+
+ /**
+ * Returns the HttpClient singleton.
+ */
+ public static synchronized HttpClient getHttpClient() {
+ if (httpClient == null) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Creating new instance of HTTP client");
+
+ HttpParams params = new BasicHttpParams();
+ params.setParameter(CoreProtocolPNames.USER_AGENT, AppConfig.USER_AGENT);
+ params.setIntParameter("http.protocol.max-redirects", MAX_REDIRECTS);
+ params.setBooleanParameter("http.protocol.reject-relative-redirect",
+ false);
+ HttpConnectionParams.setSoTimeout(params, SOCKET_TIMEOUT);
+ HttpConnectionParams.setConnectionTimeout(params, CONNECTION_TIMEOUT);
+ HttpClientParams.setRedirecting(params, true);
+
+ httpClient = new DefaultHttpClient(createClientConnectionManager(), params);
+ // Workaround for broken URLs in redirection
+ ((AbstractHttpClient) httpClient)
+ .setRedirectHandler(new APRedirectHandler());
+ }
+ return httpClient;
+ }
+
+ /**
+ * Closes expired connections. This method should be called by the using class once has finished its work with
+ * the HTTP client.
+ */
+ public static synchronized void cleanup() {
+ if (httpClient != null) {
+ httpClient.getConnectionManager().closeExpiredConnections();
+ httpClient.getConnectionManager().closeIdleConnections(EXPIRED_CONN_TIMEOUT_SEC, TimeUnit.SECONDS);
+ }
+ }
+
+
+ private static ClientConnectionManager createClientConnectionManager() {
+ HttpParams params = new BasicHttpParams();
+ params.setIntParameter(ConnManagerPNames.MAX_TOTAL_CONNECTIONS, MAX_CONNECTIONS);
+ return new ThreadSafeClientConnManager(params, prepareSchemeRegistry());
+ }
+
+ private static SchemeRegistry prepareSchemeRegistry() {
+ SchemeRegistry sr = new SchemeRegistry();
+
+ Scheme http = new Scheme("http",
+ PlainSocketFactory.getSocketFactory(), 80);
+ sr.register(http);
+ Scheme https = new Scheme("https",
+ SSLSocketFactory.getSocketFactory(), 443);
+ sr.register(https);
+
+ return sr;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java
new file mode 100644
index 000000000..e803d30d4
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadRequest.java
@@ -0,0 +1,209 @@
+package de.danoeh.antennapod.service.download;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import org.apache.commons.lang3.Validate;
+
+public class DownloadRequest implements Parcelable {
+
+ private final String destination;
+ private final String source;
+ private final String title;
+ private String username;
+ private String password;
+ private boolean deleteOnFailure;
+ private final long feedfileId;
+ private final int feedfileType;
+
+ protected int progressPercent;
+ protected long soFar;
+ protected long size;
+ protected int statusMsg;
+
+ public DownloadRequest(String destination, String source, String title,
+ long feedfileId, int feedfileType, String username, String password, boolean deleteOnFailure) {
+ Validate.notNull(destination);
+ Validate.notNull(source);
+ Validate.notNull(title);
+
+ this.destination = destination;
+ this.source = source;
+ this.title = title;
+ this.feedfileId = feedfileId;
+ this.feedfileType = feedfileType;
+ this.username = username;
+ this.password = password;
+ this.deleteOnFailure = deleteOnFailure;
+ }
+
+ public DownloadRequest(String destination, String source, String title,
+ long feedfileId, int feedfileType) {
+ this(destination, source, title, feedfileId, feedfileType, null, null, true);
+ }
+
+ private DownloadRequest(Parcel in) {
+ destination = in.readString();
+ source = in.readString();
+ title = in.readString();
+ feedfileId = in.readLong();
+ feedfileType = in.readInt();
+ deleteOnFailure = (in.readByte() > 0);
+ if (in.dataAvail() > 0) {
+ username = in.readString();
+ } else {
+ username = null;
+ }
+ if (in.dataAvail() > 0) {
+ password = in.readString();
+ } else {
+ password = null;
+ }
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(destination);
+ dest.writeString(source);
+ dest.writeString(title);
+ dest.writeLong(feedfileId);
+ dest.writeInt(feedfileType);
+ dest.writeByte((deleteOnFailure) ? (byte) 1 : 0);
+ if (username != null) {
+ dest.writeString(username);
+ }
+ if (password != null) {
+ dest.writeString(password);
+ }
+ }
+
+ public static final Parcelable.Creator<DownloadRequest> CREATOR = new Parcelable.Creator<DownloadRequest>() {
+ public DownloadRequest createFromParcel(Parcel in) {
+ return new DownloadRequest(in);
+ }
+
+ public DownloadRequest[] newArray(int size) {
+ return new DownloadRequest[size];
+ }
+ };
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ DownloadRequest that = (DownloadRequest) o;
+
+ if (deleteOnFailure != that.deleteOnFailure) return false;
+ if (feedfileId != that.feedfileId) return false;
+ if (feedfileType != that.feedfileType) return false;
+ if (progressPercent != that.progressPercent) return false;
+ if (size != that.size) return false;
+ if (soFar != that.soFar) return false;
+ if (statusMsg != that.statusMsg) return false;
+ if (destination != null ? !destination.equals(that.destination) : that.destination != null)
+ return false;
+ if (password != null ? !password.equals(that.password) : that.password != null)
+ return false;
+ if (source != null ? !source.equals(that.source) : that.source != null) return false;
+ if (title != null ? !title.equals(that.title) : that.title != null) return false;
+ if (username != null ? !username.equals(that.username) : that.username != null)
+ return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = destination != null ? destination.hashCode() : 0;
+ result = 31 * result + (source != null ? source.hashCode() : 0);
+ result = 31 * result + (title != null ? title.hashCode() : 0);
+ result = 31 * result + (username != null ? username.hashCode() : 0);
+ result = 31 * result + (password != null ? password.hashCode() : 0);
+ result = 31 * result + (deleteOnFailure ? 1 : 0);
+ result = 31 * result + (int) (feedfileId ^ (feedfileId >>> 32));
+ result = 31 * result + feedfileType;
+ result = 31 * result + progressPercent;
+ result = 31 * result + (int) (soFar ^ (soFar >>> 32));
+ result = 31 * result + (int) (size ^ (size >>> 32));
+ result = 31 * result + statusMsg;
+ return result;
+ }
+
+ public String getDestination() {
+ return destination;
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public long getFeedfileId() {
+ return feedfileId;
+ }
+
+ public int getFeedfileType() {
+ return feedfileType;
+ }
+
+ public int getProgressPercent() {
+ return progressPercent;
+ }
+
+ public void setProgressPercent(int progressPercent) {
+ this.progressPercent = progressPercent;
+ }
+
+ public long getSoFar() {
+ return soFar;
+ }
+
+ public void setSoFar(long soFar) {
+ this.soFar = soFar;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public void setSize(long size) {
+ this.size = size;
+ }
+
+ public int getStatusMsg() {
+ return statusMsg;
+ }
+
+ public void setStatusMsg(int statusMsg) {
+ this.statusMsg = statusMsg;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public boolean isDeleteOnFailure() {
+ return deleteOnFailure;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java
new file mode 100644
index 000000000..63be91b57
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadService.java
@@ -0,0 +1,1230 @@
+package de.danoeh.antennapod.service.download;
+
+import android.annotation.SuppressLint;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.MediaMetadataRetriever;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import android.webkit.URLUtil;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+import org.apache.http.HttpStatus;
+import org.xml.sax.SAXException;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletionService;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorCompletionService;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.RejectedExecutionHandler;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.xml.parsers.ParserConfigurationException;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.DownloadAuthenticationActivity;
+import de.danoeh.antennapod.activity.MainActivity;
+import de.danoeh.antennapod.adapter.NavListAdapter;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.FeedPreferences;
+import de.danoeh.antennapod.fragment.DownloadsFragment;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.syndication.handler.FeedHandler;
+import de.danoeh.antennapod.syndication.handler.UnsupportedFeedtypeException;
+import de.danoeh.antennapod.util.ChapterUtils;
+import de.danoeh.antennapod.util.DownloadError;
+import de.danoeh.antennapod.util.InvalidFeedException;
+
+/**
+ * Manages the download of feedfiles in the app. Downloads can be enqueued viathe startService intent.
+ * The argument of the intent is an instance of DownloadRequest in the EXTRA_REQUEST field of
+ * the intent.
+ * After the downloads have finished, the downloaded object will be passed on to a specific handler, depending on the
+ * type of the feedfile.
+ */
+public class DownloadService extends Service {
+ private static final String TAG = "DownloadService";
+
+ /**
+ * Cancels one download. The intent MUST have an EXTRA_DOWNLOAD_URL extra that contains the download URL of the
+ * object whose download should be cancelled.
+ */
+ public static final String ACTION_CANCEL_DOWNLOAD = "action.de.danoeh.antennapod.service.cancelDownload";
+
+ /**
+ * Cancels all running downloads.
+ */
+ public static final String ACTION_CANCEL_ALL_DOWNLOADS = "action.de.danoeh.antennapod.service.cancelAllDownloads";
+
+ /**
+ * Extra for ACTION_CANCEL_DOWNLOAD
+ */
+ public static final String EXTRA_DOWNLOAD_URL = "downloadUrl";
+
+ /**
+ * Sent by the DownloadService when the content of the downloads list
+ * changes.
+ */
+ public static final String ACTION_DOWNLOADS_CONTENT_CHANGED = "action.de.danoeh.antennapod.service.downloadsContentChanged";
+
+ /**
+ * Extra for ACTION_ENQUEUE_DOWNLOAD intent.
+ */
+ public static final String EXTRA_REQUEST = "request";
+
+ /**
+ * Stores new media files that will be queued for auto-download if possible.
+ */
+ private List<Long> newMediaFiles;
+
+ /**
+ * Contains all completed downloads that have not been included in the report yet.
+ */
+ private List<DownloadStatus> reportQueue;
+
+ private ExecutorService syncExecutor;
+ private CompletionService<Downloader> downloadExecutor;
+ private FeedSyncThread feedSyncThread;
+
+ /**
+ * Number of threads of downloadExecutor.
+ */
+ private static final int NUM_PARALLEL_DOWNLOADS = 6;
+
+ private DownloadRequester requester;
+
+
+ private NotificationCompat.Builder notificationCompatBuilder;
+ private Notification.BigTextStyle notificationBuilder;
+ private int NOTIFICATION_ID = 2;
+ private int REPORT_ID = 3;
+
+ /**
+ * Currently running downloads.
+ */
+ private List<Downloader> downloads;
+
+ /**
+ * Number of running downloads.
+ */
+ private AtomicInteger numberOfDownloads;
+
+ /**
+ * True if service is running.
+ */
+ public static boolean isRunning = false;
+
+ private Handler handler;
+
+ private NotificationUpdater notificationUpdater;
+ private ScheduledFuture notificationUpdaterFuture;
+ private static final int SCHED_EX_POOL_SIZE = 1;
+ private ScheduledThreadPoolExecutor schedExecutor;
+
+ private final IBinder mBinder = new LocalBinder();
+
+ public class LocalBinder extends Binder {
+ public DownloadService getService() {
+ return DownloadService.this;
+ }
+ }
+
+ private Thread downloadCompletionThread = new Thread() {
+ private static final String TAG = "downloadCompletionThread";
+
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "downloadCompletionThread was started");
+ while (!isInterrupted()) {
+ try {
+ Downloader downloader = downloadExecutor.take().get();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received 'Download Complete' - message.");
+ removeDownload(downloader);
+ DownloadStatus status = downloader.getResult();
+ boolean successful = status.isSuccessful();
+
+ final int type = status.getFeedfileType();
+ if (successful) {
+ if (type == Feed.FEEDFILETYPE_FEED) {
+ handleCompletedFeedDownload(downloader
+ .getDownloadRequest());
+ } else if (type == FeedImage.FEEDFILETYPE_FEEDIMAGE) {
+ handleCompletedImageDownload(status, downloader.getDownloadRequest());
+ } else if (type == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ handleCompletedFeedMediaDownload(status, downloader.getDownloadRequest());
+ }
+ } else {
+ numberOfDownloads.decrementAndGet();
+ if (!status.isCancelled()) {
+ if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) {
+ postAuthenticationNotification(downloader.getDownloadRequest());
+ } else if (status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
+ && Integer.valueOf(status.getReasonDetailed()) == HttpStatus.SC_REQUESTED_RANGE_NOT_SATISFIABLE) {
+
+ Log.d(TAG, "Requested invalid range, restarting download from the beginning");
+ FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination()));
+ DownloadRequester.getInstance().download(DownloadService.this, downloader.getDownloadRequest());
+ } else {
+ Log.e(TAG, "Download failed");
+ saveDownloadStatus(status);
+ handleFailedDownload(status, downloader.getDownloadRequest());
+ }
+ }
+ sendDownloadHandledIntent();
+ queryDownloadsAsync();
+ }
+ } catch (InterruptedException e) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "DownloadCompletionThread was interrupted");
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ numberOfDownloads.decrementAndGet();
+ }
+ }
+ if (BuildConfig.DEBUG) Log.d(TAG, "End of downloadCompletionThread");
+ }
+ };
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent.getParcelableExtra(EXTRA_REQUEST) != null) {
+ onDownloadQueued(intent);
+ } else if (numberOfDownloads.get() == 0) {
+ stopSelf();
+ }
+ return Service.START_NOT_STICKY;
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public void onCreate() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service started");
+ isRunning = true;
+ handler = new Handler();
+ newMediaFiles = Collections.synchronizedList(new ArrayList<Long>());
+ reportQueue = Collections.synchronizedList(new ArrayList<DownloadStatus>());
+ downloads = new ArrayList<Downloader>();
+ numberOfDownloads = new AtomicInteger(0);
+
+ IntentFilter cancelDownloadReceiverFilter = new IntentFilter();
+ cancelDownloadReceiverFilter.addAction(ACTION_CANCEL_ALL_DOWNLOADS);
+ cancelDownloadReceiverFilter.addAction(ACTION_CANCEL_DOWNLOAD);
+ registerReceiver(cancelDownloadReceiver, cancelDownloadReceiverFilter);
+ syncExecutor = Executors.newSingleThreadExecutor(new ThreadFactory() {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ });
+ downloadExecutor = new ExecutorCompletionService<Downloader>(
+ Executors.newFixedThreadPool(NUM_PARALLEL_DOWNLOADS,
+ new ThreadFactory() {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ }
+ )
+ );
+ schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE,
+ new ThreadFactory() {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ }, new RejectedExecutionHandler() {
+
+ @Override
+ public void rejectedExecution(Runnable r,
+ ThreadPoolExecutor executor) {
+ Log.w(TAG, "SchedEx rejected submission of new task");
+ }
+ }
+ );
+ downloadCompletionThread.start();
+ feedSyncThread = new FeedSyncThread();
+ feedSyncThread.start();
+
+ setupNotificationBuilders();
+ requester = DownloadRequester.getInstance();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public void onDestroy() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service shutting down");
+ isRunning = false;
+ updateReport();
+
+ stopForeground(true);
+ NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+ nm.cancel(NOTIFICATION_ID);
+
+ downloadCompletionThread.interrupt();
+ syncExecutor.shutdown();
+ schedExecutor.shutdown();
+ feedSyncThread.shutdown();
+ cancelNotificationUpdater();
+ unregisterReceiver(cancelDownloadReceiver);
+
+ if (!newMediaFiles.isEmpty()) {
+ DBTasks.autodownloadUndownloadedItems(getApplicationContext(),
+ ArrayUtils.toPrimitive(newMediaFiles.toArray(new Long[newMediaFiles.size()])));
+ }
+ }
+
+ @SuppressLint("NewApi")
+ private void setupNotificationBuilders() {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.putExtra(MainActivity.EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV);
+ intent.putExtra(MainActivity.EXTRA_NAV_INDEX, MainActivity.POS_DOWNLOADS);
+ Bundle args = new Bundle();
+ args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_RUNNING);
+ intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args);
+
+ PendingIntent pIntent = PendingIntent.getActivity(this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT
+ );
+
+
+ Bitmap icon = BitmapFactory.decodeResource(getResources(),
+ R.drawable.stat_notify_sync);
+
+ if (android.os.Build.VERSION.SDK_INT >= 16) {
+ notificationBuilder = new Notification.BigTextStyle(
+ new Notification.Builder(this).setOngoing(true)
+ .setContentIntent(pIntent).setLargeIcon(icon)
+ .setSmallIcon(R.drawable.stat_notify_sync)
+ );
+ } else {
+ notificationCompatBuilder = new NotificationCompat.Builder(this)
+ .setOngoing(true).setContentIntent(pIntent)
+ .setLargeIcon(icon)
+ .setSmallIcon(R.drawable.stat_notify_sync);
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Notification set up");
+ }
+
+ /**
+ * Updates the contents of the service's notifications. Should be called
+ * before setupNotificationBuilders.
+ */
+ @SuppressLint("NewApi")
+ private Notification updateNotifications() {
+ String contentTitle = getString(R.string.download_notification_title);
+ int numDownloads = requester.getNumberOfDownloads();
+ String downloadsLeft;
+ if (numDownloads > 0) {
+ downloadsLeft = requester.getNumberOfDownloads()
+ + getString(R.string.downloads_left);
+ } else {
+ downloadsLeft = getString(R.string.downloads_processing);
+ }
+ if (android.os.Build.VERSION.SDK_INT >= 16) {
+
+ if (notificationBuilder != null) {
+
+ StringBuilder bigText = new StringBuilder("");
+ for (int i = 0; i < downloads.size(); i++) {
+ Downloader downloader = downloads.get(i);
+ final DownloadRequest request = downloader
+ .getDownloadRequest();
+ if (request.getFeedfileType() == Feed.FEEDFILETYPE_FEED) {
+ if (request.getTitle() != null) {
+ if (i > 0) {
+ bigText.append("\n");
+ }
+ bigText.append("\u2022 " + request.getTitle());
+ }
+ } else if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ if (request.getTitle() != null) {
+ if (i > 0) {
+ bigText.append("\n");
+ }
+ bigText.append("\u2022 " + request.getTitle()
+ + " (" + request.getProgressPercent()
+ + "%)");
+ }
+ }
+
+ }
+ notificationBuilder.setSummaryText(downloadsLeft);
+ notificationBuilder.setBigContentTitle(contentTitle);
+ if (bigText != null) {
+ notificationBuilder.bigText(bigText.toString());
+ }
+ return notificationBuilder.build();
+ }
+ } else {
+ if (notificationCompatBuilder != null) {
+ notificationCompatBuilder.setContentTitle(contentTitle);
+ notificationCompatBuilder.setContentText(downloadsLeft);
+ return notificationCompatBuilder.build();
+ }
+ }
+ return null;
+ }
+
+ private Downloader getDownloader(String downloadUrl) {
+ for (Downloader downloader : downloads) {
+ if (downloader.getDownloadRequest().getSource().equals(downloadUrl)) {
+ return downloader;
+ }
+ }
+ return null;
+ }
+
+ private BroadcastReceiver cancelDownloadReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ACTION_CANCEL_DOWNLOAD)) {
+ String url = intent.getStringExtra(EXTRA_DOWNLOAD_URL);
+ Validate.notNull(url, "ACTION_CANCEL_DOWNLOAD intent needs download url extra");
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Cancelling download with url " + url);
+ Downloader d = getDownloader(url);
+ if (d != null) {
+ d.cancel();
+ } else {
+ Log.e(TAG, "Could not cancel download with url " + url);
+ }
+
+ } else if (StringUtils.equals(intent.getAction(), ACTION_CANCEL_ALL_DOWNLOADS)) {
+ for (Downloader d : downloads) {
+ d.cancel();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Cancelled all downloads");
+ }
+ sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED));
+
+ }
+ queryDownloads();
+ }
+
+ };
+
+ private void onDownloadQueued(Intent intent) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received enqueue request");
+ DownloadRequest request = intent.getParcelableExtra(EXTRA_REQUEST);
+ if (request == null) {
+ throw new IllegalArgumentException(
+ "ACTION_ENQUEUE_DOWNLOAD intent needs request extra");
+ }
+
+ Downloader downloader = getDownloader(request);
+ if (downloader != null) {
+ numberOfDownloads.incrementAndGet();
+ downloads.add(downloader);
+ downloadExecutor.submit(downloader);
+ sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED));
+ }
+
+ queryDownloads();
+ }
+
+ private Downloader getDownloader(DownloadRequest request) {
+ if (URLUtil.isHttpUrl(request.getSource())
+ || URLUtil.isHttpsUrl(request.getSource())) {
+ return new HttpDownloader(request);
+ }
+ Log.e(TAG,
+ "Could not find appropriate downloader for "
+ + request.getSource()
+ );
+ return null;
+ }
+
+ /**
+ * Remove download from the DownloadRequester list and from the
+ * DownloadService list.
+ */
+ private void removeDownload(final Downloader d) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Removing downloader: "
+ + d.getDownloadRequest().getSource());
+ boolean rc = downloads.remove(d);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Result of downloads.remove: " + rc);
+ DownloadRequester.getInstance().removeDownload(d.getDownloadRequest());
+ sendBroadcast(new Intent(ACTION_DOWNLOADS_CONTENT_CHANGED));
+ }
+ });
+ }
+
+ /**
+ * Adds a new DownloadStatus object to the list of completed downloads and
+ * saves it in the database
+ *
+ * @param status the download that is going to be saved
+ */
+ private void saveDownloadStatus(DownloadStatus status) {
+ reportQueue.add(status);
+ DBWriter.addDownloadStatus(this, status);
+ }
+
+ private void sendDownloadHandledIntent() {
+ EventDistributor.getInstance().sendDownloadHandledBroadcast();
+ }
+
+ /**
+ * Creates a notification at the end of the service lifecycle to notify the
+ * user about the number of completed downloads. A report will only be
+ * created if the number of successfully downloaded feeds is bigger than 1
+ * or if there is at least one failed download which is not an image or if
+ * there is at least one downloaded media file.
+ */
+ private void updateReport() {
+ // check if report should be created
+ boolean createReport = false;
+ int successfulDownloads = 0;
+ int failedDownloads = 0;
+
+ // a download report is created if at least one download has failed
+ // (excluding failed image downloads)
+ for (DownloadStatus status : reportQueue) {
+ if (status.isSuccessful()) {
+ successfulDownloads++;
+ } else if (!status.isCancelled()) {
+ if (status.getFeedfileType() != FeedImage.FEEDFILETYPE_FEEDIMAGE) {
+ createReport = true;
+ }
+ failedDownloads++;
+ }
+ }
+
+ if (createReport) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Creating report");
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.putExtra(MainActivity.EXTRA_NAV_TYPE, NavListAdapter.VIEW_TYPE_NAV);
+ intent.putExtra(MainActivity.EXTRA_NAV_INDEX, MainActivity.POS_DOWNLOADS);
+ Bundle args = new Bundle();
+ args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_LOG);
+ intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args);
+
+ // create notification object
+ Notification notification = new NotificationCompat.Builder(this)
+ .setTicker(
+ getString(de.danoeh.antennapod.R.string.download_report_title))
+ .setContentTitle(
+ getString(de.danoeh.antennapod.R.string.download_report_title))
+ .setContentText(
+ String.format(
+ getString(R.string.download_report_content),
+ successfulDownloads, failedDownloads)
+ )
+ .setSmallIcon(R.drawable.stat_notify_sync)
+ .setLargeIcon(
+ BitmapFactory.decodeResource(getResources(),
+ R.drawable.stat_notify_sync)
+ )
+ .setContentIntent(
+ PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
+ )
+ .setAutoCancel(true).build();
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.notify(REPORT_ID, notification);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "No report is created");
+ }
+ reportQueue.clear();
+ }
+
+ /**
+ * Calls query downloads on the services main thread. This method should be used instead of queryDownloads if it is
+ * used from a thread other than the main thread.
+ */
+ void queryDownloadsAsync() {
+ handler.post(new Runnable() {
+ public void run() {
+ queryDownloads();
+ ;
+ }
+ });
+ }
+
+ /**
+ * Check if there's something else to download, otherwise stop
+ */
+ void queryDownloads() {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, numberOfDownloads.get() + " downloads left");
+ }
+
+ if (numberOfDownloads.get() <= 0 && DownloadRequester.getInstance().hasNoDownloads()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Number of downloads is " + numberOfDownloads.get() + ", attempting shutdown");
+ stopSelf();
+ } else {
+ setupNotificationUpdater();
+ startForeground(NOTIFICATION_ID, updateNotifications());
+ }
+ }
+
+ private void postAuthenticationNotification(final DownloadRequest downloadRequest) {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ final String resourceTitle = (downloadRequest.getTitle() != null)
+ ? downloadRequest.getTitle() : downloadRequest.getSource();
+
+ final Intent activityIntent = new Intent(getApplicationContext(), DownloadAuthenticationActivity.class);
+ activityIntent.putExtra(DownloadAuthenticationActivity.ARG_DOWNLOAD_REQUEST, downloadRequest);
+ activityIntent.putExtra(DownloadAuthenticationActivity.ARG_SEND_TO_DOWNLOAD_REQUESTER_BOOL, true);
+ final PendingIntent contentIntent = PendingIntent.getActivity(getApplicationContext(), 0, activityIntent, PendingIntent.FLAG_ONE_SHOT);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(DownloadService.this);
+ builder.setTicker(getText(R.string.authentication_notification_title))
+ .setContentTitle(getText(R.string.authentication_notification_title))
+ .setContentText(getText(R.string.authentication_notification_msg))
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(getText(R.string.authentication_notification_msg)
+ + ": " + resourceTitle))
+ .setSmallIcon(R.drawable.ic_stat_authentication)
+ .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_stat_authentication))
+ .setAutoCancel(true)
+ .setContentIntent(contentIntent);
+ Notification n = builder.build();
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.notify(downloadRequest.getSource().hashCode(), n);
+ }
+ });
+ }
+
+ /**
+ * Is called whenever a Feed is downloaded
+ */
+ private void handleCompletedFeedDownload(DownloadRequest request) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Handling completed Feed Download");
+ feedSyncThread.submitCompletedDownload(request);
+
+ }
+
+ /**
+ * Is called whenever a Feed-Image is downloaded
+ */
+ private void handleCompletedImageDownload(DownloadStatus status, DownloadRequest request) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Handling completed Image Download");
+ syncExecutor.execute(new ImageHandlerThread(status, request));
+ }
+
+ /**
+ * Is called whenever a FeedMedia is downloaded.
+ */
+ private void handleCompletedFeedMediaDownload(DownloadStatus status, DownloadRequest request) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Handling completed FeedMedia Download");
+ syncExecutor.execute(new MediaHandlerThread(status, request));
+ }
+
+ private void handleFailedDownload(DownloadStatus status, DownloadRequest request) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Handling failed download");
+ syncExecutor.execute(new FailedDownloadHandler(status, request));
+ }
+
+ /**
+ * Takes a single Feed, parses the corresponding file and refreshes
+ * information in the manager
+ */
+ class FeedSyncThread extends Thread {
+ private static final String TAG = "FeedSyncThread";
+
+ private BlockingQueue<DownloadRequest> completedRequests = new LinkedBlockingDeque<DownloadRequest>();
+ private CompletionService<Feed> parserService = new ExecutorCompletionService<Feed>(Executors.newSingleThreadExecutor());
+ private ExecutorService dbService = Executors.newSingleThreadExecutor();
+ private Future<?> dbUpdateFuture;
+ private volatile boolean isActive = true;
+ private volatile boolean isCollectingRequests = false;
+
+ private final long WAIT_TIMEOUT = 3000;
+
+
+ /**
+ * Waits for completed requests. Once the first request has been taken, the method will wait WAIT_TIMEOUT ms longer to
+ * collect more completed requests.
+ *
+ * @return Collected feeds or null if the method has been interrupted during the first waiting period.
+ */
+ private List<Feed> collectCompletedRequests() {
+ List<Feed> results = new LinkedList<Feed>();
+ DownloadRequester requester = DownloadRequester.getInstance();
+ int tasks = 0;
+
+ try {
+ DownloadRequest request = completedRequests.take();
+ parserService.submit(new FeedParserTask(request));
+ tasks++;
+ } catch (InterruptedException e) {
+ return null;
+ }
+
+ tasks += pollCompletedDownloads();
+
+ isCollectingRequests = true;
+
+ if (requester.isDownloadingFeeds()) {
+ // wait for completion of more downloads
+ long startTime = System.currentTimeMillis();
+ long currentTime = startTime;
+ while (requester.isDownloadingFeeds() && (currentTime - startTime) < WAIT_TIMEOUT) {
+ try {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Waiting for " + (startTime + WAIT_TIMEOUT - currentTime) + " ms");
+ sleep(startTime + WAIT_TIMEOUT - currentTime);
+ } catch (InterruptedException e) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "interrupted while waiting for more downloads");
+ tasks += pollCompletedDownloads();
+ } finally {
+ currentTime = System.currentTimeMillis();
+ }
+ }
+
+ tasks += pollCompletedDownloads();
+
+ }
+
+ isCollectingRequests = false;
+
+ for (int i = 0; i < tasks; i++) {
+ try {
+ Feed f = parserService.take().get();
+ if (f != null) {
+ results.add(f);
+ }
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ return results;
+ }
+
+ private int pollCompletedDownloads() {
+ int tasks = 0;
+ for (int i = 0; i < completedRequests.size(); i++) {
+ parserService.submit(new FeedParserTask(completedRequests.poll()));
+ tasks++;
+ }
+ return tasks;
+ }
+
+ @Override
+ public void run() {
+ while (isActive) {
+ final List<Feed> feeds = collectCompletedRequests();
+
+ if (feeds == null) {
+ continue;
+ }
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Bundling " + feeds.size() + " feeds");
+
+ for (Feed feed : feeds) {
+ removeDuplicateImages(feed); // duplicate images have to removed because the DownloadRequester does not accept two downloads with the same download URL yet.
+ }
+
+ // Save information of feed in DB
+ if (dbUpdateFuture != null) {
+ try {
+ dbUpdateFuture.get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ dbUpdateFuture = dbService.submit(new Runnable() {
+ @Override
+ public void run() {
+ Feed[] savedFeeds = DBTasks.updateFeed(DownloadService.this, feeds.toArray(new Feed[feeds.size()]));
+
+ for (Feed savedFeed : savedFeeds) {
+ // Download Feed Image if provided and not downloaded
+ if (savedFeed.getImage() != null
+ && savedFeed.getImage().isDownloaded() == false) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Feed has image; Downloading....");
+ savedFeed.getImage().setOwner(savedFeed);
+ final Feed savedFeedRef = savedFeed;
+ try {
+ requester.downloadImage(DownloadService.this,
+ savedFeedRef.getImage());
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DBWriter.addDownloadStatus(
+ DownloadService.this,
+ new DownloadStatus(
+ savedFeedRef.getImage(),
+ savedFeedRef
+ .getImage()
+ .getHumanReadableIdentifier(),
+ DownloadError.ERROR_REQUEST_ERROR,
+ false, e.getMessage()
+ )
+ );
+ }
+ }
+
+ // queue new media files for automatic download
+ for (FeedItem item : savedFeed.getItems()) {
+ if (!item.isRead() && item.hasMedia() && !item.getMedia().isDownloaded()) {
+ newMediaFiles.add(item.getMedia().getId());
+ }
+ }
+
+ numberOfDownloads.decrementAndGet();
+ }
+
+ sendDownloadHandledIntent();
+
+ queryDownloadsAsync();
+ }
+ });
+
+ }
+
+ if (dbUpdateFuture != null) {
+ try {
+ dbUpdateFuture.get();
+ } catch (InterruptedException e) {
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Shutting down");
+
+ }
+
+ private class FeedParserTask implements Callable<Feed> {
+
+ private DownloadRequest request;
+
+ private FeedParserTask(DownloadRequest request) {
+ this.request = request;
+ }
+
+ @Override
+ public Feed call() throws Exception {
+ return parseFeed(request);
+ }
+ }
+
+ private Feed parseFeed(DownloadRequest request) {
+ Feed savedFeed = null;
+
+ Feed feed = new Feed(request.getSource(), new Date());
+ feed.setFile_url(request.getDestination());
+ feed.setId(request.getFeedfileId());
+ feed.setDownloaded(true);
+ feed.setPreferences(new FeedPreferences(0, true, request.getUsername(), request.getPassword()));
+
+ DownloadError reason = null;
+ String reasonDetailed = null;
+ boolean successful = true;
+ FeedHandler feedHandler = new FeedHandler();
+
+ try {
+ feed = feedHandler.parseFeed(feed).feed;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, feed.getTitle() + " parsed");
+ if (checkFeedData(feed) == false) {
+ throw new InvalidFeedException();
+ }
+
+ } catch (SAXException e) {
+ successful = false;
+ e.printStackTrace();
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } catch (IOException e) {
+ successful = false;
+ e.printStackTrace();
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } catch (ParserConfigurationException e) {
+ successful = false;
+ e.printStackTrace();
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } catch (UnsupportedFeedtypeException e) {
+ e.printStackTrace();
+ successful = false;
+ reason = DownloadError.ERROR_UNSUPPORTED_TYPE;
+ reasonDetailed = e.getMessage();
+ } catch (InvalidFeedException e) {
+ e.printStackTrace();
+ successful = false;
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ }
+
+ // cleanup();
+ if (savedFeed == null) {
+ savedFeed = feed;
+ }
+
+
+ if (successful) {
+ return savedFeed;
+ } else {
+ numberOfDownloads.decrementAndGet();
+ saveDownloadStatus(new DownloadStatus(savedFeed,
+ savedFeed.getHumanReadableIdentifier(), reason, successful,
+ reasonDetailed));
+ return null;
+ }
+ }
+
+
+ /**
+ * Checks if the feed was parsed correctly.
+ */
+ private boolean checkFeedData(Feed feed) {
+ if (feed.getTitle() == null) {
+ Log.e(TAG, "Feed has no title.");
+ return false;
+ }
+ if (!hasValidFeedItems(feed)) {
+ Log.e(TAG, "Feed has invalid items");
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Checks if the FeedItems of this feed have images that point
+ * to the same URL. If two FeedItems have an image that points to
+ * the same URL, the reference of the second item is removed, so that every image
+ * reference is unique.
+ */
+ private void removeDuplicateImages(Feed feed) {
+ for (int x = 0; x < feed.getItems().size(); x++) {
+ for (int y = x + 1; y < feed.getItems().size(); y++) {
+ FeedItem item1 = feed.getItems().get(x);
+ FeedItem item2 = feed.getItems().get(y);
+ if (item1.hasItemImage() && item2.hasItemImage()) {
+ if (StringUtils.equals(item1.getImage().getDownload_url(), item2.getImage().getDownload_url())) {
+ item2.setImage(null);
+ }
+ }
+ }
+ }
+ }
+
+ private boolean hasValidFeedItems(Feed feed) {
+ for (FeedItem item : feed.getItems()) {
+ if (item.getTitle() == null) {
+ Log.e(TAG, "Item has no title");
+ return false;
+ }
+ if (item.getPubDate() == null) {
+ Log.e(TAG,
+ "Item has no pubDate. Using current time as pubDate");
+ if (item.getTitle() != null) {
+ Log.e(TAG, "Title of invalid item: " + item.getTitle());
+ }
+ item.setPubDate(new Date());
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Delete files that aren't needed anymore
+ */
+ private void cleanup(Feed feed) {
+ if (feed.getFile_url() != null) {
+ if (new File(feed.getFile_url()).delete())
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Successfully deleted cache file.");
+ else
+ Log.e(TAG, "Failed to delete cache file.");
+ feed.setFile_url(null);
+ } else if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Didn't delete cache file: File url is not set.");
+ }
+ }
+
+ public void shutdown() {
+ isActive = false;
+ if (isCollectingRequests) {
+ interrupt();
+ }
+ }
+
+ public void submitCompletedDownload(DownloadRequest request) {
+ completedRequests.offer(request);
+ if (isCollectingRequests) {
+ interrupt();
+ }
+ }
+
+ }
+
+ /**
+ * Handles failed downloads.
+ * <p/>
+ * If the file has been partially downloaded, this handler will set the file_url of the FeedFile to the location
+ * of the downloaded file.
+ * <p/>
+ * Currently, this handler only handles FeedMedia objects, because Feeds and FeedImages are deleted if the download fails.
+ */
+ class FailedDownloadHandler implements Runnable {
+
+ private DownloadRequest request;
+ private DownloadStatus status;
+
+ FailedDownloadHandler(DownloadStatus status, DownloadRequest request) {
+ this.request = request;
+ this.status = status;
+ }
+
+ @Override
+ public void run() {
+ if (request.isDeleteOnFailure()) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Ignoring failed download, deleteOnFailure=true");
+ } else {
+ File dest = new File(request.getDestination());
+ if (dest.exists() && request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ Log.d(TAG, "File has been partially downloaded. Writing file url");
+ FeedMedia media = DBReader.getFeedMedia(DownloadService.this, request.getFeedfileId());
+ media.setFile_url(request.getDestination());
+ try {
+ DBWriter.setFeedMedia(DownloadService.this, media).get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles a completed image download.
+ */
+ class ImageHandlerThread implements Runnable {
+
+ private DownloadRequest request;
+ private DownloadStatus status;
+
+ public ImageHandlerThread(DownloadStatus status, DownloadRequest request) {
+ Validate.notNull(status);
+ Validate.notNull(request);
+
+ this.status = status;
+ this.request = request;
+ }
+
+ @Override
+ public void run() {
+ FeedImage image = DBReader.getFeedImage(DownloadService.this, request.getFeedfileId());
+ if (image == null) {
+ throw new IllegalStateException("Could not find downloaded image in database");
+ }
+
+ image.setFile_url(request.getDestination());
+ image.setDownloaded(true);
+
+ saveDownloadStatus(status);
+ sendDownloadHandledIntent();
+ DBWriter.setFeedImage(DownloadService.this, image);
+ numberOfDownloads.decrementAndGet();
+ queryDownloadsAsync();
+ }
+ }
+
+ /**
+ * Handles a completed media download.
+ */
+ class MediaHandlerThread implements Runnable {
+
+ private DownloadRequest request;
+ private DownloadStatus status;
+
+ public MediaHandlerThread(DownloadStatus status, DownloadRequest request) {
+ Validate.notNull(status);
+ Validate.notNull(request);
+
+ this.status = status;
+ this.request = request;
+ }
+
+ @Override
+ public void run() {
+ FeedMedia media = DBReader.getFeedMedia(DownloadService.this,
+ request.getFeedfileId());
+ if (media == null) {
+ throw new IllegalStateException(
+ "Could not find downloaded media object in database");
+ }
+ boolean chaptersRead = false;
+ media.setDownloaded(true);
+ media.setFile_url(request.getDestination());
+
+ // Get duration
+ MediaMetadataRetriever mmr = null;
+ try {
+ mmr = new MediaMetadataRetriever();
+ mmr.setDataSource(media.getFile_url());
+ String durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ media.setDuration(Integer.parseInt(durationStr));
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Duration of file is " + media.getDuration());
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ } catch (RuntimeException e) {
+ e.printStackTrace();
+ } finally {
+ if (mmr != null) {
+ mmr.release();
+ }
+ }
+
+ if (media.getItem().getChapters() == null) {
+ ChapterUtils.loadChaptersFromFileUrl(media);
+ if (media.getItem().getChapters() != null) {
+ chaptersRead = true;
+ }
+ }
+
+ try {
+ if (chaptersRead) {
+ DBWriter.setFeedItem(DownloadService.this, media.getItem()).get();
+ }
+ DBWriter.setFeedMedia(DownloadService.this, media).get();
+ if (!DBTasks.isInQueue(DownloadService.this, media.getItem().getId())) {
+ DBWriter.addQueueItem(DownloadService.this, media.getItem().getId()).get();
+ }
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ status = new DownloadStatus(media, media.getEpisodeTitle(), DownloadError.ERROR_DB_ACCESS_ERROR, false, e.getMessage());
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ status = new DownloadStatus(media, media.getEpisodeTitle(), DownloadError.ERROR_DB_ACCESS_ERROR, false, e.getMessage());
+ }
+
+ saveDownloadStatus(status);
+ sendDownloadHandledIntent();
+
+ numberOfDownloads.decrementAndGet();
+ queryDownloadsAsync();
+ }
+ }
+
+ /**
+ * Schedules the notification updater task if it hasn't been scheduled yet.
+ */
+ private void setupNotificationUpdater() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting up notification updater");
+ if (notificationUpdater == null) {
+ notificationUpdater = new NotificationUpdater();
+ notificationUpdaterFuture = schedExecutor.scheduleAtFixedRate(
+ notificationUpdater, 5L, 5L, TimeUnit.SECONDS);
+ }
+ }
+
+ private void cancelNotificationUpdater() {
+ boolean result = false;
+ if (notificationUpdaterFuture != null) {
+ result = notificationUpdaterFuture.cancel(true);
+ }
+ notificationUpdater = null;
+ notificationUpdaterFuture = null;
+ Log.d(TAG, "NotificationUpdater cancelled. Result: " + result);
+ }
+
+ private class NotificationUpdater implements Runnable {
+ public void run() {
+ handler.post(new Runnable() {
+ @Override
+ public void run() {
+ Notification n = updateNotifications();
+ if (n != null) {
+ NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.notify(NOTIFICATION_ID, n);
+ }
+ }
+ });
+ }
+ }
+
+ public List<Downloader> getDownloads() {
+ return downloads;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java
new file mode 100644
index 000000000..1d76770bb
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloadStatus.java
@@ -0,0 +1,181 @@
+package de.danoeh.antennapod.service.download;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.feed.FeedFile;
+import de.danoeh.antennapod.util.DownloadError;
+
+import java.util.Date;
+
+/** Contains status attributes for one download */
+public class DownloadStatus {
+ /**
+ * Downloaders should use this constant for the size attribute if necessary
+ * so that the listadapters etc. can react properly.
+ */
+ public static final int SIZE_UNKNOWN = -1;
+
+ // ----------------------------------- ATTRIBUTES STORED IN DB
+ /** Unique id for storing the object in database. */
+ protected long id;
+ /**
+ * A human-readable string which is shown to the user so that he can
+ * identify the download. Should be the title of the item/feed/media or the
+ * URL if the download has no other title.
+ */
+ protected String title;
+ protected DownloadError reason;
+ /**
+ * A message which can be presented to the user to give more information.
+ * Should be null if Download was successful.
+ */
+ protected String reasonDetailed;
+ protected boolean successful;
+ protected Date completionDate;
+ protected long feedfileId;
+ /**
+ * Is used to determine the type of the feedfile even if the feedfile does
+ * not exist anymore. The value should be FEEDFILETYPE_FEED,
+ * FEEDFILETYPE_FEEDIMAGE or FEEDFILETYPE_FEEDMEDIA
+ */
+ protected int feedfileType;
+
+ // ------------------------------------ NOT STORED IN DB
+ protected boolean done;
+ protected boolean cancelled;
+
+ /** Constructor for restoring Download status entries from DB. */
+ public DownloadStatus(long id, String title, long feedfileId,
+ int feedfileType, boolean successful, DownloadError reason,
+ Date completionDate, String reasonDetailed) {
+ this.id = id;
+ this.title = title;
+ this.done = true;
+ this.feedfileId = feedfileId;
+ this.reason = reason;
+ this.successful = successful;
+ this.completionDate = (Date) completionDate.clone();
+ this.reasonDetailed = reasonDetailed;
+ this.feedfileType = feedfileType;
+ }
+
+ public DownloadStatus(DownloadRequest request, DownloadError reason,
+ boolean successful, boolean cancelled, String reasonDetailed) {
+ Validate.notNull(request);
+
+ this.title = request.getTitle();
+ this.feedfileId = request.getFeedfileId();
+ this.feedfileType = request.getFeedfileType();
+ this.reason = reason;
+ this.successful = successful;
+ this.cancelled = cancelled;
+ this.reasonDetailed = reasonDetailed;
+ this.completionDate = new Date();
+ }
+
+ /** Constructor for creating new completed downloads. */
+ public DownloadStatus(FeedFile feedfile, String title, DownloadError reason,
+ boolean successful, String reasonDetailed) {
+ Validate.notNull(feedfile);
+
+ this.title = title;
+ this.done = true;
+ this.feedfileId = feedfile.getId();
+ this.feedfileType = feedfile.getTypeAsInt();
+ this.reason = reason;
+ this.successful = successful;
+ this.completionDate = new Date();
+ this.reasonDetailed = reasonDetailed;
+ }
+
+ /** Constructor for creating new completed downloads. */
+ public DownloadStatus(long feedfileId, int feedfileType, String title,
+ DownloadError reason, boolean successful, String reasonDetailed) {
+ this.title = title;
+ this.done = true;
+ this.feedfileId = feedfileId;
+ this.feedfileType = feedfileType;
+ this.reason = reason;
+ this.successful = successful;
+ this.completionDate = new Date();
+ this.reasonDetailed = reasonDetailed;
+ }
+
+ @Override
+ public String toString() {
+ return "DownloadStatus [id=" + id + ", title=" + title + ", reason="
+ + reason + ", reasonDetailed=" + reasonDetailed
+ + ", successful=" + successful + ", completionDate="
+ + completionDate + ", feedfileId=" + feedfileId
+ + ", feedfileType=" + feedfileType + ", done=" + done
+ + ", cancelled=" + cancelled + "]";
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public DownloadError getReason() {
+ return reason;
+ }
+
+ public String getReasonDetailed() {
+ return reasonDetailed;
+ }
+
+ public boolean isSuccessful() {
+ return successful;
+ }
+
+ public Date getCompletionDate() {
+ return (Date) completionDate.clone();
+ }
+
+ public long getFeedfileId() {
+ return feedfileId;
+ }
+
+ public int getFeedfileType() {
+ return feedfileType;
+ }
+
+ public boolean isDone() {
+ return done;
+ }
+
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ public void setSuccessful() {
+ this.successful = true;
+ this.reason = DownloadError.SUCCESS;
+ this.done = true;
+ }
+
+ public void setFailed(DownloadError reason, String reasonDetailed) {
+ this.successful = false;
+ this.reason = reason;
+ this.reasonDetailed = reasonDetailed;
+ this.done = true;
+ }
+
+ public void setCancelled() {
+ this.successful = false;
+ this.reason = DownloadError.ERROR_DOWNLOAD_CANCELLED;
+ this.done = true;
+ this.cancelled = true;
+ }
+
+ public void setCompletionDate(Date completionDate) {
+ this.completionDate = (Date) completionDate.clone();
+ }
+
+ public void setId(long id) {
+ this.id = id;
+ }
+} \ No newline at end of file
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java b/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java
new file mode 100644
index 000000000..80cc5b3f8
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/Downloader.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.service.download;
+
+import android.content.Context;
+import android.net.wifi.WifiManager;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.R;
+
+import java.util.concurrent.Callable;
+
+/** Downloads files */
+public abstract class Downloader implements Callable<Downloader> {
+ private static final String TAG = "Downloader";
+
+ protected volatile boolean finished;
+
+ protected volatile boolean cancelled;
+
+ protected DownloadRequest request;
+ protected DownloadStatus result;
+
+ public Downloader(DownloadRequest request) {
+ super();
+ this.request = request;
+ this.request.setStatusMsg(R.string.download_pending);
+ this.cancelled = false;
+ this.result = new DownloadStatus(request, null, false, false, null);
+ }
+
+ protected abstract void download();
+
+ public final Downloader call() {
+ WifiManager wifiManager = (WifiManager) PodcastApp.getInstance().getSystemService(Context.WIFI_SERVICE);
+ WifiManager.WifiLock wifiLock = null;
+ if (wifiManager != null) {
+ wifiLock = wifiManager.createWifiLock(TAG);
+ wifiLock.acquire();
+ }
+
+ download();
+
+ if (wifiLock != null) {
+ wifiLock.release();
+ }
+
+ if (result == null) {
+ throw new IllegalStateException(
+ "Downloader hasn't created DownloadStatus object");
+ }
+ finished = true;
+ return this;
+ }
+
+ public DownloadRequest getDownloadRequest() {
+ return request;
+ }
+
+ public DownloadStatus getResult() {
+ return result;
+ }
+
+ public boolean isFinished() {
+ return finished;
+ }
+
+ public void cancel() {
+ cancelled = true;
+ }
+
+} \ No newline at end of file
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java b/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java
new file mode 100644
index 000000000..08420e83a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/DownloaderCallback.java
@@ -0,0 +1,10 @@
+package de.danoeh.antennapod.service.download;
+
+/**
+ * Callback used by the Downloader-classes to notify the requester that the
+ * download has completed.
+ */
+public interface DownloaderCallback {
+
+ public void onDownloadCompleted(Downloader downloader);
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java b/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java
new file mode 100644
index 000000000..7ae96dc07
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/download/HttpDownloader.java
@@ -0,0 +1,246 @@
+package de.danoeh.antennapod.service.download;
+
+import android.net.http.AndroidHttpClient;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.util.DownloadError;
+import de.danoeh.antennapod.util.StorageUtils;
+import de.danoeh.antennapod.util.URIUtil;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.auth.BasicScheme;
+import org.apache.http.message.BasicHeader;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.UnknownHostException;
+
+public class HttpDownloader extends Downloader {
+ private static final String TAG = "HttpDownloader";
+
+ private static final int BUFFER_SIZE = 8 * 1024;
+
+ public HttpDownloader(DownloadRequest request) {
+ super(request);
+ }
+
+ @Override
+ protected void download() {
+ File destination = new File(request.getDestination());
+ final boolean fileExists = destination.exists();
+
+ if (request.isDeleteOnFailure() && fileExists) {
+ Log.w(TAG, "File already exists");
+ if (request.getFeedfileType() != FeedImage.FEEDFILETYPE_FEEDIMAGE) {
+ onFail(DownloadError.ERROR_FILE_EXISTS, null);
+ return;
+ } else {
+ onSuccess();
+ return;
+ }
+ }
+
+ HttpClient httpClient = AntennapodHttpClient.getHttpClient();
+ RandomAccessFile out = null;
+ InputStream connection = null;
+ try {
+ HttpGet httpGet = new HttpGet(URIUtil.getURIFromRequestUrl(request.getSource()));
+
+ // add authentication information
+ String userInfo = httpGet.getURI().getUserInfo();
+ if (userInfo != null) {
+ String[] parts = userInfo.split(":");
+ if (parts.length == 2) {
+ httpGet.addHeader(BasicScheme.authenticate(
+ new UsernamePasswordCredentials(parts[0], parts[1]),
+ "UTF-8", false));
+ }
+ } else if (!StringUtils.isEmpty(request.getUsername()) && request.getPassword() != null) {
+ httpGet.addHeader(BasicScheme.authenticate(new UsernamePasswordCredentials(request.getUsername(),
+ request.getPassword()), "UTF-8", false));
+ }
+
+ // add range header if necessary
+ if (fileExists) {
+ request.setSoFar(destination.length());
+ httpGet.addHeader(new BasicHeader("Range",
+ "bytes=" + request.getSoFar() + "-"));
+ if (BuildConfig.DEBUG) Log.d(TAG, "Adding range header: " + request.getSoFar());
+ }
+
+ HttpResponse response = httpClient.execute(httpGet);
+ HttpEntity httpEntity = response.getEntity();
+ int responseCode = response.getStatusLine().getStatusCode();
+ Header contentEncodingHeader = response.getFirstHeader("Content-Encoding");
+
+ final boolean isGzip = contentEncodingHeader != null &&
+ contentEncodingHeader.getValue().equalsIgnoreCase("gzip");
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Response code is " + responseCode);
+
+ if (responseCode / 100 != 2 || httpEntity == null) {
+ final DownloadError error;
+ final String details;
+ if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ error = DownloadError.ERROR_UNAUTHORIZED;
+ details = String.valueOf(responseCode);
+ } else {
+ error = DownloadError.ERROR_HTTP_DATA_ERROR;
+ details = String.valueOf(responseCode);
+ }
+ onFail(error, details);
+ return;
+ }
+
+ if (!StorageUtils.storageAvailable(PodcastApp.getInstance())) {
+ onFail(DownloadError.ERROR_DEVICE_NOT_FOUND, null);
+ return;
+ }
+
+ connection = new BufferedInputStream(AndroidHttpClient
+ .getUngzippedContent(httpEntity));
+
+ Header[] contentRangeHeaders = (fileExists) ? response.getHeaders("Content-Range") : null;
+
+ if (fileExists && responseCode == HttpStatus.SC_PARTIAL_CONTENT
+ && contentRangeHeaders != null && contentRangeHeaders.length > 0) {
+ String start = contentRangeHeaders[0].getValue().substring("bytes ".length(),
+ contentRangeHeaders[0].getValue().indexOf("-"));
+ request.setSoFar(Long.valueOf(start));
+ Log.d(TAG, "Starting download at position " + request.getSoFar());
+
+ out = new RandomAccessFile(destination, "rw");
+ out.seek(request.getSoFar());
+ } else {
+ destination.delete();
+ destination.createNewFile();
+ out = new RandomAccessFile(destination, "rw");
+ }
+
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int count = 0;
+ request.setStatusMsg(R.string.download_running);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Getting size of download");
+ request.setSize(httpEntity.getContentLength() + request.getSoFar());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Size is " + request.getSize());
+ if (request.getSize() < 0) {
+ request.setSize(DownloadStatus.SIZE_UNKNOWN);
+ }
+
+ long freeSpace = StorageUtils.getFreeSpaceAvailable();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Free space is " + freeSpace);
+
+ if (request.getSize() != DownloadStatus.SIZE_UNKNOWN
+ && request.getSize() > freeSpace) {
+ onFail(DownloadError.ERROR_NOT_ENOUGH_SPACE, null);
+ return;
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting download");
+ while (!cancelled
+ && (count = connection.read(buffer)) != -1) {
+ out.write(buffer, 0, count);
+ request.setSoFar(request.getSoFar() + count);
+ request.setProgressPercent((int) (((double) request
+ .getSoFar() / (double) request
+ .getSize()) * 100));
+ }
+ if (cancelled) {
+ onCancelled();
+ } else {
+ // check if size specified in the response header is the same as the size of the
+ // written file. This check cannot be made if compression was used
+ if (!isGzip && request.getSize() != DownloadStatus.SIZE_UNKNOWN &&
+ request.getSoFar() != request.getSize()) {
+ onFail(DownloadError.ERROR_IO_ERROR,
+ "Download completed but size: " +
+ request.getSoFar() +
+ " does not equal expected size " +
+ request.getSize()
+ );
+ return;
+ }
+ onSuccess();
+ }
+
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_MALFORMED_URL, e.getMessage());
+ } catch (SocketTimeoutException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, e.getMessage());
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage());
+ } catch (IOException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_IO_ERROR, e.getMessage());
+ } catch (NullPointerException e) {
+ // might be thrown by connection.getInputStream()
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, request.getSource());
+ } finally {
+ IOUtils.closeQuietly(out);
+ AntennapodHttpClient.cleanup();
+ }
+ }
+
+ private void onSuccess() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Download was successful");
+ result.setSuccessful();
+ }
+
+ private void onFail(DownloadError reason, String reasonDetailed) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Download failed");
+ }
+ result.setFailed(reason, reasonDetailed);
+ if (request.isDeleteOnFailure()) {
+ cleanup();
+ }
+ }
+
+ private void onCancelled() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Download was cancelled");
+ result.setCancelled();
+ cleanup();
+ }
+
+ /**
+ * Deletes unfinished downloads.
+ */
+ private void cleanup() {
+ if (request.getDestination() != null) {
+ File dest = new File(request.getDestination());
+ if (dest.exists()) {
+ boolean rc = dest.delete();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Deleted file " + dest.getName() + "; Result: "
+ + rc);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "cleanup() didn't delete file: does not exist.");
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java
new file mode 100644
index 000000000..59d7ddbb9
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackService.java
@@ -0,0 +1,1080 @@
+package de.danoeh.antennapod.service.playback;
+
+import android.annotation.SuppressLint;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.media.AudioManager;
+import android.media.MediaMetadataRetriever;
+import android.media.MediaPlayer;
+import android.media.RemoteControlClient;
+import android.media.RemoteControlClient.MetadataEditor;
+import android.os.AsyncTask;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.support.v4.app.NotificationCompat;
+import android.util.Log;
+import android.util.Pair;
+import android.view.KeyEvent;
+import android.view.SurfaceHolder;
+import android.widget.Toast;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.AudioplayerActivity;
+import de.danoeh.antennapod.activity.VideoplayerActivity;
+import de.danoeh.antennapod.asynctask.PicassoProvider;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.receiver.MediaButtonReceiver;
+import de.danoeh.antennapod.receiver.PlayerWidget;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+import de.danoeh.antennapod.util.playback.Playable;
+
+/**
+ * Controls the MediaPlayer that plays a FeedMedia-file
+ */
+public class PlaybackService extends Service {
+ /**
+ * Logging tag
+ */
+ private static final String TAG = "PlaybackService";
+
+ /**
+ * Parcelable of type Playable.
+ */
+ public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra";
+ /**
+ * True if media should be streamed.
+ */
+ public static final String EXTRA_SHOULD_STREAM = "extra.de.danoeh.antennapod.service.shouldStream";
+ /**
+ * True if playback should be started immediately after media has been
+ * prepared.
+ */
+ public static final String EXTRA_START_WHEN_PREPARED = "extra.de.danoeh.antennapod.service.startWhenPrepared";
+
+ public static final String EXTRA_PREPARE_IMMEDIATELY = "extra.de.danoeh.antennapod.service.prepareImmediately";
+
+ public static final String ACTION_PLAYER_STATUS_CHANGED = "action.de.danoeh.antennapod.service.playerStatusChanged";
+ private static final String AVRCP_ACTION_PLAYER_STATUS_CHANGED = "com.android.music.playstatechanged";
+ private static final String AVRCP_ACTION_META_CHANGED = "com.android.music.metachanged";
+
+ public static final String ACTION_PLAYER_NOTIFICATION = "action.de.danoeh.antennapod.service.playerNotification";
+ public static final String EXTRA_NOTIFICATION_CODE = "extra.de.danoeh.antennapod.service.notificationCode";
+ public static final String EXTRA_NOTIFICATION_TYPE = "extra.de.danoeh.antennapod.service.notificationType";
+
+ /**
+ * If the PlaybackService receives this action, it will stop playback and
+ * try to shutdown.
+ */
+ public static final String ACTION_SHUTDOWN_PLAYBACK_SERVICE = "action.de.danoeh.antennapod.service.actionShutdownPlaybackService";
+
+ /**
+ * If the PlaybackService receives this action, it will end playback of the
+ * current episode and load the next episode if there is one available.
+ */
+ public static final String ACTION_SKIP_CURRENT_EPISODE = "action.de.danoeh.antennapod.service.skipCurrentEpisode";
+
+ /**
+ * Used in NOTIFICATION_TYPE_RELOAD.
+ */
+ public static final int EXTRA_CODE_AUDIO = 1;
+ public static final int EXTRA_CODE_VIDEO = 2;
+
+ public static final int NOTIFICATION_TYPE_ERROR = 0;
+ public static final int NOTIFICATION_TYPE_INFO = 1;
+ public static final int NOTIFICATION_TYPE_BUFFER_UPDATE = 2;
+
+ /**
+ * Receivers of this intent should update their information about the curently playing media
+ */
+ public static final int NOTIFICATION_TYPE_RELOAD = 3;
+ /**
+ * The state of the sleeptimer changed.
+ */
+ public static final int NOTIFICATION_TYPE_SLEEPTIMER_UPDATE = 4;
+ public static final int NOTIFICATION_TYPE_BUFFER_START = 5;
+ public static final int NOTIFICATION_TYPE_BUFFER_END = 6;
+ /**
+ * No more episodes are going to be played.
+ */
+ public static final int NOTIFICATION_TYPE_PLAYBACK_END = 7;
+
+ /**
+ * Playback speed has changed
+ */
+ public static final int NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE = 8;
+
+ /**
+ * Returned by getPositionSafe() or getDurationSafe() if the playbackService
+ * is in an invalid state.
+ */
+ public static final int INVALID_TIME = -1;
+
+ /**
+ * Is true if service is running.
+ */
+ public static boolean isRunning = false;
+ /**
+ * Is true if service has received a valid start command.
+ */
+ public static boolean started = false;
+
+ private static final int NOTIFICATION_ID = 1;
+
+ private RemoteControlClient remoteControlClient;
+ private PlaybackServiceMediaPlayer mediaPlayer;
+ private PlaybackServiceTaskManager taskManager;
+
+ private static volatile MediaType currentMediaType = MediaType.UNKNOWN;
+
+ private final IBinder mBinder = new LocalBinder();
+
+ public class LocalBinder extends Binder {
+ public PlaybackService getService() {
+ return PlaybackService.this;
+ }
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received onUnbind event");
+ return super.onUnbind(intent);
+ }
+
+ /**
+ * Returns an intent which starts an audio- or videoplayer, depending on the
+ * type of media that is being played. If the playbackservice is not
+ * running, the type of the last played media will be looked up.
+ */
+ public static Intent getPlayerActivityIntent(Context context) {
+ if (isRunning) {
+ if (currentMediaType == MediaType.VIDEO) {
+ return new Intent(context, VideoplayerActivity.class);
+ } else {
+ return new Intent(context, AudioplayerActivity.class);
+ }
+ } else {
+ if (PlaybackPreferences.getCurrentEpisodeIsVideo()) {
+ return new Intent(context, VideoplayerActivity.class);
+ } else {
+ return new Intent(context, AudioplayerActivity.class);
+ }
+ }
+ }
+
+ /**
+ * Same as getPlayerActivityIntent(context), but here the type of activity
+ * depends on the FeedMedia that is provided as an argument.
+ */
+ public static Intent getPlayerActivityIntent(Context context, Playable media) {
+ MediaType mt = media.getMediaType();
+ if (mt == MediaType.VIDEO) {
+ return new Intent(context, VideoplayerActivity.class);
+ } else {
+ return new Intent(context, AudioplayerActivity.class);
+ }
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service created.");
+ isRunning = true;
+
+ registerReceiver(headsetDisconnected, new IntentFilter(
+ Intent.ACTION_HEADSET_PLUG));
+ registerReceiver(shutdownReceiver, new IntentFilter(
+ ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+ registerReceiver(audioBecomingNoisy, new IntentFilter(
+ AudioManager.ACTION_AUDIO_BECOMING_NOISY));
+ registerReceiver(skipCurrentEpisodeReceiver, new IntentFilter(
+ ACTION_SKIP_CURRENT_EPISODE));
+ remoteControlClient = setupRemoteControlClient();
+ taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback);
+ mediaPlayer = new PlaybackServiceMediaPlayer(this, mediaPlayerCallback);
+
+ }
+
+ @SuppressLint("NewApi")
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service is about to be destroyed");
+ isRunning = false;
+ started = false;
+ currentMediaType = MediaType.UNKNOWN;
+
+ unregisterReceiver(headsetDisconnected);
+ unregisterReceiver(shutdownReceiver);
+ unregisterReceiver(audioBecomingNoisy);
+ unregisterReceiver(skipCurrentEpisodeReceiver);
+ mediaPlayer.shutdown();
+ taskManager.shutdown();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received onBind event");
+ return mBinder;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ super.onStartCommand(intent, flags, startId);
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "OnStartCommand called");
+ final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1);
+ final Playable playable = intent.getParcelableExtra(EXTRA_PLAYABLE);
+ if (keycode == -1 && playable == null) {
+ Log.e(TAG, "PlaybackService was started with no arguments");
+ stopSelf();
+ }
+
+ if ((flags & Service.START_FLAG_REDELIVERY) != 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "onStartCommand is a redelivered intent, calling stopForeground now.");
+ stopForeground(true);
+ } else {
+
+ if (keycode != -1) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received media button event");
+ handleKeycode(keycode);
+ } else {
+ started = true;
+ boolean stream = intent.getBooleanExtra(EXTRA_SHOULD_STREAM,
+ true);
+ boolean startWhenPrepared = intent.getBooleanExtra(EXTRA_START_WHEN_PREPARED, false);
+ boolean prepareImmediately = intent.getBooleanExtra(EXTRA_PREPARE_IMMEDIATELY, false);
+ sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0);
+ mediaPlayer.playMediaObject(playable, stream, startWhenPrepared, prepareImmediately);
+ }
+ }
+
+ return Service.START_REDELIVER_INTENT;
+ }
+
+ /**
+ * Handles media button events
+ */
+ private void handleKeycode(int keycode) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Handling keycode: " + keycode);
+
+ final PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo();
+ final PlayerStatus status = info.playerStatus;
+ switch (keycode) {
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ if (status == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(true, true);
+ } else if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ mediaPlayer.resume();
+ } else if (status == PlayerStatus.PREPARING) {
+ mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared());
+ } else if (status == PlayerStatus.INITIALIZED) {
+ mediaPlayer.setStartWhenPrepared(true);
+ mediaPlayer.prepare();
+ }
+ break;
+ case KeyEvent.KEYCODE_MEDIA_PLAY:
+ if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) {
+ mediaPlayer.resume();
+ } else if (status == PlayerStatus.INITIALIZED) {
+ mediaPlayer.setStartWhenPrepared(true);
+ mediaPlayer.prepare();
+ }
+ break;
+ case KeyEvent.KEYCODE_MEDIA_PAUSE:
+ if (status == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(true, true);
+ }
+ break;
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ mediaPlayer.seekDelta(UserPreferences.getSeekDeltaMs());
+ break;
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ case KeyEvent.KEYCODE_MEDIA_REWIND:
+ mediaPlayer.seekDelta(-UserPreferences.getSeekDeltaMs());
+ break;
+ default:
+ if (info.playable != null && info.playerStatus == PlayerStatus.PLAYING) { // only notify the user about an unknown key event if it is actually doing something
+ String message = String.format(getResources().getString(R.string.unknown_media_key), keycode);
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Called by a mediaplayer Activity as soon as it has prepared its
+ * mediaplayer.
+ */
+ public void setVideoSurface(SurfaceHolder sh) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting display");
+ mediaPlayer.setVideoSurface(sh);
+ }
+
+ /**
+ * Called when the surface holder of the mediaplayer has to be changed.
+ */
+ private void resetVideoSurface() {
+ taskManager.cancelPositionSaver();
+ mediaPlayer.resetVideoSurface();
+ }
+
+ public void notifyVideoSurfaceAbandoned() {
+ stopForeground(true);
+ mediaPlayer.resetVideoSurface();
+ }
+
+ private final PlaybackServiceTaskManager.PSTMCallback taskManagerCallback = new PlaybackServiceTaskManager.PSTMCallback() {
+ @Override
+ public void positionSaverTick() {
+ saveCurrentPosition(true, PlaybackServiceTaskManager.POSITION_SAVER_WAITING_INTERVAL);
+ }
+
+ @Override
+ public void onSleepTimerExpired() {
+ mediaPlayer.pause(true, true);
+ sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0);
+ }
+
+
+ @Override
+ public void onWidgetUpdaterTick() {
+ updateWidget();
+ }
+
+ @Override
+ public void onChapterLoaded(Playable media) {
+ sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0);
+ }
+ };
+
+ private final PlaybackServiceMediaPlayer.PSMPCallback mediaPlayerCallback = new PlaybackServiceMediaPlayer.PSMPCallback() {
+ @Override
+ public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) {
+ currentMediaType = mediaPlayer.getCurrentMediaType();
+ switch (newInfo.playerStatus) {
+ case INITIALIZED:
+ writePlaybackPreferences();
+ break;
+
+ case PREPARED:
+ taskManager.startChapterLoader(newInfo.playable);
+ break;
+
+ case PAUSED:
+ taskManager.cancelPositionSaver();
+ saveCurrentPosition(false, 0);
+ taskManager.cancelWidgetUpdater();
+ stopForeground(true);
+ break;
+
+ case STOPPED:
+ //setCurrentlyPlayingMedia(PlaybackPreferences.NO_MEDIA_PLAYING);
+ //stopSelf();
+ break;
+
+ case PLAYING:
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Audiofocus successfully requested");
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resuming/Starting playback");
+
+ taskManager.startPositionSaver();
+ taskManager.startWidgetUpdater();
+ setupNotification(newInfo);
+ break;
+ case ERROR:
+ writePlaybackPreferencesNoMediaPlaying();
+ break;
+
+ }
+
+ sendBroadcast(new Intent(ACTION_PLAYER_STATUS_CHANGED));
+ updateWidget();
+ refreshRemoteControlClientState(newInfo);
+ bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED);
+ bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED);
+ }
+
+ @Override
+ public void shouldStop() {
+ stopSelf();
+ }
+
+ @Override
+ public void playbackSpeedChanged(float s) {
+ sendNotificationBroadcast(
+ NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE, 0);
+ }
+
+ @Override
+ public void onBufferingUpdate(int percent) {
+ sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_UPDATE, percent);
+ }
+
+ @Override
+ public boolean onMediaPlayerInfo(int code) {
+ switch (code) {
+ case MediaPlayer.MEDIA_INFO_BUFFERING_START:
+ sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_START, 0);
+ return true;
+ case MediaPlayer.MEDIA_INFO_BUFFERING_END:
+ sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_END, 0);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onMediaPlayerError(Object inObj, int what, int extra) {
+ final String TAG = "PlaybackService.onErrorListener";
+ Log.w(TAG, "An error has occured: " + what + " " + extra);
+ if (mediaPlayer.getPSMPInfo().playerStatus == PlayerStatus.PLAYING) {
+ mediaPlayer.pause(true, false);
+ }
+ sendNotificationBroadcast(NOTIFICATION_TYPE_ERROR, what);
+ writePlaybackPreferencesNoMediaPlaying();
+ stopSelf();
+ return true;
+ }
+
+ @Override
+ public boolean endPlayback(boolean playNextEpisode) {
+ PlaybackService.this.endPlayback(true);
+ return true;
+ }
+
+ @Override
+ public RemoteControlClient getRemoteControlClient() {
+ return remoteControlClient;
+ }
+ };
+
+ private void endPlayback(boolean playNextEpisode) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Playback ended");
+
+ final Playable media = mediaPlayer.getPSMPInfo().playable;
+ if (media == null) {
+ Log.e(TAG, "Cannot end playback: media was null");
+ return;
+ }
+
+ taskManager.cancelPositionSaver();
+
+ boolean isInQueue = false;
+ FeedItem nextItem = null;
+
+ if (media instanceof FeedMedia) {
+ FeedItem item = ((FeedMedia) media).getItem();
+ DBWriter.markItemRead(PlaybackService.this, item, true, true);
+
+ try {
+ final List<FeedItem> queue = taskManager.getQueue();
+ isInQueue = QueueAccess.ItemListAccess(queue).contains(((FeedMedia) media).getItem().getId());
+ nextItem = DBTasks.getQueueSuccessorOfItem(this, item.getId(), queue);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ // isInQueue remains false
+ }
+ if (isInQueue) {
+ DBWriter.removeQueueItem(PlaybackService.this, item.getId(), true);
+ }
+ DBWriter.addItemToPlaybackHistory(PlaybackService.this, (FeedMedia) media);
+
+ // auto-flattr if enabled
+ if (isAutoFlattrable(media) && UserPreferences.getAutoFlattrPlayedDurationThreshold() == 1.0f) {
+ DBTasks.flattrItemIfLoggedIn(PlaybackService.this, item);
+ }
+ }
+
+ // Load next episode if previous episode was in the queue and if there
+ // is an episode in the queue left.
+ // Start playback immediately if continuous playback is enabled
+ Playable nextMedia = null;
+ boolean loadNextItem = isInQueue && nextItem != null;
+ playNextEpisode = playNextEpisode && loadNextItem
+ && UserPreferences.isFollowQueue();
+ if (loadNextItem) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading next item in queue");
+ nextMedia = nextItem.getMedia();
+ }
+ final boolean prepareImmediately;
+ final boolean startWhenPrepared;
+ final boolean stream;
+
+ if (playNextEpisode) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Playback of next episode will start immediately.");
+ prepareImmediately = startWhenPrepared = true;
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "No more episodes available to play");
+
+ prepareImmediately = startWhenPrepared = false;
+ stopForeground(true);
+ stopWidgetUpdater();
+ }
+
+ writePlaybackPreferencesNoMediaPlaying();
+ if (nextMedia != null) {
+ stream = !media.localFileAvailable();
+ mediaPlayer.playMediaObject(nextMedia, stream, startWhenPrepared, prepareImmediately);
+ sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD,
+ (nextMedia.getMediaType() == MediaType.VIDEO) ? EXTRA_CODE_VIDEO : EXTRA_CODE_AUDIO);
+ } else {
+ sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0);
+ mediaPlayer.stop();
+ //stopSelf();
+ }
+ }
+
+ public void setSleepTimer(long waitingTime) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting sleep timer to " + Long.toString(waitingTime)
+ + " milliseconds");
+ taskManager.setSleepTimer(waitingTime);
+ sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0);
+ }
+
+ public void disableSleepTimer() {
+ taskManager.disableSleepTimer();
+ sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0);
+ }
+
+ private void writePlaybackPreferencesNoMediaPlaying() {
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(getApplicationContext()).edit();
+ editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.commit();
+ }
+
+
+ private void writePlaybackPreferences() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Writing playback preferences");
+
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(getApplicationContext()).edit();
+ PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo();
+ MediaType mediaType = mediaPlayer.getCurrentMediaType();
+ boolean stream = mediaPlayer.isStreaming();
+
+ if (info.playable != null) {
+ editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA,
+ info.playable.getPlayableType());
+ editor.putBoolean(
+ PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM,
+ stream);
+ editor.putBoolean(
+ PlaybackPreferences.PREF_CURRENT_EPISODE_IS_VIDEO,
+ mediaType == MediaType.VIDEO);
+ if (info.playable instanceof FeedMedia) {
+ FeedMedia fMedia = (FeedMedia) info.playable;
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
+ fMedia.getItem().getFeed().getId());
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID,
+ fMedia.getId());
+ } else {
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ }
+ info.playable.writeToPreferences(editor);
+ } else {
+ editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID,
+ PlaybackPreferences.NO_MEDIA_PLAYING);
+ }
+
+ editor.commit();
+ }
+
+ /**
+ * Send ACTION_PLAYER_STATUS_CHANGED without changing the status attribute.
+ */
+ private void postStatusUpdateIntent() {
+ sendBroadcast(new Intent(ACTION_PLAYER_STATUS_CHANGED));
+ }
+
+ private void sendNotificationBroadcast(int type, int code) {
+ Intent intent = new Intent(ACTION_PLAYER_NOTIFICATION);
+ intent.putExtra(EXTRA_NOTIFICATION_TYPE, type);
+ intent.putExtra(EXTRA_NOTIFICATION_CODE, code);
+ sendBroadcast(intent);
+ }
+
+ /**
+ * Used by setupNotification to load notification data in another thread.
+ */
+ private AsyncTask<Void, Void, Void> notificationSetupTask;
+
+ /**
+ * Prepares notification and starts the service in the foreground.
+ */
+ @SuppressLint("NewApi")
+ private void setupNotification(final PlaybackServiceMediaPlayer.PSMPInfo info) {
+ final PendingIntent pIntent = PendingIntent.getActivity(this, 0,
+ PlaybackService.getPlayerActivityIntent(this),
+ PendingIntent.FLAG_UPDATE_CURRENT);
+
+ if (notificationSetupTask != null) {
+ notificationSetupTask.cancel(true);
+ }
+ notificationSetupTask = new AsyncTask<Void, Void, Void>() {
+ Bitmap icon = null;
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting background work");
+ if (android.os.Build.VERSION.SDK_INT >= 11) {
+ if (info.playable != null) {
+ try {
+ int iconSize = getResources().getDimensionPixelSize(
+ android.R.dimen.notification_large_icon_width);
+ icon = PicassoProvider.getMediaMetadataPicassoInstance(PlaybackService.this)
+ .load(info.playable.getImageUri())
+ .resize(iconSize, iconSize)
+ .get();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ }
+ if (icon == null) {
+ icon = BitmapFactory.decodeResource(getResources(),
+ R.drawable.ic_stat_antenna);
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ if (!isCancelled() && info.playerStatus == PlayerStatus.PLAYING
+ && info.playable != null) {
+ String contentText = info.playable.getFeedTitle();
+ String contentTitle = info.playable.getEpisodeTitle();
+ Notification notification = null;
+ if (android.os.Build.VERSION.SDK_INT >= 16) {
+ Intent pauseButtonIntent = new Intent(
+ PlaybackService.this, PlaybackService.class);
+ pauseButtonIntent.putExtra(
+ MediaButtonReceiver.EXTRA_KEYCODE,
+ KeyEvent.KEYCODE_MEDIA_PAUSE);
+ PendingIntent pauseButtonPendingIntent = PendingIntent
+ .getService(PlaybackService.this, 0,
+ pauseButtonIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ Notification.Builder notificationBuilder = new Notification.Builder(
+ PlaybackService.this)
+ .setContentTitle(contentTitle)
+ .setContentText(contentText)
+ .setOngoing(true)
+ .setContentIntent(pIntent)
+ .setLargeIcon(icon)
+ .setSmallIcon(R.drawable.ic_stat_antenna)
+ .addAction(android.R.drawable.ic_media_pause,
+ getString(R.string.pause_label),
+ pauseButtonPendingIntent);
+ notification = notificationBuilder.build();
+ } else {
+ NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(
+ PlaybackService.this)
+ .setContentTitle(contentTitle)
+ .setContentText(contentText).setOngoing(true)
+ .setContentIntent(pIntent).setLargeIcon(icon)
+ .setSmallIcon(R.drawable.ic_stat_antenna);
+ notification = notificationBuilder.getNotification();
+ }
+ startForeground(NOTIFICATION_ID, notification);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Notification set up");
+ }
+ }
+
+ };
+ if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.GINGERBREAD_MR1) {
+ notificationSetupTask
+ .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ notificationSetupTask.execute();
+ }
+
+ }
+
+ /**
+ * Saves the current position of the media file to the DB
+ *
+ * @param updatePlayedDuration true if played_duration should be updated. This applies only to FeedMedia objects
+ * @param deltaPlayedDuration value by which played_duration should be increased.
+ */
+ private synchronized void saveCurrentPosition(boolean updatePlayedDuration, int deltaPlayedDuration) {
+ int position = getCurrentPosition();
+ int duration = getDuration();
+ float playbackSpeed = getCurrentPlaybackSpeed();
+ final Playable playable = mediaPlayer.getPSMPInfo().playable;
+ if (position != INVALID_TIME && duration != INVALID_TIME && playable != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Saving current position to " + position);
+ if (updatePlayedDuration && playable instanceof FeedMedia) {
+ FeedMedia m = (FeedMedia) playable;
+ FeedItem item = m.getItem();
+ m.setPlayedDuration(m.getPlayedDuration() + ((int) (deltaPlayedDuration * playbackSpeed)));
+ // Auto flattr
+ if (isAutoFlattrable(m) &&
+ (m.getPlayedDuration() > UserPreferences.getAutoFlattrPlayedDurationThreshold() * duration)) {
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "saveCurrentPosition: performing auto flattr since played duration " + Integer.toString(m.getPlayedDuration())
+ + " is " + UserPreferences.getAutoFlattrPlayedDurationThreshold() * 100 + "% of file duration " + Integer.toString(duration));
+ DBTasks.flattrItemIfLoggedIn(this, item);
+ }
+ }
+ playable.saveCurrentPosition(PreferenceManager
+ .getDefaultSharedPreferences(getApplicationContext()),
+ position
+ );
+ }
+ }
+
+ private void stopWidgetUpdater() {
+ taskManager.cancelWidgetUpdater();
+ sendBroadcast(new Intent(PlayerWidget.STOP_WIDGET_UPDATE));
+ }
+
+ private void updateWidget() {
+ PlaybackService.this.sendBroadcast(new Intent(
+ PlayerWidget.FORCE_WIDGET_UPDATE));
+ }
+
+ public boolean sleepTimerActive() {
+ return taskManager.isSleepTimerActive();
+ }
+
+ public long getSleepTimerTimeLeft() {
+ return taskManager.getSleepTimerTimeLeft();
+ }
+
+ @SuppressLint("NewApi")
+ private RemoteControlClient setupRemoteControlClient() {
+ if (Build.VERSION.SDK_INT < 14) {
+ return null;
+ }
+
+ Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
+ mediaButtonIntent.setComponent(new ComponentName(getPackageName(),
+ MediaButtonReceiver.class.getName()));
+ PendingIntent mediaPendingIntent = PendingIntent.getBroadcast(
+ getApplicationContext(), 0, mediaButtonIntent, 0);
+ remoteControlClient = new RemoteControlClient(mediaPendingIntent);
+ int controlFlags;
+ if (android.os.Build.VERSION.SDK_INT < 16) {
+ controlFlags = RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE
+ | RemoteControlClient.FLAG_KEY_MEDIA_NEXT;
+ } else {
+ controlFlags = RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE;
+ }
+ remoteControlClient.setTransportControlFlags(controlFlags);
+ return remoteControlClient;
+ }
+
+ /**
+ * Refresh player status and metadata.
+ */
+ @SuppressLint("NewApi")
+ private void refreshRemoteControlClientState(PlaybackServiceMediaPlayer.PSMPInfo info) {
+ if (android.os.Build.VERSION.SDK_INT >= 14) {
+ if (remoteControlClient != null) {
+ switch (info.playerStatus) {
+ case PLAYING:
+ remoteControlClient
+ .setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING);
+ break;
+ case PAUSED:
+ case INITIALIZED:
+ remoteControlClient
+ .setPlaybackState(RemoteControlClient.PLAYSTATE_PAUSED);
+ break;
+ case STOPPED:
+ remoteControlClient
+ .setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
+ break;
+ case ERROR:
+ remoteControlClient
+ .setPlaybackState(RemoteControlClient.PLAYSTATE_ERROR);
+ break;
+ default:
+ remoteControlClient
+ .setPlaybackState(RemoteControlClient.PLAYSTATE_BUFFERING);
+ }
+ if (info.playable != null) {
+ MetadataEditor editor = remoteControlClient
+ .editMetadata(false);
+ editor.putString(MediaMetadataRetriever.METADATA_KEY_TITLE,
+ info.playable.getEpisodeTitle());
+
+ editor.putString(MediaMetadataRetriever.METADATA_KEY_ALBUM,
+ info.playable.getFeedTitle());
+
+ editor.apply();
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "RemoteControlClient state was refreshed");
+ }
+ }
+ }
+
+ private void bluetoothNotifyChange(PlaybackServiceMediaPlayer.PSMPInfo info, String whatChanged) {
+ boolean isPlaying = false;
+
+ if (info.playerStatus == PlayerStatus.PLAYING) {
+ isPlaying = true;
+ }
+
+ if (info.playable != null) {
+ Intent i = new Intent(whatChanged);
+ i.putExtra("id", 1);
+ i.putExtra("artist", "");
+ i.putExtra("album", info.playable.getFeedTitle());
+ i.putExtra("track", info.playable.getEpisodeTitle());
+ i.putExtra("playing", isPlaying);
+ final List<FeedItem> queue = taskManager.getQueueIfLoaded();
+ if (queue != null) {
+ i.putExtra("ListSize", queue.size());
+ }
+ i.putExtra("duration", info.playable.getDuration());
+ i.putExtra("position", info.playable.getPosition());
+ sendBroadcast(i);
+ }
+ }
+
+ /**
+ * Pauses playback when the headset is disconnected and the preference is
+ * set
+ */
+ private BroadcastReceiver headsetDisconnected = new BroadcastReceiver() {
+ private static final String TAG = "headsetDisconnected";
+ private static final int UNPLUGGED = 0;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), Intent.ACTION_HEADSET_PLUG)) {
+ int state = intent.getIntExtra("state", -1);
+ if (state != -1) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Headset plug event. State is " + state);
+ if (state == UNPLUGGED) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Headset was unplugged during playback.");
+ pauseIfPauseOnDisconnect();
+ }
+ } else {
+ Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent");
+ }
+ }
+ }
+ };
+
+ private BroadcastReceiver audioBecomingNoisy = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // sound is about to change, eg. bluetooth -> speaker
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Pausing playback because audio is becoming noisy");
+ pauseIfPauseOnDisconnect();
+ }
+ // android.media.AUDIO_BECOMING_NOISY
+ };
+
+ /**
+ * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true.
+ */
+ private void pauseIfPauseOnDisconnect() {
+ if (UserPreferences.isPauseOnHeadsetDisconnect()) {
+ mediaPlayer.pause(true, true);
+ }
+ }
+
+ private BroadcastReceiver shutdownReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ACTION_SHUTDOWN_PLAYBACK_SERVICE)) {
+ stopSelf();
+ }
+ }
+
+ };
+
+ private BroadcastReceiver skipCurrentEpisodeReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (StringUtils.equals(intent.getAction(), ACTION_SKIP_CURRENT_EPISODE)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received SKIP_CURRENT_EPISODE intent");
+ mediaPlayer.endPlayback();
+ }
+ }
+ };
+
+ public static MediaType getCurrentMediaType() {
+ return currentMediaType;
+ }
+
+ public void resume() {
+ mediaPlayer.resume();
+ }
+
+ public void prepare() {
+ mediaPlayer.prepare();
+ }
+
+ public void pause(boolean abandonAudioFocus, boolean reinit) {
+ mediaPlayer.pause(abandonAudioFocus, reinit);
+ }
+
+ public void reinit() {
+ mediaPlayer.reinit();
+ }
+
+ public PlaybackServiceMediaPlayer.PSMPInfo getPSMPInfo() {
+ return mediaPlayer.getPSMPInfo();
+ }
+
+ public PlayerStatus getStatus() {
+ return mediaPlayer.getPSMPInfo().playerStatus;
+ }
+
+ public Playable getPlayable() {
+ return mediaPlayer.getPSMPInfo().playable;
+ }
+
+ public void setSpeed(float speed) {
+ mediaPlayer.setSpeed(speed);
+ }
+
+ public boolean canSetSpeed() {
+ return mediaPlayer.canSetSpeed();
+ }
+
+ public float getCurrentPlaybackSpeed() {
+ return mediaPlayer.getPlaybackSpeed();
+ }
+
+ public boolean isStartWhenPrepared() {
+ return mediaPlayer.isStartWhenPrepared();
+ }
+
+ public void setStartWhenPrepared(boolean s) {
+ mediaPlayer.setStartWhenPrepared(s);
+ }
+
+
+ public void seekTo(final int t) {
+ mediaPlayer.seekTo(t);
+ }
+
+
+ public void seekDelta(final int d) {
+ mediaPlayer.seekDelta(d);
+ }
+
+ /**
+ * @see de.danoeh.antennapod.service.playback.PlaybackServiceMediaPlayer#seekToChapter(de.danoeh.antennapod.feed.Chapter)
+ */
+ public void seekToChapter(Chapter c) {
+ mediaPlayer.seekToChapter(c);
+ }
+
+ /**
+ * call getDuration() on mediaplayer or return INVALID_TIME if player is in
+ * an invalid state.
+ */
+ public int getDuration() {
+ return mediaPlayer.getDuration();
+ }
+
+ /**
+ * call getCurrentPosition() on mediaplayer or return INVALID_TIME if player
+ * is in an invalid state.
+ */
+ public int getCurrentPosition() {
+ return mediaPlayer.getPosition();
+ }
+
+ public boolean isStreaming() {
+ return mediaPlayer.isStreaming();
+ }
+
+ public Pair<Integer, Integer> getVideoSize() {
+ return mediaPlayer.getVideoSize();
+ }
+
+ private boolean isAutoFlattrable(Playable p) {
+ if (p != null && p instanceof FeedMedia) {
+ FeedMedia media = (FeedMedia) p;
+ FeedItem item = ((FeedMedia) p).getItem();
+ return item != null && FlattrUtils.hasToken() && UserPreferences.isAutoFlattr() && item.getPaymentLink() != null && item.getFlattrStatus().getUnflattred();
+ } else {
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java
new file mode 100644
index 000000000..49f20012d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceMediaPlayer.java
@@ -0,0 +1,979 @@
+package de.danoeh.antennapod.service.playback;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.AudioManager;
+import android.media.RemoteControlClient;
+import android.net.wifi.WifiManager;
+import android.os.PowerManager;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+import android.util.Pair;
+import android.view.SurfaceHolder;
+
+import org.apache.commons.lang3.Validate;
+
+import java.io.IOException;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.RejectedExecutionHandler;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.receiver.MediaButtonReceiver;
+import de.danoeh.antennapod.util.playback.AudioPlayer;
+import de.danoeh.antennapod.util.playback.IPlayer;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.playback.VideoPlayer;
+
+/**
+ * Manages the MediaPlayer object of the PlaybackService.
+ */
+public class PlaybackServiceMediaPlayer {
+ public static final String TAG = "PlaybackServiceMediaPlayer";
+
+ /**
+ * Return value of some PSMP methods if the method call failed.
+ */
+ public static final int INVALID_TIME = -1;
+
+ private final AudioManager audioManager;
+
+ private volatile PlayerStatus playerStatus;
+ private volatile PlayerStatus statusBeforeSeeking;
+ private volatile IPlayer mediaPlayer;
+ private volatile Playable media;
+
+ private volatile boolean stream;
+ private volatile MediaType mediaType;
+ private volatile AtomicBoolean startWhenPrepared;
+ private volatile boolean pausedBecauseOfTransientAudiofocusLoss;
+ private volatile Pair<Integer, Integer> videoSize;
+
+ /**
+ * Some asynchronous calls might change the state of the MediaPlayer object. Therefore calls in other threads
+ * have to wait until these operations have finished.
+ */
+ private final ReentrantLock playerLock;
+
+ private final PSMPCallback callback;
+ private final Context context;
+
+ private final ThreadPoolExecutor executor;
+
+ /**
+ * A wifi-lock that is acquired if the media file is being streamed.
+ */
+ private WifiManager.WifiLock wifiLock;
+
+ public PlaybackServiceMediaPlayer(Context context, PSMPCallback callback) {
+ Validate.notNull(context);
+ Validate.notNull(callback);
+
+ this.context = context;
+ this.callback = callback;
+ this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ this.playerLock = new ReentrantLock();
+ this.startWhenPrepared = new AtomicBoolean(false);
+ executor = new ThreadPoolExecutor(1, 1, 5, TimeUnit.MINUTES, new LinkedBlockingDeque<Runnable>(),
+ new RejectedExecutionHandler() {
+ @Override
+ public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Rejected execution of runnable");
+ }
+ }
+ );
+
+ mediaPlayer = null;
+ statusBeforeSeeking = null;
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ mediaType = MediaType.UNKNOWN;
+ playerStatus = PlayerStatus.STOPPED;
+ videoSize = null;
+ }
+
+ /**
+ * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
+ * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will
+ * not do anything.
+ * Whether playback starts immediately depends on the given parameters. See below for more details.
+ * <p/>
+ * States:
+ * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters.
+ * <p/>
+ * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If
+ * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state.
+ * <p/>
+ * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object
+ * will enter the ERROR state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ *
+ * @param playable The Playable object that is supposed to be played. This parameter must not be null.
+ * @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via
+ * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by
+ * the Android MediaPlayer via getStreamUrl.
+ * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the
+ * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared
+ * for playback immediately (see 'prepareImmediately' parameter for more details)
+ * @param prepareImmediately Set to true if the method should also prepare the episode for playback.
+ */
+ public void playMediaObject(final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) {
+ Validate.notNull(playable);
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Play media object.");
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ try {
+ playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately);
+ } catch (RuntimeException e) {
+ e.printStackTrace();
+ throw e;
+ } finally {
+ playerLock.unlock();
+ }
+ }
+ });
+ }
+
+ /**
+ * Internal implementation of playMediaObject. This method has an additional parameter that allows the caller to force a media player reset even if
+ * the given playable parameter is the same object as the currently playing media.
+ * <p/>
+ * This method requires the playerLock and is executed on the caller's thread.
+ *
+ * @see #playMediaObject(de.danoeh.antennapod.util.playback.Playable, boolean, boolean, boolean)
+ */
+ private void playMediaObject(final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) {
+ Validate.notNull(playable);
+ if (!playerLock.isHeldByCurrentThread())
+ throw new IllegalStateException("method requires playerLock");
+
+
+ if (media != null) {
+ if (!forceReset && media.getIdentifier().equals(playable.getIdentifier())) {
+ // episode is already playing -> ignore method call
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing.");
+ return;
+ } else {
+ // stop playback of this episode
+ if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PLAYING || playerStatus == PlayerStatus.PREPARED) {
+ mediaPlayer.stop();
+ }
+ setPlayerStatus(PlayerStatus.INDETERMINATE, null);
+ }
+ }
+
+ this.media = playable;
+ this.stream = stream;
+ this.mediaType = media.getMediaType();
+ this.videoSize = null;
+ createMediaPlayer();
+ PlaybackServiceMediaPlayer.this.startWhenPrepared.set(startWhenPrepared);
+ setPlayerStatus(PlayerStatus.INITIALIZING, media);
+ try {
+ media.loadMetadata();
+ if (stream) {
+ mediaPlayer.setDataSource(media.getStreamUrl());
+ } else {
+ mediaPlayer.setDataSource(media.getLocalMediaUrl());
+ }
+ setPlayerStatus(PlayerStatus.INITIALIZED, media);
+
+ if (mediaType == MediaType.VIDEO) {
+ VideoPlayer vp = (VideoPlayer) mediaPlayer;
+ // vp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT);
+ }
+
+ if (prepareImmediately) {
+ setPlayerStatus(PlayerStatus.PREPARING, media);
+ mediaPlayer.prepare();
+ onPrepared(startWhenPrepared);
+ }
+
+ } catch (Playable.PlayableException e) {
+ e.printStackTrace();
+ setPlayerStatus(PlayerStatus.ERROR, null);
+ } catch (IOException e) {
+ e.printStackTrace();
+ setPlayerStatus(PlayerStatus.ERROR, null);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ setPlayerStatus(PlayerStatus.ERROR, null);
+ }
+ }
+
+
+ /**
+ * Resumes playback if the PSMP object is in PREPARED or PAUSED state. If the PSMP object is in an invalid state.
+ * nothing will happen.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public void resume() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ resumeSync();
+ playerLock.unlock();
+ }
+ });
+ }
+
+ private void resumeSync() {
+ if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) {
+ int focusGained = audioManager.requestAudioFocus(
+ audioFocusChangeListener, AudioManager.STREAM_MUSIC,
+ AudioManager.AUDIOFOCUS_GAIN);
+ if (focusGained == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
+ acquireWifiLockIfNecessary();
+ setSpeed(Float.parseFloat(UserPreferences.getPlaybackSpeed()));
+ mediaPlayer.start();
+ if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) {
+ mediaPlayer.seekTo(media.getPosition());
+ }
+
+ setPlayerStatus(PlayerStatus.PLAYING, media);
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ if (android.os.Build.VERSION.SDK_INT >= 14) {
+ RemoteControlClient remoteControlClient = callback.getRemoteControlClient();
+ if (remoteControlClient != null) {
+ audioManager
+ .registerRemoteControlClient(remoteControlClient);
+ }
+ }
+ audioManager
+ .registerMediaButtonEventReceiver(new ComponentName(context.getPackageName(),
+ MediaButtonReceiver.class.getName()));
+ media.onPlaybackStart();
+
+ } else {
+ if (BuildConfig.DEBUG) Log.e(TAG, "Failed to request audio focus");
+ }
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Call to resume() was ignored because current state of PSMP object is " + playerStatus);
+ }
+ }
+
+
+ /**
+ * Saves the current position and pauses playback. Note that, if audiofocus
+ * is abandoned, the lockscreen controls will also disapear.
+ * <p/>
+ * This method is executed on an internal executor service.
+ *
+ * @param abandonFocus is true if the service should release audio focus
+ * @param reinit is true if service should reinit after pausing if the media
+ * file is being streamed
+ */
+ public void pause(final boolean abandonFocus, final boolean reinit) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ releaseWifiLockIfNecessary();
+ if (playerStatus == PlayerStatus.PLAYING) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Pausing playback.");
+ mediaPlayer.pause();
+ setPlayerStatus(PlayerStatus.PAUSED, media);
+
+ if (abandonFocus) {
+ audioManager.abandonAudioFocus(audioFocusChangeListener);
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ }
+ if (stream && reinit) {
+ reinit();
+ }
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Ignoring call to pause: Player is in " + playerStatus + " state");
+ }
+
+ playerLock.unlock();
+ }
+ });
+ }
+
+ /**
+ * Prepared media player for playback if the service is in the INITALIZED
+ * state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public void prepare() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+
+ if (playerStatus == PlayerStatus.INITIALIZED) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Preparing media player");
+ setPlayerStatus(PlayerStatus.PREPARING, media);
+ try {
+ mediaPlayer.prepare();
+ onPrepared(startWhenPrepared.get());
+ } catch (IOException e) {
+ e.printStackTrace();
+ setPlayerStatus(PlayerStatus.ERROR, null);
+ }
+ }
+ playerLock.unlock();
+
+ }
+ });
+ }
+
+ /**
+ * Called after media player has been prepared. This method is executed on the caller's thread.
+ */
+ void onPrepared(final boolean startWhenPrepared) {
+ playerLock.lock();
+
+ if (playerStatus != PlayerStatus.PREPARING) {
+ playerLock.unlock();
+ throw new IllegalStateException("Player is not in PREPARING state");
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resource prepared");
+
+ if (mediaType == MediaType.VIDEO) {
+ VideoPlayer vp = (VideoPlayer) mediaPlayer;
+ videoSize = new Pair<Integer, Integer>(vp.getVideoWidth(), vp.getVideoHeight());
+ }
+
+ if (media.getPosition() > 0) {
+ mediaPlayer.seekTo(media.getPosition());
+ }
+
+ if (media.getDuration() == 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting duration of media");
+ media.setDuration(mediaPlayer.getDuration());
+ }
+ setPlayerStatus(PlayerStatus.PREPARED, media);
+
+ if (startWhenPrepared) {
+ resumeSync();
+ }
+
+ playerLock.unlock();
+ }
+
+ /**
+ * Resets the media player and moves it into INITIALIZED state.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public void reinit() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ releaseWifiLockIfNecessary();
+ if (media != null) {
+ playMediaObject(media, true, stream, startWhenPrepared.get(), false);
+ } else if (mediaPlayer != null) {
+ mediaPlayer.reset();
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Call to reinit was ignored: media and mediaPlayer were null");
+ }
+ playerLock.unlock();
+ }
+ });
+ }
+
+
+ /**
+ * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing.
+ *
+ * @param t The position to seek to in milliseconds. t < 0 will be interpreted as t = 0
+ * <p/>
+ * This method is executed on the caller's thread.
+ */
+ private void seekToSync(int t) {
+ if (t < 0) {
+ t = 0;
+ }
+ playerLock.lock();
+
+ if (playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) {
+ if (stream) {
+ // statusBeforeSeeking = playerStatus;
+ // setPlayerStatus(PlayerStatus.SEEKING, media);
+ }
+ mediaPlayer.seekTo(t);
+
+ } else if (playerStatus == PlayerStatus.INITIALIZED) {
+ media.setPosition(t);
+ startWhenPrepared.set(true);
+ prepare();
+ }
+ playerLock.unlock();
+ }
+
+ /**
+ * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing.
+ * Invalid time values (< 0) will be ignored.
+ * <p/>
+ * This method is executed on an internal executor service.
+ */
+ public void seekTo(final int t) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ seekToSync(t);
+ }
+ });
+ }
+
+ /**
+ * Seek a specific position from the current position
+ *
+ * @param d offset from current position (positive or negative)
+ */
+ public void seekDelta(final int d) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ int currentPosition = getPosition();
+ if (currentPosition != INVALID_TIME) {
+ seekToSync(currentPosition + d);
+ } else {
+ Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta");
+ }
+
+ playerLock.unlock();
+ }
+ });
+ }
+
+ /**
+ * Seek to the start of the specified chapter.
+ */
+ public void seekToChapter(Chapter c) {
+ Validate.notNull(c);
+
+ seekTo((int) c.getStart());
+ }
+
+ /**
+ * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved.
+ */
+ public int getDuration() {
+ if (!playerLock.tryLock()) {
+ return INVALID_TIME;
+ }
+
+ int retVal = INVALID_TIME;
+ if (playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) {
+ retVal = mediaPlayer.getDuration();
+ } else if (media != null && media.getDuration() > 0) {
+ retVal = media.getDuration();
+ }
+
+ playerLock.unlock();
+ return retVal;
+ }
+
+ /**
+ * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved.
+ */
+ public int getPosition() {
+ if (!playerLock.tryLock()) {
+ return INVALID_TIME;
+ }
+
+ int retVal = INVALID_TIME;
+ if (playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) {
+ retVal = mediaPlayer.getCurrentPosition();
+ } else if (media != null && media.getPosition() > 0) {
+ retVal = media.getPosition();
+ }
+
+ playerLock.unlock();
+ return retVal;
+ }
+
+ public boolean isStartWhenPrepared() {
+ return startWhenPrepared.get();
+ }
+
+ public void setStartWhenPrepared(boolean startWhenPrepared) {
+ this.startWhenPrepared.set(startWhenPrepared);
+ }
+
+ /**
+ * Returns true if the playback speed can be adjusted.
+ */
+ public boolean canSetSpeed() {
+ boolean retVal = false;
+ if (mediaPlayer != null && media != null && media.getMediaType() == MediaType.AUDIO) {
+ retVal = (mediaPlayer).canSetSpeed();
+ }
+ return retVal;
+ }
+
+ /**
+ * Sets the playback speed.
+ * This method is executed on the caller's thread.
+ */
+ private void setSpeedSync(float speed) {
+ playerLock.lock();
+ if (media != null && media.getMediaType() == MediaType.AUDIO) {
+ if (mediaPlayer.canSetSpeed()) {
+ mediaPlayer.setPlaybackSpeed((float) speed);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Playback speed was set to " + speed);
+ callback.playbackSpeedChanged(speed);
+ }
+ }
+ playerLock.unlock();
+ }
+
+ /**
+ * Sets the playback speed.
+ * This method is executed on an internal executor service.
+ */
+ public void setSpeed(final float speed) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ setSpeedSync(speed);
+ }
+ });
+ }
+
+ /**
+ * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned.
+ */
+ public float getPlaybackSpeed() {
+ if (!playerLock.tryLock()) {
+ return 1;
+ }
+
+ float retVal = 1;
+ if ((playerStatus == PlayerStatus.PLAYING
+ || playerStatus == PlayerStatus.PAUSED
+ || playerStatus == PlayerStatus.PREPARED) && mediaPlayer.canSetSpeed()) {
+ retVal = mediaPlayer.getCurrentSpeedMultiplier();
+ }
+ playerLock.unlock();
+ return retVal;
+ }
+
+ public MediaType getCurrentMediaType() {
+ return mediaType;
+ }
+
+ public boolean isStreaming() {
+ return stream;
+ }
+
+
+ /**
+ * Releases internally used resources. This method should only be called when the object is not used anymore.
+ */
+ public void shutdown() {
+ executor.shutdown();
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ }
+ releaseWifiLockIfNecessary();
+ }
+
+ public void setVideoSurface(final SurfaceHolder surface) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ if (mediaPlayer != null) {
+ mediaPlayer.setDisplay(surface);
+ }
+ playerLock.unlock();
+ }
+ });
+ }
+
+ public void resetVideoSurface() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Resetting video surface");
+ mediaPlayer.setDisplay(null);
+ reinit();
+ playerLock.unlock();
+ }
+ });
+ }
+
+ /**
+ * Return width and height of the currently playing video as a pair.
+ *
+ * @return Width and height as a Pair or null if the video size could not be determined. The method might still
+ * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return
+ * invalid values.
+ */
+ public Pair<Integer, Integer> getVideoSize() {
+ if (!playerLock.tryLock()) {
+ // use cached value if lock can't be aquired
+ return videoSize;
+ }
+ Pair<Integer, Integer> res;
+ if (mediaPlayer == null || playerStatus == PlayerStatus.ERROR || mediaType != MediaType.VIDEO) {
+ res = null;
+ } else {
+ VideoPlayer vp = (VideoPlayer) mediaPlayer;
+ videoSize = new Pair<Integer, Integer>(vp.getVideoWidth(), vp.getVideoHeight());
+ res = videoSize;
+ }
+ playerLock.unlock();
+ return res;
+ }
+
+ /**
+ * Returns a PSMInfo object that contains information about the current state of the PSMP object.
+ *
+ * @return The PSMPInfo object.
+ */
+ public synchronized PSMPInfo getPSMPInfo() {
+ return new PSMPInfo(playerStatus, media);
+ }
+
+ /**
+ * Sets the player status of the PSMP object. PlayerStatus and media attributes have to be set at the same time
+ * so that getPSMPInfo can't return an invalid state (e.g. status is PLAYING, but media is null).
+ * <p/>
+ * This method will notify the callback about the change of the player status (even if the new status is the same
+ * as the old one).
+ *
+ * @param newStatus The new PlayerStatus. This must not be null.
+ * @param newMedia The new playable object of the PSMP object. This can be null.
+ */
+ private synchronized void setPlayerStatus(PlayerStatus newStatus, Playable newMedia) {
+ Validate.notNull(newStatus);
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Setting player status to " + newStatus);
+
+ this.playerStatus = newStatus;
+ this.media = newMedia;
+ callback.statusChanged(new PSMPInfo(playerStatus, media));
+ }
+
+ private IPlayer createMediaPlayer() {
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ }
+ if (media == null || media.getMediaType() == MediaType.VIDEO) {
+ mediaPlayer = new VideoPlayer();
+ } else {
+ mediaPlayer = new AudioPlayer(context);
+ }
+ mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
+ mediaPlayer.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK);
+ return setMediaPlayerListeners(mediaPlayer);
+ }
+
+ private final AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() {
+
+ @Override
+ public void onAudioFocusChange(final int focusChange) {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+
+ // If there is an incoming call, playback should be paused permanently
+ TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ final int callState = (tm != null) ? tm.getCallState() : 0;
+ if (BuildConfig.DEBUG) Log.d(TAG, "Call state: " + callState);
+ Log.i(TAG, "Call state:" + callState);
+
+ if (focusChange == AudioManager.AUDIOFOCUS_LOSS || callState != TelephonyManager.CALL_STATE_IDLE) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Lost audio focus");
+ pause(true, false);
+ callback.shouldStop();
+ } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Gained audio focus");
+ if (pausedBecauseOfTransientAudiofocusLoss) { // we paused => play now
+ resume();
+ } else { // we ducked => raise audio level back
+ audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
+ AudioManager.ADJUST_RAISE, 0);
+ }
+ } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
+ if (playerStatus == PlayerStatus.PLAYING) {
+ if (!UserPreferences.shouldPauseForFocusLoss()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Lost audio focus temporarily. Ducking...");
+ audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
+ AudioManager.ADJUST_LOWER, 0);
+ pausedBecauseOfTransientAudiofocusLoss = false;
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Lost audio focus temporarily. Could duck, but won't, pausing...");
+ pause(false, false);
+ pausedBecauseOfTransientAudiofocusLoss = true;
+ }
+ }
+ } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
+ if (playerStatus == PlayerStatus.PLAYING) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Lost audio focus temporarily. Pausing...");
+ pause(false, false);
+ pausedBecauseOfTransientAudiofocusLoss = true;
+ }
+ playerLock.unlock();
+ }
+ }
+ });
+ }
+ };
+
+
+ public void endPlayback() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ releaseWifiLockIfNecessary();
+
+ if (playerStatus != PlayerStatus.INDETERMINATE) {
+ setPlayerStatus(PlayerStatus.INDETERMINATE, media);
+ }
+ if (mediaPlayer != null) {
+ mediaPlayer.reset();
+
+ }
+ audioManager.abandonAudioFocus(audioFocusChangeListener);
+ callback.endPlayback(true);
+
+ playerLock.unlock();
+ }
+ });
+ }
+
+ /**
+ * Moves the PlaybackServiceMediaPlayer into STOPPED state. This call is only valid if the player is currently in
+ * INDETERMINATE state, for example after a call to endPlayback.
+ * This method will only take care of changing the PlayerStatus of this object! Other tasks like
+ * abandoning audio focus have to be done with other methods.
+ */
+ public void stop() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ releaseWifiLockIfNecessary();
+
+ if (playerStatus == PlayerStatus.INDETERMINATE) {
+ setPlayerStatus(PlayerStatus.STOPPED, null);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus);
+ }
+ playerLock.unlock();
+
+ }
+ });
+ }
+
+ private synchronized void acquireWifiLockIfNecessary() {
+ if (stream) {
+ if (wifiLock == null) {
+ wifiLock = ((WifiManager) context.getSystemService(Context.WIFI_SERVICE))
+ .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG);
+ wifiLock.setReferenceCounted(false);
+ }
+ wifiLock.acquire();
+ }
+ }
+
+ private synchronized void releaseWifiLockIfNecessary() {
+ if (wifiLock != null && wifiLock.isHeld()) {
+ wifiLock.release();
+ }
+ }
+
+ /**
+ * Holds information about a PSMP object.
+ */
+ public class PSMPInfo {
+ public PlayerStatus playerStatus;
+ public Playable playable;
+
+ public PSMPInfo(PlayerStatus playerStatus, Playable playable) {
+ this.playerStatus = playerStatus;
+ this.playable = playable;
+ }
+ }
+
+ public static interface PSMPCallback {
+ public void statusChanged(PSMPInfo newInfo);
+
+ public void shouldStop();
+
+ public void playbackSpeedChanged(float s);
+
+ public void onBufferingUpdate(int percent);
+
+ public boolean onMediaPlayerInfo(int code);
+
+ public boolean onMediaPlayerError(Object inObj, int what, int extra);
+
+ public boolean endPlayback(boolean playNextEpisode);
+
+ public RemoteControlClient getRemoteControlClient();
+ }
+
+ private IPlayer setMediaPlayerListeners(IPlayer mp) {
+ if (mp != null && media != null) {
+ if (media.getMediaType() == MediaType.AUDIO) {
+ ((AudioPlayer) mp)
+ .setOnCompletionListener(audioCompletionListener);
+ ((AudioPlayer) mp)
+ .setOnSeekCompleteListener(audioSeekCompleteListener);
+ ((AudioPlayer) mp).setOnErrorListener(audioErrorListener);
+ ((AudioPlayer) mp)
+ .setOnBufferingUpdateListener(audioBufferingUpdateListener);
+ ((AudioPlayer) mp).setOnInfoListener(audioInfoListener);
+ } else {
+ ((VideoPlayer) mp)
+ .setOnCompletionListener(videoCompletionListener);
+ ((VideoPlayer) mp)
+ .setOnSeekCompleteListener(videoSeekCompleteListener);
+ ((VideoPlayer) mp).setOnErrorListener(videoErrorListener);
+ ((VideoPlayer) mp)
+ .setOnBufferingUpdateListener(videoBufferingUpdateListener);
+ ((VideoPlayer) mp).setOnInfoListener(videoInfoListener);
+ }
+ }
+ return mp;
+ }
+
+ private final com.aocate.media.MediaPlayer.OnCompletionListener audioCompletionListener = new com.aocate.media.MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(com.aocate.media.MediaPlayer mp) {
+ genericOnCompletion();
+ }
+ };
+
+ private final android.media.MediaPlayer.OnCompletionListener videoCompletionListener = new android.media.MediaPlayer.OnCompletionListener() {
+ @Override
+ public void onCompletion(android.media.MediaPlayer mp) {
+ genericOnCompletion();
+ }
+ };
+
+ private void genericOnCompletion() {
+ endPlayback();
+ }
+
+ private final com.aocate.media.MediaPlayer.OnBufferingUpdateListener audioBufferingUpdateListener = new com.aocate.media.MediaPlayer.OnBufferingUpdateListener() {
+ @Override
+ public void onBufferingUpdate(com.aocate.media.MediaPlayer mp,
+ int percent) {
+ genericOnBufferingUpdate(percent);
+ }
+ };
+
+ private final android.media.MediaPlayer.OnBufferingUpdateListener videoBufferingUpdateListener = new android.media.MediaPlayer.OnBufferingUpdateListener() {
+ @Override
+ public void onBufferingUpdate(android.media.MediaPlayer mp, int percent) {
+ genericOnBufferingUpdate(percent);
+ }
+ };
+
+ private void genericOnBufferingUpdate(int percent) {
+ callback.onBufferingUpdate(percent);
+ }
+
+ private final com.aocate.media.MediaPlayer.OnInfoListener audioInfoListener = new com.aocate.media.MediaPlayer.OnInfoListener() {
+ @Override
+ public boolean onInfo(com.aocate.media.MediaPlayer mp, int what,
+ int extra) {
+ return genericInfoListener(what);
+ }
+ };
+
+ private final android.media.MediaPlayer.OnInfoListener videoInfoListener = new android.media.MediaPlayer.OnInfoListener() {
+ @Override
+ public boolean onInfo(android.media.MediaPlayer mp, int what, int extra) {
+ return genericInfoListener(what);
+ }
+ };
+
+ private boolean genericInfoListener(int what) {
+ return callback.onMediaPlayerInfo(what);
+ }
+
+ private final com.aocate.media.MediaPlayer.OnErrorListener audioErrorListener = new com.aocate.media.MediaPlayer.OnErrorListener() {
+ @Override
+ public boolean onError(com.aocate.media.MediaPlayer mp, int what,
+ int extra) {
+ return genericOnError(mp, what, extra);
+ }
+ };
+
+ private final android.media.MediaPlayer.OnErrorListener videoErrorListener = new android.media.MediaPlayer.OnErrorListener() {
+ @Override
+ public boolean onError(android.media.MediaPlayer mp, int what, int extra) {
+ return genericOnError(mp, what, extra);
+ }
+ };
+
+ private boolean genericOnError(Object inObj, int what, int extra) {
+ return callback.onMediaPlayerError(inObj, what, extra);
+ }
+
+ private final com.aocate.media.MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener = new com.aocate.media.MediaPlayer.OnSeekCompleteListener() {
+ @Override
+ public void onSeekComplete(com.aocate.media.MediaPlayer mp) {
+ genericSeekCompleteListener();
+ }
+ };
+
+ private final android.media.MediaPlayer.OnSeekCompleteListener videoSeekCompleteListener = new android.media.MediaPlayer.OnSeekCompleteListener() {
+ @Override
+ public void onSeekComplete(android.media.MediaPlayer mp) {
+ genericSeekCompleteListener();
+ }
+ };
+
+ private final void genericSeekCompleteListener() {
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ playerLock.lock();
+ if (playerStatus == PlayerStatus.SEEKING) {
+ setPlayerStatus(statusBeforeSeeking, media);
+ }
+ playerLock.unlock();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java
new file mode 100644
index 000000000..680ec2e2f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlaybackServiceTaskManager.java
@@ -0,0 +1,384 @@
+package de.danoeh.antennapod.service.playback;
+
+import android.content.Context;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.EventDistributor;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.playback.Playable;
+
+import java.util.List;
+import java.util.concurrent.*;
+
+/**
+ * Manages the background tasks of PlaybackSerivce, i.e.
+ * the sleep timer, the position saver, the widget updater and
+ * the queue loader.
+ * <p/>
+ * The PlaybackServiceTaskManager(PSTM) uses a callback object (PSTMCallback)
+ * to notify the PlaybackService about updates from the running tasks.
+ */
+public class PlaybackServiceTaskManager {
+ private static final String TAG = "PlaybackServiceTaskManager";
+
+ /**
+ * Update interval of position saver in milliseconds.
+ */
+ public static final int POSITION_SAVER_WAITING_INTERVAL = 5000;
+ /**
+ * Notification interval of widget updater in milliseconds.
+ */
+ public static final int WIDGET_UPDATER_NOTIFICATION_INTERVAL = 1500;
+
+ private static final int SCHED_EX_POOL_SIZE = 2;
+ private final ScheduledThreadPoolExecutor schedExecutor;
+
+ private ScheduledFuture positionSaverFuture;
+ private ScheduledFuture widgetUpdaterFuture;
+ private ScheduledFuture sleepTimerFuture;
+ private volatile Future<List<FeedItem>> queueFuture;
+ private volatile Future chapterLoaderFuture;
+
+ private SleepTimer sleepTimer;
+
+ private final Context context;
+ private final PSTMCallback callback;
+
+ /**
+ * Sets up a new PSTM. This method will also start the queue loader task.
+ *
+ * @param context
+ * @param callback A PSTMCallback object for notifying the user about updates. Must not be null.
+ */
+ public PlaybackServiceTaskManager(Context context, PSTMCallback callback) {
+ Validate.notNull(context);
+ Validate.notNull(callback);
+
+ this.context = context;
+ this.callback = callback;
+ schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOL_SIZE, new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ });
+ loadQueue();
+ EventDistributor.getInstance().register(eventDistributorListener);
+ }
+
+ private final EventDistributor.EventListener eventDistributorListener = new EventDistributor.EventListener() {
+ @Override
+ public void update(EventDistributor eventDistributor, Integer arg) {
+ if ((EventDistributor.QUEUE_UPDATE & arg) != 0) {
+ cancelQueueLoader();
+ loadQueue();
+ }
+ }
+ };
+
+ private synchronized boolean isQueueLoaderActive() {
+ return queueFuture != null && !queueFuture.isDone();
+ }
+
+ private synchronized void cancelQueueLoader() {
+ if (isQueueLoaderActive()) {
+ queueFuture.cancel(true);
+ }
+ }
+
+ private synchronized void loadQueue() {
+ if (!isQueueLoaderActive()) {
+ queueFuture = schedExecutor.submit(new Callable<List<FeedItem>>() {
+ @Override
+ public List<FeedItem> call() throws Exception {
+ return DBReader.getQueue(context);
+ }
+ });
+ }
+ }
+
+ /**
+ * Returns the queue if it is already loaded or null if it hasn't been loaded yet.
+ * In order to wait until the queue has been loaded, use getQueue()
+ */
+ public synchronized List<FeedItem> getQueueIfLoaded() {
+ if (queueFuture.isDone()) {
+ try {
+ return queueFuture.get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the queue or waits until the PSTM has loaded the queue from the database.
+ */
+ public synchronized List<FeedItem> getQueue() throws InterruptedException {
+ try {
+ return queueFuture.get();
+ } catch (ExecutionException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ /**
+ * Starts the position saver task. If the position saver is already active, nothing will happen.
+ */
+ public synchronized void startPositionSaver() {
+ if (!isPositionSaverActive()) {
+ Runnable positionSaver = new Runnable() {
+ @Override
+ public void run() {
+ callback.positionSaverTick();
+ }
+ };
+ positionSaverFuture = schedExecutor.scheduleWithFixedDelay(positionSaver, POSITION_SAVER_WAITING_INTERVAL,
+ POSITION_SAVER_WAITING_INTERVAL, TimeUnit.MILLISECONDS);
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Started PositionSaver");
+ } else {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Call to startPositionSaver was ignored.");
+ }
+ }
+
+ /**
+ * Returns true if the position saver is currently running.
+ */
+ public synchronized boolean isPositionSaverActive() {
+ return positionSaverFuture != null && !positionSaverFuture.isCancelled() && !positionSaverFuture.isDone();
+ }
+
+ /**
+ * Cancels the position saver. If the position saver is not running, nothing will happen.
+ */
+ public synchronized void cancelPositionSaver() {
+ if (isPositionSaverActive()) {
+ positionSaverFuture.cancel(false);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Cancelled PositionSaver");
+ }
+ }
+
+ /**
+ * Starts the widget updater task. If the widget updater is already active, nothing will happen.
+ */
+ public synchronized void startWidgetUpdater() {
+ if (!isWidgetUpdaterActive()) {
+ Runnable widgetUpdater = new Runnable() {
+ @Override
+ public void run() {
+ callback.onWidgetUpdaterTick();
+ }
+ };
+ widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL,
+ WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Started WidgetUpdater");
+ } else {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Call to startWidgetUpdater was ignored.");
+ }
+ }
+
+ /**
+ * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
+ * cancelled first.
+ * After waitingTime has elapsed, onSleepTimerExpired() will be called.
+ *
+ * @throws java.lang.IllegalArgumentException if waitingTime <= 0
+ */
+ public synchronized void setSleepTimer(long waitingTime) {
+ Validate.isTrue(waitingTime > 0, "Waiting time <= 0");
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting sleep timer to " + Long.toString(waitingTime)
+ + " milliseconds");
+ if (isSleepTimerActive()) {
+ sleepTimerFuture.cancel(true);
+ }
+ sleepTimer = new SleepTimer(waitingTime);
+ sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Returns true if the sleep timer is currently active.
+ */
+ public synchronized boolean isSleepTimerActive() {
+ return sleepTimer != null && sleepTimerFuture != null && !sleepTimerFuture.isCancelled() && !sleepTimerFuture.isDone() && sleepTimer.isWaiting;
+ }
+
+ /**
+ * Disables the sleep timer. If the sleep timer is not active, nothing will happen.
+ */
+ public synchronized void disableSleepTimer() {
+ if (isSleepTimerActive()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Disabling sleep timer");
+ sleepTimerFuture.cancel(true);
+ }
+ }
+
+ /**
+ * Returns the current sleep timer time or 0 if the sleep timer is not active.
+ */
+ public synchronized long getSleepTimerTimeLeft() {
+ if (isSleepTimerActive()) {
+ return sleepTimer.getWaitingTime();
+ } else {
+ return 0;
+ }
+ }
+
+
+ /**
+ * Returns true if the widget updater is currently running.
+ */
+ public synchronized boolean isWidgetUpdaterActive() {
+ return widgetUpdaterFuture != null && !widgetUpdaterFuture.isCancelled() && !widgetUpdaterFuture.isDone();
+ }
+
+ /**
+ * Cancels the widget updater. If the widget updater is not running, nothing will happen.
+ */
+ public synchronized void cancelWidgetUpdater() {
+ if (isWidgetUpdaterActive()) {
+ widgetUpdaterFuture.cancel(false);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Cancelled WidgetUpdater");
+ }
+ }
+
+ private synchronized void cancelChapterLoader() {
+ if (isChapterLoaderActive()) {
+ chapterLoaderFuture.cancel(true);
+ }
+ }
+
+ private synchronized boolean isChapterLoaderActive() {
+ return chapterLoaderFuture != null && !chapterLoaderFuture.isDone();
+ }
+
+ /**
+ * Starts a new thread that loads the chapter marks from a playable object. If another chapter loader is already active,
+ * it will be cancelled first.
+ * On completion, the callback's onChapterLoaded method will be called.
+ */
+ public synchronized void startChapterLoader(final Playable media) {
+ Validate.notNull(media);
+
+ if (isChapterLoaderActive()) {
+ cancelChapterLoader();
+ }
+
+ Runnable chapterLoader = new Runnable() {
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Chapter loader started");
+ if (media.getChapters() == null) {
+ media.loadChapterMarks();
+ if (!Thread.currentThread().isInterrupted() && media.getChapters() != null) {
+ callback.onChapterLoaded(media);
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Chapter loader stopped");
+ }
+ };
+ chapterLoaderFuture = schedExecutor.submit(chapterLoader);
+ }
+
+
+ /**
+ * Cancels all tasks. The PSTM will be in the initial state after execution of this method.
+ */
+ public synchronized void cancelAllTasks() {
+ cancelPositionSaver();
+ cancelWidgetUpdater();
+ disableSleepTimer();
+ cancelQueueLoader();
+ cancelChapterLoader();
+ }
+
+ /**
+ * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after
+ * execution of this method.
+ */
+ public synchronized void shutdown() {
+ EventDistributor.getInstance().unregister(eventDistributorListener);
+ cancelAllTasks();
+ schedExecutor.shutdown();
+ }
+
+ /**
+ * Sleeps for a given time and then pauses playback.
+ */
+ private class SleepTimer implements Runnable {
+ private static final String TAG = "SleepTimer";
+ private static final long UPDATE_INTERVALL = 1000L;
+ private volatile long waitingTime;
+ private volatile boolean isWaiting;
+
+ public SleepTimer(long waitingTime) {
+ super();
+ this.waitingTime = waitingTime;
+ isWaiting = true;
+ }
+
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting");
+ while (waitingTime > 0) {
+ try {
+ Thread.sleep(UPDATE_INTERVALL);
+ waitingTime -= UPDATE_INTERVALL;
+
+ if (waitingTime <= 0) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Waiting completed");
+ postExecute();
+ if (!Thread.currentThread().isInterrupted()) {
+ callback.onSleepTimerExpired();
+ }
+
+ }
+ } catch (InterruptedException e) {
+ Log.d(TAG, "Thread was interrupted while waiting");
+ break;
+ }
+ }
+ postExecute();
+ }
+
+ protected void postExecute() {
+ isWaiting = false;
+ }
+
+ public long getWaitingTime() {
+ return waitingTime;
+ }
+
+ public boolean isWaiting() {
+ return isWaiting;
+ }
+
+ }
+
+ public static interface PSTMCallback {
+ void positionSaverTick();
+
+ void onSleepTimerExpired();
+
+ void onWidgetUpdaterTick();
+
+ void onChapterLoaded(Playable media);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java
new file mode 100644
index 000000000..3d2b4ad39
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerStatus.java
@@ -0,0 +1,14 @@
+package de.danoeh.antennapod.service.playback;
+
+public enum PlayerStatus {
+ INDETERMINATE, // player is currently changing its state, listeners should wait until the player has left this state.
+ ERROR,
+ PREPARING,
+ PAUSED,
+ PLAYING,
+ STOPPED,
+ PREPARED,
+ SEEKING,
+ INITIALIZING, // playback service is loading the Playable's metadata
+ INITIALIZED // playback service was started, data source of media player was set.
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java
new file mode 100644
index 000000000..71bc40c2a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/service/playback/PlayerWidgetService.java
@@ -0,0 +1,190 @@
+package de.danoeh.antennapod.service.playback;
+
+import android.app.PendingIntent;
+import android.app.Service;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.RemoteViews;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.receiver.MediaButtonReceiver;
+import de.danoeh.antennapod.receiver.PlayerWidget;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.playback.Playable;
+
+/** Updates the state of the player widget */
+public class PlayerWidgetService extends Service {
+ private static final String TAG = "PlayerWidgetService";
+
+ private PlaybackService playbackService;
+ /** True while service is updating the widget */
+ private volatile boolean isUpdating;
+
+ public PlayerWidgetService() {
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service created");
+ isUpdating = false;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Service is about to be destroyed");
+ try {
+ unbindService(mConnection);
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "IllegalArgumentException when trying to unbind service");
+ }
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (!isUpdating) {
+ if (playbackService == null && PlaybackService.isRunning) {
+ bindService(new Intent(this, PlaybackService.class),
+ mConnection, 0);
+ } else {
+ startViewUpdaterIfNotRunning();
+ }
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Service was called while updating. Ignoring update request");
+ }
+ return Service.START_NOT_STICKY;
+ }
+
+ private void updateViews() {
+ if (playbackService == null) {
+ return;
+ }
+ isUpdating = true;
+
+ ComponentName playerWidget = new ComponentName(this, PlayerWidget.class);
+ AppWidgetManager manager = AppWidgetManager.getInstance(this);
+ RemoteViews views = new RemoteViews(getPackageName(),
+ R.layout.player_widget);
+ PendingIntent startMediaplayer = PendingIntent.getActivity(this, 0,
+ PlaybackService.getPlayerActivityIntent(this), 0);
+
+ views.setOnClickPendingIntent(R.id.layout_left, startMediaplayer);
+ final Playable media = playbackService.getPlayable();
+ if (playbackService != null && media != null) {
+ PlayerStatus status = playbackService.getStatus();
+
+ views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle());
+
+ if (status == PlayerStatus.PLAYING) {
+ String progressString = getProgressString(playbackService);
+ if (progressString != null) {
+ views.setTextViewText(R.id.txtvProgress, progressString);
+ }
+ views.setImageViewResource(R.id.butPlay, R.drawable.av_pause_dark);
+ if (Build.VERSION.SDK_INT >= 15) {
+ views.setContentDescription(R.id.butPlay, getString(R.string.pause_label));
+ }
+ } else {
+ views.setImageViewResource(R.id.butPlay, R.drawable.av_play_dark);
+ if (Build.VERSION.SDK_INT >= 15) {
+ views.setContentDescription(R.id.butPlay, getString(R.string.play_label));
+ }
+ }
+ views.setOnClickPendingIntent(R.id.butPlay,
+ createMediaButtonIntent());
+ } else {
+ views.setViewVisibility(R.id.txtvProgress, View.INVISIBLE);
+ views.setTextViewText(R.id.txtvTitle,
+ this.getString(R.string.no_media_playing_label));
+ views.setImageViewResource(R.id.butPlay, R.drawable.av_play);
+
+ }
+
+ manager.updateAppWidget(playerWidget, views);
+ isUpdating = false;
+ }
+
+ /** Creates an intent which fakes a mediabutton press */
+ private PendingIntent createMediaButtonIntent() {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE);
+ Intent startingIntent = new Intent(
+ MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER);
+ startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
+
+ return PendingIntent.getBroadcast(this, 0, startingIntent, 0);
+ }
+
+ private String getProgressString(PlaybackService ps) {
+ int position = ps.getCurrentPosition();
+ int duration = ps.getDuration();
+ if (position != PlaybackService.INVALID_TIME
+ && duration != PlaybackService.INVALID_TIME) {
+ return Converter.getDurationStringLong(position) + " / "
+ + Converter.getDurationStringLong(duration);
+ } else {
+ return null;
+ }
+ }
+
+ private ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Connection to service established");
+ playbackService = ((PlaybackService.LocalBinder) service)
+ .getService();
+ startViewUpdaterIfNotRunning();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ playbackService = null;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Disconnected from service");
+ }
+
+ };
+
+ private void startViewUpdaterIfNotRunning() {
+ if (!isUpdating) {
+ ViewUpdater updateThread = new ViewUpdater(this);
+ updateThread.start();
+ }
+ }
+
+ static class ViewUpdater extends Thread {
+ private static final String THREAD_NAME = "ViewUpdater";
+ private PlayerWidgetService service;
+
+ public ViewUpdater(PlayerWidgetService service) {
+ super();
+ setName(THREAD_NAME);
+ this.service = service;
+
+ }
+
+ @Override
+ public void run() {
+ service.updateViews();
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java b/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java
new file mode 100644
index 000000000..75cbd8b5a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/spa/SPAUtil.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.spa;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.receiver.SPAReceiver;
+
+/**
+ * Provides methods related to AntennaPodSP (https://github.com/danieloeh/AntennaPodSP)
+ */
+public class SPAUtil {
+ private static final String TAG = "SPAUtil";
+
+ private static final String PREF_HAS_QUERIED_SP_APPS = "prefSPAUtil.hasQueriedSPApps";
+
+ private SPAUtil() {
+ }
+
+
+ /**
+ * Sends an ACTION_SP_APPS_QUERY_FEEDS intent to all AntennaPod Single Purpose apps.
+ * The receiving single purpose apps will then send their feeds back to AntennaPod via an
+ * ACTION_SP_APPS_QUERY_FEEDS_RESPONSE intent.
+ * This intent will only be sent once.
+ *
+ * @return True if an intent was sent, false otherwise (for example if the intent has already been
+ * sent before.
+ */
+ public static synchronized boolean sendSPAppsQueryFeedsIntent(Context context) {
+ if (context == null) throw new IllegalArgumentException("context = null");
+ final Context appContext = context.getApplicationContext();
+ if (appContext == null) {
+ Log.wtf(TAG, "Unable to get application context");
+ return false;
+ }
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(appContext);
+ if (!prefs.getBoolean(PREF_HAS_QUERIED_SP_APPS, false)) {
+ appContext.sendBroadcast(new Intent(SPAReceiver.ACTION_SP_APPS_QUERY_FEEDS));
+ if (BuildConfig.DEBUG) Log.d(TAG, "Sending SP_APPS_QUERY_FEEDS intent");
+
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(PREF_HAS_QUERIED_SP_APPS, true);
+ editor.commit();
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Resets all preferences created by this class. Should only be used for debug purposes.
+ */
+ public static void resetSPAPreferences(Context c) {
+ if (BuildConfig.DEBUG) {
+ Validate.notNull(c);
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(c.getApplicationContext()).edit();
+ editor.putBoolean(PREF_HAS_QUERIED_SP_APPS, false);
+ editor.commit();
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java b/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java
new file mode 100644
index 000000000..e49ea4f83
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/DBReader.java
@@ -0,0 +1,908 @@
+package de.danoeh.antennapod.storage;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.SQLException;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.*;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.util.DownloadError;
+import de.danoeh.antennapod.util.comparator.DownloadStatusComparator;
+import de.danoeh.antennapod.util.comparator.FeedItemPubdateComparator;
+import de.danoeh.antennapod.util.comparator.PlaybackCompletionDateComparator;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+import de.danoeh.antennapod.util.flattr.FlattrThing;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Provides methods for reading data from the AntennaPod database.
+ * In general, all database calls in DBReader-methods are executed on the caller's thread.
+ * This means that the caller should make sure that DBReader-methods are not executed on the GUI-thread.
+ * This class will use the {@link de.danoeh.antennapod.feed.EventDistributor} to notify listeners about changes in the database.
+ */
+public final class DBReader {
+ private static final String TAG = "DBReader";
+
+ /**
+ * Maximum size of the list returned by {@link #getPlaybackHistory(android.content.Context)}.
+ */
+ public static final int PLAYBACK_HISTORY_SIZE = 50;
+
+ /**
+ * Maximum size of the list returned by {@link #getDownloadLog(android.content.Context)}.
+ */
+ public static final int DOWNLOAD_LOG_SIZE = 200;
+
+
+ private DBReader() {
+ }
+
+ /**
+ * Returns a list of Feeds, sorted alphabetically by their title.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of Feeds, sorted alphabetically by their title. A Feed-object
+ * of the returned list does NOT have its list of FeedItems yet. The FeedItem-list
+ * can be loaded separately with {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)}.
+ */
+ public static List<Feed> getFeedList(final Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting Feedlist");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<Feed> result = getFeedList(adapter);
+ adapter.close();
+ return result;
+ }
+
+ private static List<Feed> getFeedList(PodDBAdapter adapter) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting Feedlist");
+
+ Cursor feedlistCursor = adapter.getAllFeedsCursor();
+ List<Feed> feeds = new ArrayList<Feed>(feedlistCursor.getCount());
+
+ if (feedlistCursor.moveToFirst()) {
+ do {
+ Feed feed = extractFeedFromCursorRow(adapter, feedlistCursor);
+ feeds.add(feed);
+ } while (feedlistCursor.moveToNext());
+ }
+ feedlistCursor.close();
+ return feeds;
+ }
+
+ /**
+ * Returns a list with the download URLs of all feeds.
+ *
+ * @param context A context that is used for opening the database connection.
+ * @return A list of Strings with the download URLs of all feeds.
+ */
+ public static List<String> getFeedListDownloadUrls(final Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ List<String> result = new ArrayList<String>();
+ adapter.open();
+ Cursor feeds = adapter.getFeedCursorDownloadUrls();
+ if (feeds.moveToFirst()) {
+ do {
+ result.add(feeds.getString(1));
+ } while (feeds.moveToNext());
+ }
+ feeds.close();
+ adapter.close();
+
+ return result;
+ }
+
+ /**
+ * Returns a list of 'expired Feeds', i.e. Feeds that have not been updated for a certain amount of time.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param expirationTime Time that is used for determining whether a feed is outdated or not.
+ * A Feed is considered expired if 'lastUpdate < (currentTime - expirationTime)' evaluates to true.
+ * @return A list of Feeds, sorted alphabetically by their title. A Feed-object
+ * of the returned list does NOT have its list of FeedItems yet. The FeedItem-list
+ * can be loaded separately with {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)}.
+ */
+ public static List<Feed> getExpiredFeedsList(final Context context, final long expirationTime) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, String.format("getExpiredFeedsList(%d)", expirationTime));
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor feedlistCursor = adapter.getExpiredFeedsCursor(expirationTime);
+ List<Feed> feeds = new ArrayList<Feed>(feedlistCursor.getCount());
+
+ if (feedlistCursor.moveToFirst()) {
+ do {
+ Feed feed = extractFeedFromCursorRow(adapter, feedlistCursor);
+ feeds.add(feed);
+ } while (feedlistCursor.moveToNext());
+ }
+ feedlistCursor.close();
+ return feeds;
+ }
+
+ /**
+ * Takes a list of FeedItems and loads their corresponding Feed-objects from the database.
+ * The feedID-attribute of a FeedItem must be set to the ID of its feed or the method will
+ * not find the correct feed of an item.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param items The FeedItems whose Feed-objects should be loaded.
+ */
+ public static void loadFeedDataOfFeedItemlist(Context context,
+ List<FeedItem> items) {
+ List<Feed> feeds = getFeedList(context);
+ for (FeedItem item : items) {
+ for (Feed feed : feeds) {
+ if (feed.getId() == item.getFeedId()) {
+ item.setFeed(feed);
+ break;
+ }
+ }
+ if (item.getFeed() == null) {
+ Log.w(TAG, "No match found for item with ID " + item.getId() + ". Feed ID was " + item.getFeedId());
+ }
+ }
+ }
+
+ /**
+ * Loads the list of FeedItems for a certain Feed-object. This method should NOT be used if the FeedItems are not
+ * used. In order to get information ABOUT the list of FeedItems, consider using {@link #getFeedStatisticsList(android.content.Context)} instead.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param feed The Feed whose items should be loaded
+ * @return A list with the FeedItems of the Feed. The Feed-attribute of the FeedItems will already be set correctly.
+ * The method does NOT change the items-attribute of the feed.
+ */
+ public static List<FeedItem> getFeedItemList(Context context,
+ final Feed feed) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting Feeditems of feed " + feed.getTitle());
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor itemlistCursor = adapter.getAllItemsOfFeedCursor(feed);
+ List<FeedItem> items = extractItemlistFromCursor(adapter,
+ itemlistCursor);
+ itemlistCursor.close();
+
+ Collections.sort(items, new FeedItemPubdateComparator());
+
+ adapter.close();
+
+ for (FeedItem item : items) {
+ item.setFeed(feed);
+ }
+
+ return items;
+ }
+
+ static List<FeedItem> extractItemlistFromCursor(Context context, Cursor itemlistCursor) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<FeedItem> result = extractItemlistFromCursor(adapter, itemlistCursor);
+ adapter.close();
+ return result;
+ }
+
+ private static List<FeedItem> extractItemlistFromCursor(
+ PodDBAdapter adapter, Cursor itemlistCursor) {
+ ArrayList<String> itemIds = new ArrayList<String>();
+ List<FeedItem> items = new ArrayList<FeedItem>(
+ itemlistCursor.getCount());
+
+ if (itemlistCursor.moveToFirst()) {
+ do {
+ FeedItem item = new FeedItem();
+
+ item.setId(itemlistCursor.getLong(PodDBAdapter.IDX_FI_SMALL_ID));
+ item.setTitle(itemlistCursor
+ .getString(PodDBAdapter.IDX_FI_SMALL_TITLE));
+ item.setLink(itemlistCursor
+ .getString(PodDBAdapter.IDX_FI_SMALL_LINK));
+ item.setPubDate(new Date(itemlistCursor
+ .getLong(PodDBAdapter.IDX_FI_SMALL_PUBDATE)));
+ item.setPaymentLink(itemlistCursor
+ .getString(PodDBAdapter.IDX_FI_SMALL_PAYMENT_LINK));
+ item.setFeedId(itemlistCursor
+ .getLong(PodDBAdapter.IDX_FI_SMALL_FEED));
+ itemIds.add(String.valueOf(item.getId()));
+
+ item.setRead((itemlistCursor
+ .getInt(PodDBAdapter.IDX_FI_SMALL_READ) > 0));
+ item.setItemIdentifier(itemlistCursor
+ .getString(PodDBAdapter.IDX_FI_SMALL_ITEM_IDENTIFIER));
+ item.setFlattrStatus(new FlattrStatus(itemlistCursor
+ .getLong(PodDBAdapter.IDX_FI_SMALL_FLATTR_STATUS)));
+
+ long imageIndex = itemlistCursor.getLong(PodDBAdapter.IDX_FI_SMALL_IMAGE);
+ if (imageIndex != 0) {
+ item.setImage(getFeedImage(adapter, imageIndex));
+ }
+
+ // extract chapters
+ boolean hasSimpleChapters = itemlistCursor
+ .getInt(PodDBAdapter.IDX_FI_SMALL_HAS_CHAPTERS) > 0;
+ if (hasSimpleChapters) {
+ Cursor chapterCursor = adapter
+ .getSimpleChaptersOfFeedItemCursor(item);
+ if (chapterCursor.moveToFirst()) {
+ item.setChapters(new ArrayList<Chapter>());
+ do {
+ int chapterType = chapterCursor
+ .getInt(PodDBAdapter.KEY_CHAPTER_TYPE_INDEX);
+ Chapter chapter = null;
+ long start = chapterCursor
+ .getLong(PodDBAdapter.KEY_CHAPTER_START_INDEX);
+ String title = chapterCursor
+ .getString(PodDBAdapter.KEY_TITLE_INDEX);
+ String link = chapterCursor
+ .getString(PodDBAdapter.KEY_CHAPTER_LINK_INDEX);
+
+ switch (chapterType) {
+ case SimpleChapter.CHAPTERTYPE_SIMPLECHAPTER:
+ chapter = new SimpleChapter(start, title, item,
+ link);
+ break;
+ case ID3Chapter.CHAPTERTYPE_ID3CHAPTER:
+ chapter = new ID3Chapter(start, title, item,
+ link);
+ break;
+ case VorbisCommentChapter.CHAPTERTYPE_VORBISCOMMENT_CHAPTER:
+ chapter = new VorbisCommentChapter(start,
+ title, item, link);
+ break;
+ }
+ if (chapter != null) {
+ chapter.setId(chapterCursor
+ .getLong(PodDBAdapter.KEY_ID_INDEX));
+ item.getChapters().add(chapter);
+ }
+ } while (chapterCursor.moveToNext());
+ }
+ chapterCursor.close();
+ }
+ items.add(item);
+ } while (itemlistCursor.moveToNext());
+ }
+
+ extractMediafromItemlist(adapter, items, itemIds);
+ return items;
+ }
+
+ private static void extractMediafromItemlist(PodDBAdapter adapter,
+ List<FeedItem> items, ArrayList<String> itemIds) {
+
+ List<FeedItem> itemsCopy = new ArrayList<FeedItem>(items);
+ Cursor cursor = adapter.getFeedMediaCursorByItemID(itemIds
+ .toArray(new String[itemIds.size()]));
+ if (cursor.moveToFirst()) {
+ do {
+ long itemId = cursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX);
+ // find matching feed item
+ FeedItem item = getMatchingItemForMedia(itemId, itemsCopy);
+ if (item != null) {
+ item.setMedia(extractFeedMediaFromCursorRow(cursor));
+ item.getMedia().setItem(item);
+ }
+ } while (cursor.moveToNext());
+ cursor.close();
+ }
+ }
+
+ private static FeedMedia extractFeedMediaFromCursorRow(final Cursor cursor) {
+ long mediaId = cursor.getLong(PodDBAdapter.KEY_ID_INDEX);
+ Date playbackCompletionDate = null;
+ long playbackCompletionTime = cursor
+ .getLong(PodDBAdapter.KEY_PLAYBACK_COMPLETION_DATE_INDEX);
+ if (playbackCompletionTime > 0) {
+ playbackCompletionDate = new Date(
+ playbackCompletionTime);
+ }
+
+ return new FeedMedia(
+ mediaId,
+ null,
+ cursor.getInt(PodDBAdapter.KEY_DURATION_INDEX),
+ cursor.getInt(PodDBAdapter.KEY_POSITION_INDEX),
+ cursor.getLong(PodDBAdapter.KEY_SIZE_INDEX),
+ cursor.getString(PodDBAdapter.KEY_MIME_TYPE_INDEX),
+ cursor.getString(PodDBAdapter.KEY_FILE_URL_INDEX),
+ cursor.getString(PodDBAdapter.KEY_DOWNLOAD_URL_INDEX),
+ cursor.getInt(PodDBAdapter.KEY_DOWNLOADED_INDEX) > 0,
+ playbackCompletionDate,
+ cursor.getInt(PodDBAdapter.KEY_PLAYED_DURATION_INDEX));
+ }
+
+ private static Feed extractFeedFromCursorRow(PodDBAdapter adapter,
+ Cursor cursor) {
+ Date lastUpdate = new Date(
+ cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_LASTUPDATE));
+
+ final FeedImage image;
+ long imageIndex = cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_IMAGE);
+ if (imageIndex != 0) {
+ image = getFeedImage(adapter, imageIndex);
+ } else {
+ image = null;
+ }
+ Feed feed = new Feed(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_ID),
+ lastUpdate,
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_TITLE),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_LINK),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_DESCRIPTION),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_PAYMENT_LINK),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_AUTHOR),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_LANGUAGE),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_TYPE),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_FEED_IDENTIFIER),
+ image,
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_FILE_URL),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_STD_DOWNLOAD_URL),
+ cursor.getInt(PodDBAdapter.IDX_FEED_SEL_STD_DOWNLOADED) > 0,
+ new FlattrStatus(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_FLATTR_STATUS)));
+
+ if (image != null) {
+ image.setOwner(feed);
+ }
+
+ FeedPreferences preferences = new FeedPreferences(cursor.getLong(PodDBAdapter.IDX_FEED_SEL_STD_ID),
+ cursor.getInt(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_AUTO_DOWNLOAD) > 0,
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_USERNAME),
+ cursor.getString(PodDBAdapter.IDX_FEED_SEL_PREFERENCES_PASSWORD));
+
+ feed.setPreferences(preferences);
+ return feed;
+ }
+
+ private static FeedItem getMatchingItemForMedia(long itemId,
+ List<FeedItem> items) {
+ for (FeedItem item : items) {
+ if (item.getId() == itemId) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ static List<FeedItem> getQueue(Context context, PodDBAdapter adapter) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting queue");
+
+ Cursor itemlistCursor = adapter.getQueueCursor();
+ List<FeedItem> items = extractItemlistFromCursor(adapter,
+ itemlistCursor);
+ itemlistCursor.close();
+ loadFeedDataOfFeedItemlist(context, items);
+
+ return items;
+ }
+
+ /**
+ * Loads the IDs of the FeedItems in the queue. This method should be preferred over
+ * {@link #getQueue(android.content.Context)} if the FeedItems of the queue are not needed.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of IDs sorted by the same order as the queue. The caller can wrap the returned
+ * list in a {@link de.danoeh.antennapod.util.QueueAccess} object for easier access to the queue's properties.
+ */
+ public static List<Long> getQueueIDList(Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+
+ adapter.open();
+ List<Long> result = getQueueIDList(adapter);
+ adapter.close();
+
+ return result;
+ }
+
+ static List<Long> getQueueIDList(PodDBAdapter adapter) {
+ adapter.open();
+ Cursor queueCursor = adapter.getQueueIDCursor();
+
+ List<Long> queueIds = new ArrayList<Long>(queueCursor.getCount());
+ if (queueCursor.moveToFirst()) {
+ do {
+ queueIds.add(queueCursor.getLong(0));
+ } while (queueCursor.moveToNext());
+ }
+ return queueIds;
+ }
+
+
+ /**
+ * Loads a list of the FeedItems in the queue. If the FeedItems of the queue are not used directly, consider using
+ * {@link #getQueueIDList(android.content.Context)} instead.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of FeedItems sorted by the same order as the queue. The caller can wrap the returned
+ * list in a {@link de.danoeh.antennapod.util.QueueAccess} object for easier access to the queue's properties.
+ */
+ public static List<FeedItem> getQueue(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting queue");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<FeedItem> items = getQueue(context, adapter);
+ adapter.close();
+ return items;
+ }
+
+ /**
+ * Loads a list of FeedItems whose episode has been downloaded.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of FeedItems whose episdoe has been downloaded.
+ */
+ public static List<FeedItem> getDownloadedItems(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting downloaded items");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor itemlistCursor = adapter.getDownloadedItemsCursor();
+ List<FeedItem> items = extractItemlistFromCursor(adapter,
+ itemlistCursor);
+ itemlistCursor.close();
+ loadFeedDataOfFeedItemlist(context, items);
+ Collections.sort(items, new FeedItemPubdateComparator());
+
+ adapter.close();
+ return items;
+
+ }
+
+ /**
+ * Loads a list of FeedItems whose 'read'-attribute is set to false.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of FeedItems whose 'read'-attribute it set to false. If the FeedItems in the list are not used,
+ * consider using {@link #getUnreadItemIds(android.content.Context)} instead.
+ */
+ public static List<FeedItem> getUnreadItemsList(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting unread items list");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor itemlistCursor = adapter.getUnreadItemsCursor();
+ List<FeedItem> items = extractItemlistFromCursor(adapter,
+ itemlistCursor);
+ itemlistCursor.close();
+
+ loadFeedDataOfFeedItemlist(context, items);
+
+ adapter.close();
+
+ return items;
+ }
+
+ /**
+ * Loads the IDs of the FeedItems whose 'read'-attribute is set to false.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of IDs of the FeedItems whose 'read'-attribute is set to false. This method should be preferred
+ * over {@link #getUnreadItemsList(android.content.Context)} if the FeedItems in the UnreadItems list are not used.
+ */
+ public static long[] getUnreadItemIds(Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor cursor = adapter.getUnreadItemIdsCursor();
+ long[] itemIds = new long[cursor.getCount()];
+ int i = 0;
+ if (cursor.moveToFirst()) {
+ do {
+ itemIds[i] = cursor.getLong(PodDBAdapter.KEY_ID_INDEX);
+ i++;
+ } while (cursor.moveToNext());
+ }
+ return itemIds;
+ }
+
+
+ /**
+ * Loads a list of FeedItems sorted by pubDate in descending order.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param limit The maximum number of episodes that should be loaded.
+ */
+ public static List<FeedItem> getRecentlyPublishedEpisodes(Context context, int limit) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting recently published items list");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor itemlistCursor = adapter.getRecentlyPublishedItemsCursor(limit);
+ List<FeedItem> items = extractItemlistFromCursor(adapter,
+ itemlistCursor);
+ itemlistCursor.close();
+
+ loadFeedDataOfFeedItemlist(context, items);
+
+ adapter.close();
+
+ return items;
+ }
+
+ /**
+ * Loads the playback history from the database. A FeedItem is in the playback history if playback of the correpsonding episode
+ * has been completed at least once.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return The playback history. The FeedItems are sorted by their media's playbackCompletionDate in descending order.
+ * The size of the returned list is limited by {@link #PLAYBACK_HISTORY_SIZE}.
+ */
+ public static List<FeedItem> getPlaybackHistory(final Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading playback history");
+ final int PLAYBACK_HISTORY_SIZE = 50;
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ Cursor mediaCursor = adapter.getCompletedMediaCursor(PLAYBACK_HISTORY_SIZE);
+ String[] itemIds = new String[mediaCursor.getCount()];
+ for (int i = 0; i < itemIds.length && mediaCursor.moveToPosition(i); i++) {
+ itemIds[i] = Long.toString(mediaCursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX));
+ }
+ mediaCursor.close();
+ Cursor itemCursor = adapter.getFeedItemCursor(itemIds);
+ List<FeedItem> items = extractItemlistFromCursor(adapter, itemCursor);
+ loadFeedDataOfFeedItemlist(context, items);
+ itemCursor.close();
+ adapter.close();
+
+ Collections.sort(items, new PlaybackCompletionDateComparator());
+ return items;
+ }
+
+ /**
+ * Loads the download log from the database.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list with DownloadStatus objects that represent the download log.
+ * The size of the returned list is limited by {@link #DOWNLOAD_LOG_SIZE}.
+ */
+ public static List<DownloadStatus> getDownloadLog(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Extracting DownloadLog");
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor logCursor = adapter.getDownloadLogCursor(DOWNLOAD_LOG_SIZE);
+ List<DownloadStatus> downloadLog = new ArrayList<DownloadStatus>(
+ logCursor.getCount());
+
+ if (logCursor.moveToFirst()) {
+ do {
+ long id = logCursor.getLong(PodDBAdapter.KEY_ID_INDEX);
+
+ long feedfileId = logCursor
+ .getLong(PodDBAdapter.KEY_FEEDFILE_INDEX);
+ int feedfileType = logCursor
+ .getInt(PodDBAdapter.KEY_FEEDFILETYPE_INDEX);
+ boolean successful = logCursor
+ .getInt(PodDBAdapter.KEY_SUCCESSFUL_INDEX) > 0;
+ int reason = logCursor.getInt(PodDBAdapter.KEY_REASON_INDEX);
+ String reasonDetailed = logCursor
+ .getString(PodDBAdapter.KEY_REASON_DETAILED_INDEX);
+ String title = logCursor
+ .getString(PodDBAdapter.KEY_DOWNLOADSTATUS_TITLE_INDEX);
+ Date completionDate = new Date(
+ logCursor
+ .getLong(PodDBAdapter.KEY_COMPLETION_DATE_INDEX)
+ );
+ downloadLog.add(new DownloadStatus(id, title, feedfileId,
+ feedfileType, successful, DownloadError.fromCode(reason), completionDate,
+ reasonDetailed));
+
+ } while (logCursor.moveToNext());
+ }
+ logCursor.close();
+ Collections.sort(downloadLog, new DownloadStatusComparator());
+ return downloadLog;
+ }
+
+ /**
+ * Loads the FeedItemStatistics objects of all Feeds in the database. This method should be preferred over
+ * {@link #getFeedItemList(android.content.Context, de.danoeh.antennapod.feed.Feed)} if only metadata about
+ * the FeedItems is needed.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return A list of FeedItemStatistics objects sorted alphabetically by their Feed's title.
+ */
+ public static List<FeedItemStatistics> getFeedStatisticsList(final Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<FeedItemStatistics> result = new ArrayList<FeedItemStatistics>();
+ Cursor cursor = adapter.getFeedStatisticsCursor();
+ if (cursor.moveToFirst()) {
+ do {
+ result.add(new FeedItemStatistics(cursor.getLong(PodDBAdapter.IDX_FEEDSTATISTICS_FEED),
+ cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_NUM_ITEMS),
+ cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_NEW_ITEMS),
+ cursor.getInt(PodDBAdapter.IDX_FEEDSTATISTICS_IN_PROGRESS_EPISODES),
+ new Date(cursor.getLong(PodDBAdapter.IDX_FEEDSTATISTICS_LATEST_EPISODE))));
+ } while (cursor.moveToNext());
+ }
+
+ cursor.close();
+ adapter.close();
+ return result;
+ }
+
+ /**
+ * Loads a specific Feed from the database.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param feedId The ID of the Feed
+ * @return The Feed or null if the Feed could not be found. The Feeds FeedItems will also be loaded from the
+ * database and the items-attribute will be set correctly.
+ */
+ public static Feed getFeed(final Context context, final long feedId) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Feed result = getFeed(context, feedId, adapter);
+ adapter.close();
+ return result;
+ }
+
+ static Feed getFeed(final Context context, final long feedId, PodDBAdapter adapter) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading feed with id " + feedId);
+ Feed feed = null;
+
+ Cursor feedCursor = adapter.getFeedCursor(feedId);
+ if (feedCursor.moveToFirst()) {
+ feed = extractFeedFromCursorRow(adapter, feedCursor);
+ feed.setItems(getFeedItemList(context, feed));
+ } else {
+ Log.e(TAG, "getFeed could not find feed with id " + feedId);
+ }
+ feedCursor.close();
+ return feed;
+ }
+
+ static FeedItem getFeedItem(final Context context, final long itemId, PodDBAdapter adapter) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading feeditem with id " + itemId);
+ FeedItem item = null;
+
+ Cursor itemCursor = adapter.getFeedItemCursor(Long.toString(itemId));
+ if (itemCursor.moveToFirst()) {
+ List<FeedItem> list = extractItemlistFromCursor(adapter, itemCursor);
+ if (list.size() > 0) {
+ item = list.get(0);
+ loadFeedDataOfFeedItemlist(context, list);
+ }
+ }
+ return item;
+
+ }
+
+ /**
+ * Loads a specific FeedItem from the database.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId The ID of the FeedItem
+ * @return The FeedItem or null if the FeedItem could not be found. All FeedComponent-attributes of the FeedItem will
+ * also be loaded from the database.
+ */
+ public static FeedItem getFeedItem(final Context context, final long itemId) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Loading feeditem with id " + itemId);
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ FeedItem item = getFeedItem(context, itemId, adapter);
+ adapter.close();
+ return item;
+
+ }
+
+ /**
+ * Loads additional information about a FeedItem, e.g. shownotes
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param item The FeedItem
+ */
+ public static void loadExtraInformationOfFeedItem(final Context context, final FeedItem item) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor extraCursor = adapter.getExtraInformationOfItem(item);
+ if (extraCursor.moveToFirst()) {
+ String description = extraCursor
+ .getString(PodDBAdapter.IDX_FI_EXTRA_DESCRIPTION);
+ String contentEncoded = extraCursor
+ .getString(PodDBAdapter.IDX_FI_EXTRA_CONTENT_ENCODED);
+ item.setDescription(description);
+ item.setContentEncoded(contentEncoded);
+ }
+ adapter.close();
+ }
+
+ /**
+ * Returns the number of downloaded episodes.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return The number of downloaded episodes.
+ */
+ public static int getNumberOfDownloadedEpisodes(final Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final int result = adapter.getNumberOfDownloadedEpisodes();
+ adapter.close();
+ return result;
+ }
+
+ /**
+ * Returns the number of unread items.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return The number of unread items.
+ */
+ public static int getNumberOfUnreadItems(final Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final int result = adapter.getNumberOfUnreadItems();
+ adapter.close();
+ return result;
+ }
+
+ /**
+ * Searches the DB for a FeedImage of the given id.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param imageId The id of the object
+ * @return The found object
+ */
+ public static FeedImage getFeedImage(final Context context, final long imageId) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ FeedImage result = getFeedImage(adapter, imageId);
+ adapter.close();
+ return result;
+ }
+
+ /**
+ * Searches the DB for a FeedImage of the given id.
+ *
+ * @param id The id of the object
+ * @return The found object
+ */
+ static FeedImage getFeedImage(PodDBAdapter adapter, final long id) {
+ Cursor cursor = adapter.getImageCursor(id);
+ if ((cursor.getCount() == 0) || !cursor.moveToFirst()) {
+ throw new SQLException("No FeedImage found at index: " + id);
+ }
+ FeedImage image = new FeedImage(id, cursor.getString(cursor
+ .getColumnIndex(PodDBAdapter.KEY_TITLE)),
+ cursor.getString(cursor
+ .getColumnIndex(PodDBAdapter.KEY_FILE_URL)),
+ cursor.getString(cursor
+ .getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL)),
+ cursor.getInt(cursor
+ .getColumnIndex(PodDBAdapter.KEY_DOWNLOADED)) > 0
+ );
+ cursor.close();
+ return image;
+ }
+
+ /**
+ * Searches the DB for a FeedMedia of the given id.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param mediaId The id of the object
+ * @return The found object
+ */
+ public static FeedMedia getFeedMedia(final Context context, final long mediaId) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+
+ adapter.open();
+ Cursor mediaCursor = adapter.getSingleFeedMediaCursor(mediaId);
+
+ FeedMedia media = null;
+ if (mediaCursor.moveToFirst()) {
+ final long itemId = mediaCursor.getLong(PodDBAdapter.KEY_MEDIA_FEEDITEM_INDEX);
+ media = extractFeedMediaFromCursorRow(mediaCursor);
+ FeedItem item = getFeedItem(context, itemId);
+ if (media != null && item != null) {
+ media.setItem(item);
+ item.setMedia(media);
+ }
+ }
+
+ mediaCursor.close();
+ adapter.close();
+
+ return media;
+ }
+
+ /**
+ * Returns the flattr queue as a List of FlattrThings. The list consists of Feeds and FeedItems.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @return The flattr queue as a List.
+ */
+ public static List<FlattrThing> getFlattrQueue(Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<FlattrThing> result = new ArrayList<FlattrThing>();
+
+ // load feeds
+ Cursor feedCursor = adapter.getFeedsInFlattrQueueCursor();
+ if (feedCursor.moveToFirst()) {
+ do {
+ result.add(extractFeedFromCursorRow(adapter, feedCursor));
+ } while (feedCursor.moveToNext());
+ }
+ feedCursor.close();
+
+ //load feed items
+ Cursor feedItemCursor = adapter.getFeedItemsInFlattrQueueCursor();
+ result.addAll(extractItemlistFromCursor(adapter, feedItemCursor));
+ feedItemCursor.close();
+
+ adapter.close();
+ Log.d(TAG, "Returning flattrQueueIterator for queue with " + result.size() + " items.");
+ return result;
+ }
+
+
+ /**
+ * Returns true if the flattr queue is empty.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static boolean getFlattrQueueEmpty(Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ boolean empty = adapter.getFlattrQueueSize() == 0;
+ adapter.close();
+ return empty;
+ }
+
+ /**
+ * Returns data necessary for displaying the navigation drawer. This includes
+ * the list of subscriptions, the number of items in the queue and the number of unread
+ * items.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static NavDrawerData getNavDrawerData(Context context) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ List<Feed> feeds = getFeedList(adapter);
+ int queueSize = adapter.getQueueSize();
+ int numUnreadItems = adapter.getNumberOfUnreadItems();
+ NavDrawerData result = new NavDrawerData(feeds, queueSize, numUnreadItems);
+ adapter.close();
+ return result;
+ }
+
+ public static class NavDrawerData {
+ public List<Feed> feeds;
+ public int queueSize;
+ public int numUnreadItems;
+
+ public NavDrawerData(List<Feed> feeds, int queueSize, int numUnreadItems) {
+ this.feeds = feeds;
+ this.queueSize = queueSize;
+ this.numUnreadItems = numUnreadItems;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java b/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java
new file mode 100644
index 000000000..a230ba797
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/DBTasks.java
@@ -0,0 +1,895 @@
+package de.danoeh.antennapod.storage;
+
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.asynctask.FlattrClickWorker;
+import de.danoeh.antennapod.asynctask.FlattrStatusFetcher;
+import de.danoeh.antennapod.feed.*;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.GpodnetSyncService;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.util.DownloadError;
+import de.danoeh.antennapod.util.NetworkUtils;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.comparator.FeedItemPubdateComparator;
+import de.danoeh.antennapod.util.exception.MediaFileNotFoundException;
+import de.danoeh.antennapod.util.flattr.FlattrUtils;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Provides methods for doing common tasks that use DBReader and DBWriter.
+ */
+public final class DBTasks {
+ private static final String TAG = "DBTasks";
+
+ /**
+ * Executor service used by the autodownloadUndownloadedEpisodes method.
+ */
+ private static ExecutorService autodownloadExec;
+
+ static {
+ autodownloadExec = Executors.newSingleThreadExecutor(new ThreadFactory() {
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ });
+ }
+
+ private DBTasks() {
+ }
+
+ /**
+ * Removes the feed with the given download url. This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the db
+ * @param downloadUrl URL of the feed.
+ */
+ public static void removeFeedWithDownloadUrl(Context context, String downloadUrl) {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor cursor = adapter.getFeedCursorDownloadUrls();
+ long feedID = 0;
+ if (cursor.moveToFirst()) {
+ do {
+ if (cursor.getString(1).equals(downloadUrl)) {
+ feedID = cursor.getLong(0);
+ }
+ } while (cursor.moveToNext());
+ }
+ cursor.close();
+ adapter.close();
+
+ if (feedID != 0) {
+ try {
+ DBWriter.deleteFeed(context, feedID).get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ } else {
+ Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl);
+ }
+ }
+
+ /**
+ * Starts playback of a FeedMedia object's file. This method will build an Intent based on the given parameters to
+ * start the {@link PlaybackService}.
+ *
+ * @param context Used for sending starting Services and Activities.
+ * @param media The FeedMedia object.
+ * @param showPlayer If true, starts the appropriate player activity ({@link de.danoeh.antennapod.activity.AudioplayerActivity}
+ * or {@link de.danoeh.antennapod.activity.VideoplayerActivity}
+ * @param startWhenPrepared Parameter for the {@link PlaybackService} start intent. If true, playback will start as
+ * soon as the PlaybackService has finished loading the FeedMedia object's file.
+ * @param shouldStream Parameter for the {@link PlaybackService} start intent. If true, the FeedMedia object's file
+ * will be streamed, otherwise the downloaded file will be used. If the downloaded file cannot be
+ * found, the PlaybackService will shutdown and the database entry of the FeedMedia object will be
+ * corrected.
+ */
+ public static void playMedia(final Context context, final FeedMedia media,
+ boolean showPlayer, boolean startWhenPrepared, boolean shouldStream) {
+ try {
+ if (!shouldStream) {
+ if (media.fileExists() == false) {
+ throw new MediaFileNotFoundException(
+ "No episode was found at " + media.getFile_url(),
+ media);
+ }
+ }
+ // Start playback Service
+ Intent launchIntent = new Intent(context, PlaybackService.class);
+ launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media);
+ launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED,
+ startWhenPrepared);
+ launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM,
+ shouldStream);
+ launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY,
+ true);
+ context.startService(launchIntent);
+ if (showPlayer) {
+ // Launch media player
+ context.startActivity(PlaybackService.getPlayerActivityIntent(
+ context, media));
+ }
+ DBWriter.addQueueItemAt(context, media.getItem().getId(), 0, false);
+ } catch (MediaFileNotFoundException e) {
+ e.printStackTrace();
+ if (media.isPlaying()) {
+ context.sendBroadcast(new Intent(
+ PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+ }
+ notifyMissingFeedMediaFile(context, media);
+ }
+ }
+
+ private static AtomicBoolean isRefreshing = new AtomicBoolean(false);
+
+ /**
+ * Refreshes a given list of Feeds in a separate Thread. This method might ignore subsequent calls if it is still
+ * enqueuing Feeds for download from a previous call
+ *
+ * @param context Might be used for accessing the database
+ * @param feeds List of Feeds that should be refreshed.
+ */
+ public static void refreshAllFeeds(final Context context,
+ final List<Feed> feeds) {
+ if (isRefreshing.compareAndSet(false, true)) {
+ new Thread() {
+ public void run() {
+ if (feeds != null) {
+ refreshFeeds(context, feeds);
+ } else {
+ refreshFeeds(context, DBReader.getFeedList(context));
+ }
+ isRefreshing.set(false);
+
+ if (FlattrUtils.hasToken()) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Flattring all pending things.");
+ new FlattrClickWorker(context).executeAsync(); // flattr pending things
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Fetching flattr status.");
+ new FlattrStatusFetcher(context).start();
+
+ }
+ GpodnetSyncService.sendSyncIntent(context);
+ autodownloadUndownloadedItems(context);
+ }
+ }.start();
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Ignoring request to refresh all feeds: Refresh lock is locked");
+ }
+ }
+
+ /**
+ * Used by refreshExpiredFeeds to determine which feeds should be refreshed.
+ * This method will use the value specified in the UserPreferences as the
+ * expiration time.
+ *
+ * @param context Used for DB access.
+ * @return A list of expired feeds. An empty list will be returned if there
+ * are no expired feeds.
+ */
+ public static List<Feed> getExpiredFeeds(final Context context) {
+ long millis = UserPreferences.getUpdateInterval();
+
+ if (millis > 0) {
+
+ List<Feed> feedList = DBReader.getExpiredFeedsList(context,
+ millis);
+ if (feedList.size() > 0) {
+ refreshFeeds(context, feedList);
+ }
+ return feedList;
+ } else {
+ return new ArrayList<Feed>();
+ }
+ }
+
+ /**
+ * Refreshes expired Feeds in the list returned by the getExpiredFeedsList(Context, long) method in DBReader.
+ * The expiration date parameter is determined by the update interval specified in {@link UserPreferences}.
+ *
+ * @param context Used for DB access.
+ */
+ public static void refreshExpiredFeeds(final Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Refreshing expired feeds");
+
+ new Thread() {
+ public void run() {
+ refreshFeeds(context, getExpiredFeeds(context));
+ }
+ }.start();
+ }
+
+ private static void refreshFeeds(final Context context,
+ final List<Feed> feedList) {
+
+ for (Feed feed : feedList) {
+ try {
+ refreshFeed(context, feed);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DBWriter.addDownloadStatus(
+ context,
+ new DownloadStatus(feed, feed
+ .getHumanReadableIdentifier(),
+ DownloadError.ERROR_REQUEST_ERROR, false, e
+ .getMessage()
+ )
+ );
+ }
+ }
+
+ }
+
+ /**
+ * Updates a specific Feed.
+ *
+ * @param context Used for requesting the download.
+ * @param feed The Feed object.
+ */
+ public static void refreshFeed(Context context, Feed feed)
+ throws DownloadRequestException {
+ Feed f;
+ if (feed.getPreferences() == null) {
+ f = new Feed(feed.getDownload_url(), new Date(), feed.getTitle());
+ } else {
+ f = new Feed(feed.getDownload_url(), new Date(), feed.getTitle(),
+ feed.getPreferences().getUsername(), feed.getPreferences().getPassword());
+ }
+ f.setId(feed.getId());
+ DownloadRequester.getInstance().downloadFeed(context, f);
+ }
+
+ /**
+ * Notifies the database about a missing FeedImage file. This method will attempt to re-download the file.
+ *
+ * @param context Used for requesting the download.
+ * @param image The FeedImage object.
+ */
+ public static void notifyInvalidImageFile(final Context context,
+ final FeedImage image) {
+ Log.i(TAG,
+ "The DB was notified about an invalid image download. It will now try to re-download the image file");
+ try {
+ DownloadRequester.getInstance().downloadImage(context, image);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ Log.w(TAG, "Failed to download invalid feed image");
+ }
+ }
+
+ /**
+ * Notifies the database about a missing FeedMedia file. This method will correct the FeedMedia object's values in the
+ * DB and send a FeedUpdateBroadcast.
+ */
+ public static void notifyMissingFeedMediaFile(final Context context,
+ final FeedMedia media) {
+ Log.i(TAG,
+ "The feedmanager was notified about a missing episode. It will update its database now.");
+ media.setDownloaded(false);
+ media.setFile_url(null);
+ DBWriter.setFeedMedia(context, media);
+ EventDistributor.getInstance().sendFeedUpdateBroadcast();
+ }
+
+ /**
+ * Request the download of all objects in the queue. from a separate Thread.
+ *
+ * @param context Used for requesting the download an accessing the database.
+ */
+ public static void downloadAllItemsInQueue(final Context context) {
+ new Thread() {
+ public void run() {
+ List<FeedItem> queue = DBReader.getQueue(context);
+ if (!queue.isEmpty()) {
+ try {
+ downloadFeedItems(context,
+ queue.toArray(new FeedItem[queue.size()]));
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }.start();
+ }
+
+ /**
+ * Requests the download of a list of FeedItem objects.
+ *
+ * @param context Used for requesting the download and accessing the DB.
+ * @param items The FeedItem objects.
+ */
+ public static void downloadFeedItems(final Context context,
+ FeedItem... items) throws DownloadRequestException {
+ downloadFeedItems(true, context, items);
+ }
+
+ private static void downloadFeedItems(boolean performAutoCleanup,
+ final Context context, final FeedItem... items)
+ throws DownloadRequestException {
+ final DownloadRequester requester = DownloadRequester.getInstance();
+
+ if (performAutoCleanup) {
+ new Thread() {
+
+ @Override
+ public void run() {
+ performAutoCleanup(context,
+ getPerformAutoCleanupArgs(context, items.length));
+ }
+
+ }.start();
+ }
+ for (FeedItem item : items) {
+ if (item.getMedia() != null
+ && !requester.isDownloadingFile(item.getMedia())
+ && !item.getMedia().isDownloaded()) {
+ if (items.length > 1) {
+ try {
+ requester.downloadMedia(context, item.getMedia());
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ DBWriter.addDownloadStatus(context,
+ new DownloadStatus(item.getMedia(), item
+ .getMedia()
+ .getHumanReadableIdentifier(),
+ DownloadError.ERROR_REQUEST_ERROR,
+ false, e.getMessage()
+ )
+ );
+ }
+ } else {
+ requester.downloadMedia(context, item.getMedia());
+ }
+ }
+ }
+ }
+
+ private static int getNumberOfUndownloadedEpisodes(
+ final List<FeedItem> queue, final List<FeedItem> unreadItems) {
+ int counter = 0;
+ for (FeedItem item : queue) {
+ if (item.hasMedia() && !item.getMedia().isDownloaded()
+ && !item.getMedia().isPlaying()
+ && item.getFeed().getPreferences().getAutoDownload()) {
+ counter++;
+ }
+ }
+ for (FeedItem item : unreadItems) {
+ if (item.hasMedia() && !item.getMedia().isDownloaded()
+ && item.getFeed().getPreferences().getAutoDownload()) {
+ counter++;
+ }
+ }
+ return counter;
+ }
+
+ /**
+ * Looks for undownloaded episodes in the queue or list of unread items and request a download if
+ * 1. Network is available
+ * 2. There is free space in the episode cache
+ * This method is executed on an internal single thread executor.
+ *
+ * @param context Used for accessing the DB.
+ * @param mediaIds If this list is not empty, the method will only download a candidate for automatic downloading if
+ * its media ID is in the mediaIds list.
+ * @return A Future that can be used for waiting for the methods completion.
+ */
+ public static Future<?> autodownloadUndownloadedItems(final Context context, final long... mediaIds) {
+ return autodownloadExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Performing auto-dl of undownloaded episodes");
+ if (NetworkUtils.autodownloadNetworkAvailable(context)
+ && UserPreferences.isEnableAutodownload()) {
+ final List<FeedItem> queue = DBReader.getQueue(context);
+ final List<FeedItem> unreadItems = DBReader
+ .getUnreadItemsList(context);
+
+ int undownloadedEpisodes = getNumberOfUndownloadedEpisodes(queue,
+ unreadItems);
+ int downloadedEpisodes = DBReader
+ .getNumberOfDownloadedEpisodes(context);
+ int deletedEpisodes = performAutoCleanup(context,
+ getPerformAutoCleanupArgs(context, undownloadedEpisodes));
+ int episodeSpaceLeft = undownloadedEpisodes;
+ boolean cacheIsUnlimited = UserPreferences.getEpisodeCacheSize() == UserPreferences
+ .getEpisodeCacheSizeUnlimited();
+
+ if (!cacheIsUnlimited
+ && UserPreferences.getEpisodeCacheSize() < downloadedEpisodes
+ + undownloadedEpisodes) {
+ episodeSpaceLeft = UserPreferences.getEpisodeCacheSize()
+ - (downloadedEpisodes - deletedEpisodes);
+ }
+
+ Arrays.sort(mediaIds); // sort for binary search
+ final boolean ignoreMediaIds = mediaIds.length == 0;
+ List<FeedItem> itemsToDownload = new ArrayList<FeedItem>();
+
+ if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) {
+ for (int i = 0; i < queue.size(); i++) { // ignore playing item
+ FeedItem item = queue.get(i);
+ long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1;
+ if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0)
+ && item.hasMedia()
+ && !item.getMedia().isDownloaded()
+ && !item.getMedia().isPlaying()
+ && item.getFeed().getPreferences().getAutoDownload()) {
+ itemsToDownload.add(item);
+ episodeSpaceLeft--;
+ undownloadedEpisodes--;
+ if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) {
+ break;
+ }
+ }
+ }
+ }
+
+ if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) {
+ for (FeedItem item : unreadItems) {
+ long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1;
+ if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0)
+ && item.hasMedia()
+ && !item.getMedia().isDownloaded()
+ && item.getFeed().getPreferences().getAutoDownload()) {
+ itemsToDownload.add(item);
+ episodeSpaceLeft--;
+ undownloadedEpisodes--;
+ if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) {
+ break;
+ }
+ }
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Enqueueing " + itemsToDownload.size()
+ + " items for download");
+
+ try {
+ downloadFeedItems(false, context,
+ itemsToDownload.toArray(new FeedItem[itemsToDownload
+ .size()])
+ );
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
+
+ }
+ }
+ });
+
+ }
+
+ private static int getPerformAutoCleanupArgs(Context context,
+ final int episodeNumber) {
+ if (episodeNumber >= 0
+ && UserPreferences.getEpisodeCacheSize() != UserPreferences
+ .getEpisodeCacheSizeUnlimited()) {
+ int downloadedEpisodes = DBReader
+ .getNumberOfDownloadedEpisodes(context);
+ if (downloadedEpisodes + episodeNumber >= UserPreferences
+ .getEpisodeCacheSize()) {
+
+ return downloadedEpisodes + episodeNumber
+ - UserPreferences.getEpisodeCacheSize();
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller
+ * 'playbackCompletionDate'-value will be deleted first.
+ * <p/>
+ * This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the DB.
+ */
+ public static void performAutoCleanup(final Context context) {
+ performAutoCleanup(context, getPerformAutoCleanupArgs(context, 0));
+ }
+
+ private static int performAutoCleanup(final Context context,
+ final int episodeNumber) {
+ List<FeedItem> candidates = new ArrayList<FeedItem>();
+ List<FeedItem> downloadedItems = DBReader.getDownloadedItems(context);
+ QueueAccess queue = QueueAccess.IDListAccess(DBReader.getQueueIDList(context));
+ List<FeedItem> delete;
+ for (FeedItem item : downloadedItems) {
+ if (item.hasMedia() && item.getMedia().isDownloaded()
+ && !queue.contains(item.getId()) && item.isRead()) {
+ candidates.add(item);
+ }
+
+ }
+
+ Collections.sort(candidates, new Comparator<FeedItem>() {
+ @Override
+ public int compare(FeedItem lhs, FeedItem rhs) {
+ Date l = lhs.getMedia().getPlaybackCompletionDate();
+ Date r = rhs.getMedia().getPlaybackCompletionDate();
+
+ if (l == null) {
+ l = new Date(0);
+ }
+ if (r == null) {
+ r = new Date(0);
+ }
+ return l.compareTo(r);
+ }
+ });
+
+ if (candidates.size() > episodeNumber) {
+ delete = candidates.subList(0, episodeNumber);
+ } else {
+ delete = candidates;
+ }
+
+ for (FeedItem item : delete) {
+ try {
+ DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ int counter = delete.size();
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, String.format(
+ "Auto-delete deleted %d episodes (%d requested)", counter,
+ episodeNumber));
+
+ return counter;
+ }
+
+ /**
+ * Adds all FeedItem objects whose 'read'-attribute is false to the queue in a separate thread.
+ */
+ public static void enqueueAllNewItems(final Context context) {
+ long[] unreadItems = DBReader.getUnreadItemIds(context);
+ DBWriter.addQueueItem(context, unreadItems);
+ }
+
+ /**
+ * Returns the successor of a FeedItem in the queue.
+ *
+ * @param context Used for accessing the DB.
+ * @param itemId ID of the FeedItem
+ * @param queue Used for determining the successor of the item. If this parameter is null, the method will load
+ * the queue from the database in the same thread.
+ * @return Successor of the FeedItem or null if the FeedItem is not in the queue or has no successor.
+ */
+ public static FeedItem getQueueSuccessorOfItem(Context context,
+ final long itemId, List<FeedItem> queue) {
+ FeedItem result = null;
+ if (queue == null) {
+ queue = DBReader.getQueue(context);
+ }
+ if (queue != null) {
+ Iterator<FeedItem> iterator = queue.iterator();
+ while (iterator.hasNext()) {
+ FeedItem item = iterator.next();
+ if (item.getId() == itemId) {
+ if (iterator.hasNext()) {
+ result = iterator.next();
+ }
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Loads the queue from the database and checks if the specified FeedItem is in the queue.
+ * This method should NOT be executed in the GUI thread.
+ *
+ * @param context Used for accessing the DB.
+ * @param feedItemId ID of the FeedItem
+ */
+ public static boolean isInQueue(Context context, final long feedItemId) {
+ List<Long> queue = DBReader.getQueueIDList(context);
+ return QueueAccess.IDListAccess(queue).contains(feedItemId);
+ }
+
+ private static Feed searchFeedByIdentifyingValueOrID(Context context, PodDBAdapter adapter,
+ Feed feed) {
+ if (feed.getId() != 0) {
+ return DBReader.getFeed(context, feed.getId(), adapter);
+ } else {
+ List<Feed> feeds = DBReader.getFeedList(context);
+ for (Feed f : feeds) {
+ if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) {
+ f.setItems(DBReader.getFeedItemList(context, f));
+ return f;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get a FeedItem by its identifying value.
+ */
+ private static FeedItem searchFeedItemByIdentifyingValue(Feed feed,
+ String identifier) {
+ for (FeedItem item : feed.getItems()) {
+ if (item.getIdentifyingValue().equals(identifier)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Adds new Feeds to the database or updates the old versions if they already exists. If another Feed with the same
+ * identifying value already exists, this method will add new FeedItems from the new Feed to the existing Feed.
+ * These FeedItems will be marked as unread.
+ * <p/>
+ * This method can update multiple feeds at once. Submitting a feed twice in the same method call can result in undefined behavior.
+ * <p/>
+ * This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the DB.
+ * @param newFeeds The new Feed objects.
+ * @return The updated Feeds from the database if it already existed, or the new Feed from the parameters otherwise.
+ */
+ public static synchronized Feed[] updateFeed(final Context context,
+ final Feed... newFeeds) {
+ List<Feed> newFeedsList = new ArrayList<Feed>();
+ List<Feed> updatedFeedsList = new ArrayList<Feed>();
+ Feed[] resultFeeds = new Feed[newFeeds.length];
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+
+ for (int feedIdx = 0; feedIdx < newFeeds.length; feedIdx++) {
+
+ final Feed newFeed = newFeeds[feedIdx];
+
+ // Look up feed in the feedslist
+ final Feed savedFeed = searchFeedByIdentifyingValueOrID(context, adapter,
+ newFeed);
+ if (savedFeed == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Found no existing Feed with title "
+ + newFeed.getTitle() + ". Adding as new one."
+ );
+ // Add a new Feed
+ newFeedsList.add(newFeed);
+ resultFeeds[feedIdx] = newFeed;
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Feed with title " + newFeed.getTitle()
+ + " already exists. Syncing new with existing one.");
+
+ Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator());
+ if (savedFeed.compareWithOther(newFeed)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Feed has updated attribute values. Updating old feed's attributes");
+ savedFeed.updateFromOther(newFeed);
+ }
+ if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences");
+ savedFeed.getPreferences().updateFromOther(newFeed.getPreferences());
+ }
+ // Look for new or updated Items
+ for (int idx = 0; idx < newFeed.getItems().size(); idx++) {
+ final FeedItem item = newFeed.getItems().get(idx);
+ FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed,
+ item.getIdentifyingValue());
+ if (oldItem == null) {
+ // item is new
+ final int i = idx;
+ item.setFeed(savedFeed);
+ savedFeed.getItems().add(i, item);
+ item.setRead(false);
+ } else {
+ oldItem.updateFromOther(item);
+ }
+ }
+ // update attributes
+ savedFeed.setLastUpdate(newFeed.getLastUpdate());
+ savedFeed.setType(newFeed.getType());
+
+ updatedFeedsList.add(savedFeed);
+ resultFeeds[feedIdx] = savedFeed;
+ }
+ }
+
+ adapter.close();
+
+ try {
+ DBWriter.addNewFeed(context, newFeedsList.toArray(new Feed[newFeedsList.size()])).get();
+ DBWriter.setCompleteFeed(context, updatedFeedsList.toArray(new Feed[updatedFeedsList.size()])).get();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+
+ EventDistributor.getInstance().sendFeedUpdateBroadcast();
+
+ return resultFeeds;
+ }
+
+ /**
+ * Searches the titles of FeedItems of a specific Feed for a given
+ * string.
+ *
+ * @param context Used for accessing the DB.
+ * @param feedID The id of the feed whose items should be searched.
+ * @param query The search string.
+ * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems.
+ */
+ public static FutureTask<List<FeedItem>> searchFeedItemTitle(final Context context,
+ final long feedID, final String query) {
+ return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) {
+ @Override
+ public void execute(PodDBAdapter adapter) {
+ Cursor searchResult = adapter.searchItemTitles(feedID,
+ query);
+ List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult);
+ DBReader.loadFeedDataOfFeedItemlist(context, items);
+ setResult(items);
+ searchResult.close();
+ }
+ });
+ }
+
+ /**
+ * Searches the descriptions of FeedItems of a specific Feed for a given
+ * string.
+ *
+ * @param context Used for accessing the DB.
+ * @param feedID The id of the feed whose items should be searched.
+ * @param query The search string
+ * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems.
+ */
+ public static FutureTask<List<FeedItem>> searchFeedItemDescription(final Context context,
+ final long feedID, final String query) {
+ return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) {
+ @Override
+ public void execute(PodDBAdapter adapter) {
+ Cursor searchResult = adapter.searchItemDescriptions(feedID,
+ query);
+ List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult);
+ DBReader.loadFeedDataOfFeedItemlist(context, items);
+ setResult(items);
+ searchResult.close();
+ }
+ });
+ }
+
+ /**
+ * Searches the contentEncoded-value of FeedItems of a specific Feed for a given
+ * string.
+ *
+ * @param context Used for accessing the DB.
+ * @param feedID The id of the feed whose items should be searched.
+ * @param query The search string
+ * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems.
+ */
+ public static FutureTask<List<FeedItem>> searchFeedItemContentEncoded(final Context context,
+ final long feedID, final String query) {
+ return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) {
+ @Override
+ public void execute(PodDBAdapter adapter) {
+ Cursor searchResult = adapter.searchItemContentEncoded(feedID,
+ query);
+ List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult);
+ DBReader.loadFeedDataOfFeedItemlist(context, items);
+ setResult(items);
+ searchResult.close();
+ }
+ });
+ }
+
+ /**
+ * Searches chapters of the FeedItems of a specific Feed for a given string.
+ *
+ * @param context Used for accessing the DB.
+ * @param feedID The id of the feed whose items should be searched.
+ * @param query The search string
+ * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems.
+ */
+ public static FutureTask<List<FeedItem>> searchFeedItemChapters(final Context context,
+ final long feedID, final String query) {
+ return new FutureTask<List<FeedItem>>(new QueryTask<List<FeedItem>>(context) {
+ @Override
+ public void execute(PodDBAdapter adapter) {
+ Cursor searchResult = adapter.searchItemChapters(feedID,
+ query);
+ List<FeedItem> items = DBReader.extractItemlistFromCursor(context, searchResult);
+ DBReader.loadFeedDataOfFeedItemlist(context, items);
+ setResult(items);
+ searchResult.close();
+ }
+ });
+ }
+
+ /**
+ * A runnable which should be used for database queries. The onCompletion
+ * method is executed on the database executor to handle Cursors correctly.
+ * This class automatically creates a PodDBAdapter object and closes it when
+ * it is no longer in use.
+ */
+ static abstract class QueryTask<T> implements Callable<T> {
+ private T result;
+ private Context context;
+
+ public QueryTask(Context context) {
+ this.context = context;
+ }
+
+ @Override
+ public T call() throws Exception {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ execute(adapter);
+ adapter.close();
+ return result;
+ }
+
+ public abstract void execute(PodDBAdapter adapter);
+
+ protected void setResult(T result) {
+ this.result = result;
+ }
+ }
+
+ /**
+ * Adds the given FeedItem to the flattr queue if the user is logged in. Otherwise, a dialog
+ * will be opened that lets the user go either to the login screen or the website of the flattr thing.
+ *
+ * @param context
+ * @param item
+ */
+ public static void flattrItemIfLoggedIn(Context context, FeedItem item) {
+ if (FlattrUtils.hasToken()) {
+ item.getFlattrStatus().setFlattrQueue();
+ DBWriter.setFlattredStatus(context, item, true);
+ } else {
+ FlattrUtils.showNoTokenDialogOrRedirect(context, item.getPaymentLink());
+ }
+ }
+
+ /**
+ * Adds the given Feed to the flattr queue if the user is logged in. Otherwise, a dialog
+ * will be opened that lets the user go either to the login screen or the website of the flattr thing.
+ *
+ * @param context
+ * @param feed
+ */
+ public static void flattrFeedIfLoggedIn(Context context, Feed feed) {
+ if (FlattrUtils.hasToken()) {
+ feed.getFlattrStatus().setFlattrQueue();
+ DBWriter.setFlattredStatus(context, feed, true);
+ } else {
+ FlattrUtils.showNoTokenDialogOrRedirect(context, feed.getPaymentLink());
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java b/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java
new file mode 100644
index 000000000..9916ac97f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/DBWriter.java
@@ -0,0 +1,974 @@
+package de.danoeh.antennapod.storage;
+
+import android.app.backup.BackupManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.preference.PreferenceManager;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.asynctask.FlattrClickWorker;
+import de.danoeh.antennapod.feed.*;
+import de.danoeh.antennapod.preferences.GpodnetPreferences;
+import de.danoeh.antennapod.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+import de.danoeh.antennapod.util.flattr.FlattrThing;
+import de.danoeh.antennapod.util.flattr.SimpleFlattrThing;
+import org.shredzone.flattr4j.model.Flattr;
+
+import java.io.File;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.Date;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+
+/**
+ * Provides methods for writing data to AntennaPod's database.
+ * In general, DBWriter-methods will be executed on an internal ExecutorService.
+ * Some methods return a Future-object which the caller can use for waiting for the method's completion. The returned Future's
+ * will NOT contain any results.
+ * The caller can also use the {@link EventDistributor} in order to be notified about the method's completion asynchronously.
+ * This class will use the {@link EventDistributor} to notify listeners about changes in the database.
+ */
+public class DBWriter {
+ private static final String TAG = "DBWriter";
+
+ private static final ExecutorService dbExec;
+
+ static {
+ dbExec = Executors.newSingleThreadExecutor(new ThreadFactory() {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ });
+ }
+
+ private DBWriter() {
+ }
+
+ /**
+ * Deletes a downloaded FeedMedia file from the storage device.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param mediaId ID of the FeedMedia object whose downloaded file should be deleted.
+ */
+ public static Future<?> deleteFeedMediaOfItem(final Context context,
+ final long mediaId) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+
+ final FeedMedia media = DBReader.getFeedMedia(context, mediaId);
+ if (media != null) {
+ Log.i(TAG, String.format("Requested to delete FeedMedia [id=%d, title=%s, downloaded=%s",
+ media.getId(), media.getEpisodeTitle(), String.valueOf(media.isDownloaded())));
+ boolean result = false;
+ if (media.isDownloaded()) {
+ // delete downloaded media file
+ File mediaFile = new File(media.getFile_url());
+ if (mediaFile.exists()) {
+ result = mediaFile.delete();
+ }
+ media.setDownloaded(false);
+ media.setFile_url(null);
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setMedia(media);
+ adapter.close();
+
+ // If media is currently being played, change playback
+ // type to 'stream' and shutdown playback service
+ SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(context);
+ if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA) {
+ if (media.getId() == PlaybackPreferences
+ .getCurrentlyPlayingFeedMediaId()) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(
+ PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM,
+ true);
+ editor.commit();
+ }
+ if (PlaybackPreferences
+ .getCurrentlyPlayingFeedMediaId() == media
+ .getId()) {
+ context.sendBroadcast(new Intent(
+ PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+ }
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Deleting File. Result: " + result);
+ EventDistributor.getInstance().sendQueueUpdateBroadcast();
+ EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
+ }
+ }
+ });
+ }
+
+ /**
+ * Deletes a Feed and all downloaded files of its components like images and downloaded episodes.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param feedId ID of the Feed that should be deleted.
+ */
+ public static Future<?> deleteFeed(final Context context, final long feedId) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ DownloadRequester requester = DownloadRequester.getInstance();
+ SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(context
+ .getApplicationContext());
+ final Feed feed = DBReader.getFeed(context, feedId);
+ if (feed != null) {
+ if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA
+ && PlaybackPreferences.getLastPlayedFeedId() == feed
+ .getId()) {
+ context.sendBroadcast(new Intent(
+ PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putLong(
+ PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
+ -1);
+ editor.commit();
+ }
+
+ // delete image file
+ if (feed.getImage() != null) {
+ if (feed.getImage().isDownloaded()
+ && feed.getImage().getFile_url() != null) {
+ File imageFile = new File(feed.getImage()
+ .getFile_url());
+ imageFile.delete();
+ } else if (requester.isDownloadingFile(feed.getImage())) {
+ requester.cancelDownload(context, feed.getImage());
+ }
+ }
+ // delete stored media files and mark them as read
+ List<FeedItem> queue = DBReader.getQueue(context);
+ boolean queueWasModified = false;
+ if (feed.getItems() == null) {
+ DBReader.getFeedItemList(context, feed);
+ }
+
+ for (FeedItem item : feed.getItems()) {
+ queueWasModified |= queue.remove(item);
+ if (item.getMedia() != null
+ && item.getMedia().isDownloaded()) {
+ File mediaFile = new File(item.getMedia()
+ .getFile_url());
+ mediaFile.delete();
+ } else if (item.getMedia() != null
+ && requester.isDownloadingFile(item.getMedia())) {
+ requester.cancelDownload(context, item.getMedia());
+ }
+
+ if (item.hasItemImage()) {
+ FeedImage image = item.getImage();
+ if (image.isDownloaded() && image.getFile_url() != null) {
+ File imgFile = new File(image.getFile_url());
+ imgFile.delete();
+ } else if (requester.isDownloadingFile(image)) {
+ requester.cancelDownload(context, item.getImage());
+ }
+ }
+ }
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ if (queueWasModified) {
+ adapter.setQueue(queue);
+ }
+ adapter.removeFeed(feed);
+ adapter.close();
+
+ GpodnetPreferences.addRemovedFeed(feed.getDownload_url());
+ EventDistributor.getInstance().sendFeedUpdateBroadcast();
+
+ BackupManager backupManager = new BackupManager(context);
+ backupManager.dataChanged();
+ }
+ }
+ });
+ }
+
+ /**
+ * Deletes the entire playback history.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static Future<?> clearPlaybackHistory(final Context context) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.clearPlaybackHistory();
+ adapter.close();
+ EventDistributor.getInstance()
+ .sendPlaybackHistoryUpdateBroadcast();
+ }
+ });
+ }
+
+ /**
+ * Adds a FeedMedia object to the playback history. A FeedMedia object is in the playback history if
+ * its playback completion date is set to a non-null value. This method will set the playback completion date to the
+ * current date regardless of the current value.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param media FeedMedia that should be added to the playback history.
+ */
+ public static Future<?> addItemToPlaybackHistory(final Context context,
+ final FeedMedia media) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Adding new item to playback history");
+ media.setPlaybackCompletionDate(new Date());
+ // reset played_duration to 0 so that it behaves correctly when the episode is played again
+ media.setPlayedDuration(0);
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedMediaPlaybackCompletionDate(media);
+ adapter.close();
+ EventDistributor.getInstance().sendPlaybackHistoryUpdateBroadcast();
+
+ }
+ });
+ }
+
+ private static void cleanupDownloadLog(final PodDBAdapter adapter) {
+ final long logSize = adapter.getDownloadLogSize();
+ if (logSize > DBReader.DOWNLOAD_LOG_SIZE) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Cleaning up download log");
+ adapter.removeDownloadLogItems(logSize - DBReader.DOWNLOAD_LOG_SIZE);
+ }
+ }
+
+ /**
+ * Adds a Download status object to the download log.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param status The DownloadStatus object.
+ */
+ public static Future<?> addDownloadStatus(final Context context,
+ final DownloadStatus status) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setDownloadStatus(status);
+ adapter.close();
+ EventDistributor.getInstance().sendDownloadLogUpdateBroadcast();
+ }
+ });
+
+ }
+
+ /**
+ * Inserts a FeedItem in the queue at the specified index. The 'read'-attribute of the FeedItem will be set to
+ * true. If the FeedItem is already in the queue, the queue will not be modified.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId ID of the FeedItem that should be added to the queue.
+ * @param index Destination index. Must be in range 0..queue.size()
+ * @param performAutoDownload True if an auto-download process should be started after the operation
+ * @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
+ */
+ public static Future<?> addQueueItemAt(final Context context, final long itemId,
+ final int index, final boolean performAutoDownload) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final List<FeedItem> queue = DBReader
+ .getQueue(context, adapter);
+ FeedItem item = null;
+
+ if (queue != null) {
+ boolean queueModified = false;
+ boolean unreadItemsModified = false;
+
+ if (!itemListContains(queue, itemId)) {
+ item = DBReader.getFeedItem(context, itemId);
+ if (item != null) {
+ queue.add(index, item);
+ queueModified = true;
+ if (!item.isRead()) {
+ item.setRead(true);
+ unreadItemsModified = true;
+ }
+ }
+ }
+ if (queueModified) {
+ adapter.setQueue(queue);
+ EventDistributor.getInstance()
+ .sendQueueUpdateBroadcast();
+ }
+ if (unreadItemsModified && item != null) {
+ adapter.setSingleFeedItem(item);
+ EventDistributor.getInstance()
+ .sendUnreadItemsUpdateBroadcast();
+ }
+ }
+ adapter.close();
+ if (performAutoDownload) {
+ DBTasks.autodownloadUndownloadedItems(context);
+ }
+
+ }
+ });
+
+ }
+
+ /**
+ * Appends FeedItem objects to the end of the queue. The 'read'-attribute of all items will be set to true.
+ * If a FeedItem is already in the queue, the FeedItem will not change its position in the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemIds IDs of the FeedItem objects that should be added to the queue.
+ */
+ public static Future<?> addQueueItem(final Context context,
+ final long... itemIds) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ if (itemIds.length > 0) {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(context,
+ adapter);
+
+ if (queue != null) {
+ boolean queueModified = false;
+ boolean unreadItemsModified = false;
+ List<FeedItem> itemsToSave = new LinkedList<FeedItem>();
+ for (int i = 0; i < itemIds.length; i++) {
+ if (!itemListContains(queue, itemIds[i])) {
+ final FeedItem item = DBReader.getFeedItem(
+ context, itemIds[i]);
+
+ if (item != null) {
+ queue.add(item);
+ queueModified = true;
+ if (!item.isRead()) {
+ item.setRead(true);
+ itemsToSave.add(item);
+ unreadItemsModified = true;
+ }
+ }
+ }
+ }
+ if (queueModified) {
+ adapter.setQueue(queue);
+ EventDistributor.getInstance()
+ .sendQueueUpdateBroadcast();
+ }
+ if (unreadItemsModified) {
+ adapter.setFeedItemlist(itemsToSave);
+ EventDistributor.getInstance()
+ .sendUnreadItemsUpdateBroadcast();
+ }
+ }
+ adapter.close();
+ DBTasks.autodownloadUndownloadedItems(context);
+ }
+ }
+ });
+
+ }
+
+ /**
+ * Removes all FeedItem objects from the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static Future<?> clearQueue(final Context context) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.clearQueue();
+ adapter.close();
+
+ EventDistributor.getInstance().sendQueueUpdateBroadcast();
+ }
+ });
+ }
+
+ /**
+ * Removes a FeedItem object from the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId ID of the FeedItem that should be removed.
+ * @param performAutoDownload true if an auto-download process should be started after the operation.
+ */
+ public static Future<?> removeQueueItem(final Context context,
+ final long itemId, final boolean performAutoDownload) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final List<FeedItem> queue = DBReader
+ .getQueue(context, adapter);
+ FeedItem item = null;
+
+ if (queue != null) {
+ boolean queueModified = false;
+ QueueAccess queueAccess = QueueAccess.ItemListAccess(queue);
+ if (queueAccess.contains(itemId)) {
+ item = DBReader.getFeedItem(context, itemId);
+ if (item != null) {
+ queueModified = queueAccess.remove(itemId);
+ }
+ }
+ if (queueModified) {
+ adapter.setQueue(queue);
+ EventDistributor.getInstance()
+ .sendQueueUpdateBroadcast();
+ } else {
+ Log.w(TAG, "Queue was not modified by call to removeQueueItem");
+ }
+ } else {
+ Log.e(TAG, "removeQueueItem: Could not load queue");
+ }
+ adapter.close();
+ if (performAutoDownload) {
+ DBTasks.autodownloadUndownloadedItems(context);
+ }
+ }
+ });
+
+ }
+
+ /**
+ * Moves the specified item to the top of the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId The item to move to the top of the queue
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ */
+ public static Future<?> moveQueueItemToTop(final Context context, final long itemId, final boolean broadcastUpdate) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ List<Long> queueIdList = DBReader.getQueueIDList(context);
+ int currentLocation = 0;
+ for (long id : queueIdList) {
+ if (id == itemId) {
+ moveQueueItemHelper(context, currentLocation, 0, broadcastUpdate);
+ return;
+ }
+ currentLocation++;
+ }
+ Log.e(TAG, "moveQueueItemToTop: item not found");
+ }
+ });
+ }
+
+ /**
+ * Moves the specified item to the bottom of the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId The item to move to the bottom of the queue
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ */
+ public static Future<?> moveQueueItemToBottom(final Context context, final long itemId,
+ final boolean broadcastUpdate) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ List<Long> queueIdList = DBReader.getQueueIDList(context);
+ int currentLocation = 0;
+ for (long id : queueIdList) {
+ if (id == itemId) {
+ moveQueueItemHelper(context, currentLocation, queueIdList.size() - 1,
+ broadcastUpdate);
+ return;
+ }
+ currentLocation++;
+ }
+ Log.e(TAG, "moveQueueItemToBottom: item not found");
+ }
+ });
+ }
+
+ /**
+ * Changes the position of a FeedItem in the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param from Source index. Must be in range 0..queue.size()-1.
+ * @param to Destination index. Must be in range 0..queue.size()-1.
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
+ */
+ public static Future<?> moveQueueItem(final Context context, final int from,
+ final int to, final boolean broadcastUpdate) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ moveQueueItemHelper(context, from, to, broadcastUpdate);
+ }
+ });
+ }
+
+ /**
+ * Changes the position of a FeedItem in the queue.
+ * <p/>
+ * This function must be run using the ExecutorService (dbExec).
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param from Source index. Must be in range 0..queue.size()-1.
+ * @param to Destination index. Must be in range 0..queue.size()-1.
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
+ */
+ private static void moveQueueItemHelper(final Context context, final int from,
+ final int to, final boolean broadcastUpdate) {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ final List<FeedItem> queue = DBReader
+ .getQueue(context, adapter);
+
+ if (queue != null) {
+ if (from >= 0 && from < queue.size() && to >= 0
+ && to < queue.size()) {
+
+ final FeedItem item = queue.remove(from);
+ queue.add(to, item);
+
+ adapter.setQueue(queue);
+ if (broadcastUpdate) {
+ EventDistributor.getInstance()
+ .sendQueueUpdateBroadcast();
+ }
+
+ }
+ } else {
+ Log.e(TAG, "moveQueueItemHelper: Could not load queue");
+ }
+ adapter.close();
+ }
+
+ /**
+ * Sets the 'read'-attribute of a FeedItem to the specified value.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param item The FeedItem object
+ * @param read New value of the 'read'-attribute
+ * @param resetMediaPosition true if this method should also reset the position of the FeedItem's FeedMedia object.
+ * If the FeedItem has no FeedMedia object, this parameter will be ignored.
+ */
+ public static Future<?> markItemRead(Context context, FeedItem item, boolean read, boolean resetMediaPosition) {
+ long mediaId = (item.hasMedia()) ? item.getMedia().getId() : 0;
+ return markItemRead(context, item.getId(), read, mediaId, resetMediaPosition);
+ }
+
+ /**
+ * Sets the 'read'-attribute of a FeedItem to the specified value.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId ID of the FeedItem
+ * @param read New value of the 'read'-attribute
+ */
+ public static Future<?> markItemRead(final Context context, final long itemId,
+ final boolean read) {
+ return markItemRead(context, itemId, read, 0, false);
+ }
+
+ private static Future<?> markItemRead(final Context context, final long itemId,
+ final boolean read, final long mediaId,
+ final boolean resetMediaPosition) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedItemRead(read, itemId, mediaId,
+ resetMediaPosition);
+ adapter.close();
+
+ EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
+ }
+ });
+ }
+
+ /**
+ * Sets the 'read'-attribute of all FeedItems of a specific Feed to true.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param feedId ID of the Feed.
+ */
+ public static Future<?> markFeedRead(final Context context, final long feedId) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor itemCursor = adapter.getAllItemsOfFeedCursor(feedId);
+ long[] itemIds = new long[itemCursor.getCount()];
+ itemCursor.moveToFirst();
+ for (int i = 0; i < itemIds.length; i++) {
+ itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX);
+ itemCursor.moveToNext();
+ }
+ itemCursor.close();
+ adapter.setFeedItemRead(true, itemIds);
+ adapter.close();
+
+ EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
+ }
+ });
+
+ }
+
+ /**
+ * Sets the 'read'-attribute of all FeedItems to true.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static Future<?> markAllItemsRead(final Context context) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ Cursor itemCursor = adapter.getUnreadItemsCursor();
+ long[] itemIds = new long[itemCursor.getCount()];
+ itemCursor.moveToFirst();
+ for (int i = 0; i < itemIds.length; i++) {
+ itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX);
+ itemCursor.moveToNext();
+ }
+ itemCursor.close();
+ adapter.setFeedItemRead(true, itemIds);
+ adapter.close();
+
+ EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
+ }
+ });
+
+ }
+
+ static Future<?> addNewFeed(final Context context, final Feed... feeds) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ final PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setCompleteFeed(feeds);
+ adapter.close();
+
+ for (Feed feed : feeds) {
+ GpodnetPreferences.addAddedFeed(feed.getDownload_url());
+ }
+
+ BackupManager backupManager = new BackupManager(context);
+ backupManager.dataChanged();
+ }
+ });
+ }
+
+ static Future<?> setCompleteFeed(final Context context, final Feed... feeds) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setCompleteFeed(feeds);
+ adapter.close();
+
+ }
+ });
+
+ }
+
+ /**
+ * Saves a FeedMedia object in the database. This method will save all attributes of the FeedMedia object. The
+ * contents of FeedComponent-attributes (e.g. the FeedMedia's 'item'-attribute) will not be saved.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param media The FeedMedia object.
+ */
+ public static Future<?> setFeedMedia(final Context context,
+ final FeedMedia media) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setMedia(media);
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Saves the 'position' and 'duration' attributes of a FeedMedia object
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param media The FeedMedia object.
+ */
+ public static Future<?> setFeedMediaPlaybackInformation(final Context context, final FeedMedia media) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedMediaPlaybackInformation(media);
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Saves a FeedItem object in the database. This method will save all attributes of the FeedItem object including
+ * the content of FeedComponent-attributes.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param item The FeedItem object.
+ */
+ public static Future<?> setFeedItem(final Context context,
+ final FeedItem item) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setSingleFeedItem(item);
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Saves a FeedImage object in the database. This method will save all attributes of the FeedImage object. The
+ * contents of FeedComponent-attributes (e.g. the FeedImages's 'feed'-attribute) will not be saved.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param image The FeedImage object.
+ */
+ public static Future<?> setFeedImage(final Context context,
+ final FeedImage image) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setImage(image);
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Updates download URLs of feeds from a given Map. The key of the Map is the original URL of the feed
+ * and the value is the updated URL
+ */
+ public static Future<?> updateFeedDownloadURLs(final Context context, final Map<String, String> urls) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ for (String key : urls.keySet()) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Replacing URL " + key + " with url " + urls.get(key));
+
+ adapter.setFeedDownloadUrl(key, urls.get(key));
+ }
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Saves a FeedPreferences object in the database. The Feed ID of the FeedPreferences-object MUST NOT be 0.
+ *
+ * @param context Used for opening a database connection.
+ * @param preferences The FeedPreferences object.
+ */
+ public static Future<?> setFeedPreferences(final Context context, final FeedPreferences preferences) {
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedPreferences(preferences);
+ adapter.close();
+ EventDistributor.getInstance().sendFeedUpdateBroadcast();
+ }
+ });
+ }
+
+ private static boolean itemListContains(List<FeedItem> items, long itemId) {
+ for (FeedItem item : items) {
+ if (item.getId() == itemId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Saves the FlattrStatus of a FeedItem object in the database.
+ *
+ * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved
+ */
+ public static Future<?> setFeedItemFlattrStatus(final Context context,
+ final FeedItem item,
+ final boolean startFlattrClickWorker) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedItemFlattrStatus(item);
+ adapter.close();
+ if (startFlattrClickWorker) {
+ new FlattrClickWorker(context).executeAsync();
+ }
+ }
+ });
+ }
+
+ /**
+ * Saves the FlattrStatus of a Feed object in the database.
+ *
+ * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved
+ */
+ private static Future<?> setFeedFlattrStatus(final Context context,
+ final Feed feed,
+ final boolean startFlattrClickWorker) {
+ return dbExec.submit(new Runnable() {
+
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.setFeedFlattrStatus(feed);
+ adapter.close();
+ if (startFlattrClickWorker) {
+ new FlattrClickWorker(context).executeAsync();
+ }
+ }
+ });
+ }
+
+ /**
+ * format an url for querying the database
+ * (postfix a / and apply percent-encoding)
+ */
+ private static String formatURIForQuery(String uri) {
+ try {
+ return URLEncoder.encode(uri.endsWith("/") ? uri.substring(0, uri.length() - 1) : uri, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ Log.e(TAG, e.getMessage());
+ return "";
+ }
+ }
+
+
+ /**
+ * Set flattr status of the passed thing (either a FeedItem or a Feed)
+ *
+ * @param context
+ * @param thing
+ * @param startFlattrClickWorker true if FlattrClickWorker should be started after the FlattrStatus has been saved
+ * @return
+ */
+ public static Future<?> setFlattredStatus(Context context, FlattrThing thing, boolean startFlattrClickWorker) {
+ // must propagate this to back db
+ if (thing instanceof FeedItem)
+ return setFeedItemFlattrStatus(context, (FeedItem) thing, startFlattrClickWorker);
+ else if (thing instanceof Feed)
+ return setFeedFlattrStatus(context, (Feed) thing, startFlattrClickWorker);
+ else if (thing instanceof SimpleFlattrThing) {
+ } // SimpleFlattrThings are generated on the fly and do not have DB backing
+ else
+ Log.e(TAG, "flattrQueue processing - thing is neither FeedItem nor Feed nor SimpleFlattrThing");
+
+ return null;
+ }
+
+ /**
+ * Reset flattr status to unflattrd for all items
+ */
+ public static Future<?> clearAllFlattrStatus(final Context context) {
+ Log.d(TAG, "clearAllFlattrStatus()");
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ adapter.clearAllFlattrStatus();
+ adapter.close();
+ }
+ });
+ }
+
+ /**
+ * Set flattr status of the feeds/feeditems in flattrList to flattred at the given timestamp,
+ * where the information has been retrieved from the flattr API
+ */
+ public static Future<?> setFlattredStatus(final Context context, final List<Flattr> flattrList) {
+ Log.d(TAG, "setFlattredStatus to status retrieved from flattr api running with " + flattrList.size() + " items");
+ // clear flattr status in db
+ clearAllFlattrStatus(context);
+
+ // submit list with flattred things having normalized URLs to db
+ return dbExec.submit(new Runnable() {
+ @Override
+ public void run() {
+ PodDBAdapter adapter = new PodDBAdapter(context);
+ adapter.open();
+ for (Flattr flattr : flattrList) {
+ adapter.setItemFlattrStatus(formatURIForQuery(flattr.getThing().getUrl()), new FlattrStatus(flattr.getCreated().getTime()));
+ }
+ adapter.close();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java
new file mode 100644
index 000000000..0ef766e58
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequestException.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.storage;
+
+/**
+ * Thrown by the DownloadRequester if a download request contains invalid data
+ * or something went wrong while processing the request.
+ */
+public class DownloadRequestException extends Exception {
+
+ public DownloadRequestException() {
+ super();
+ }
+
+ public DownloadRequestException(String detailMessage, Throwable throwable) {
+ super(detailMessage, throwable);
+ }
+
+ public DownloadRequestException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public DownloadRequestException(Throwable throwable) {
+ super(throwable);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java
new file mode 100644
index 000000000..d305c572b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/DownloadRequester.java
@@ -0,0 +1,367 @@
+package de.danoeh.antennapod.storage;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import android.webkit.URLUtil;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.*;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.download.DownloadRequest;
+import de.danoeh.antennapod.service.download.DownloadService;
+import de.danoeh.antennapod.util.FileNameGenerator;
+import de.danoeh.antennapod.util.URLChecker;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringEscapeUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+
+import java.io.File;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+
+/**
+ * Sends download requests to the DownloadService. This class should always be used for starting downloads,
+ * otherwise they won't work correctly.
+ */
+public class DownloadRequester {
+ private static final String TAG = "DownloadRequester";
+
+ public static final String IMAGE_DOWNLOADPATH = "images/";
+ public static final String FEED_DOWNLOADPATH = "cache/";
+ public static final String MEDIA_DOWNLOADPATH = "media/";
+
+ private static DownloadRequester downloader;
+
+ Map<String, DownloadRequest> downloads;
+
+ private DownloadRequester() {
+ downloads = new ConcurrentHashMap<String, DownloadRequest>();
+ }
+
+ public static synchronized DownloadRequester getInstance() {
+ if (downloader == null) {
+ downloader = new DownloadRequester();
+ }
+ return downloader;
+ }
+
+ /**
+ * Starts a new download with the given DownloadRequest. This method should only
+ * be used from outside classes if the DownloadRequest was created by the DownloadService to
+ * ensure that the data is valid. Use downloadFeed(), downloadImage() or downloadMedia() instead.
+ *
+ * @param context Context object for starting the DownloadService
+ * @param request The DownloadRequest. If another DownloadRequest with the same source URL is already stored, this method
+ * call will return false.
+ * @return True if the download request was accepted, false otherwise.
+ */
+ public boolean download(Context context, DownloadRequest request) {
+ Validate.notNull(context);
+ Validate.notNull(request);
+
+ if (downloads.containsKey(request.getSource())) {
+ if (BuildConfig.DEBUG) Log.i(TAG, "DownloadRequest is already stored.");
+ return false;
+ }
+ downloads.put(request.getSource(), request);
+
+ Intent launchIntent = new Intent(context, DownloadService.class);
+ launchIntent.putExtra(DownloadService.EXTRA_REQUEST, request);
+ context.startService(launchIntent);
+ EventDistributor.getInstance().sendDownloadQueuedBroadcast();
+ return true;
+ }
+
+ private void download(Context context, FeedFile item, File dest,
+ boolean overwriteIfExists, String username, String password, boolean deleteOnFailure) {
+ if (!isDownloadingFile(item)) {
+ if (!isFilenameAvailable(dest.toString()) || (deleteOnFailure && dest.exists())) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Filename already used.");
+ if (isFilenameAvailable(dest.toString()) && overwriteIfExists) {
+ boolean result = dest.delete();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Deleting file. Result: " + result);
+ } else {
+ // find different name
+ File newDest = null;
+ for (int i = 1; i < Integer.MAX_VALUE; i++) {
+ String newName = FilenameUtils.getBaseName(dest
+ .getName())
+ + "-"
+ + i
+ + FilenameUtils.EXTENSION_SEPARATOR
+ + FilenameUtils.getExtension(dest.getName());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Testing filename " + newName);
+ newDest = new File(dest.getParent(), newName);
+ if (!newDest.exists()
+ && isFilenameAvailable(newDest.toString())) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "File doesn't exist yet. Using "
+ + newName);
+ break;
+ }
+ }
+ if (newDest != null) {
+ dest = newDest;
+ }
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Requesting download of url " + item.getDownload_url());
+ item.setDownload_url(URLChecker.prepareURL(item.getDownload_url()));
+
+ DownloadRequest request = new DownloadRequest(dest.toString(),
+ URLChecker.prepareURL(item.getDownload_url()), item.getHumanReadableIdentifier(),
+ item.getId(), item.getTypeAsInt(), username, password, deleteOnFailure);
+
+ download(context, request);
+ } else {
+ Log.e(TAG, "URL " + item.getDownload_url()
+ + " is already being downloaded");
+ }
+ }
+
+ /**
+ * Returns true if a filename is available and false if it has already been
+ * taken by another requested download.
+ */
+ private boolean isFilenameAvailable(String path) {
+ for (String key : downloads.keySet()) {
+ DownloadRequest r = downloads.get(key);
+ if (StringUtils.equals(r.getDestination(), path)) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, path
+ + " is already used by another requested download");
+ return false;
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, path + " is available as a download destination");
+ return true;
+ }
+
+ public void downloadFeed(Context context, Feed feed)
+ throws DownloadRequestException {
+ if (feedFileValid(feed)) {
+ String username = (feed.getPreferences() != null) ? feed.getPreferences().getUsername() : null;
+ String password = (feed.getPreferences() != null) ? feed.getPreferences().getPassword() : null;
+
+ download(context, feed, new File(getFeedfilePath(context),
+ getFeedfileName(feed)), true, username, password, true);
+ }
+ }
+
+ public void downloadImage(Context context, FeedImage image)
+ throws DownloadRequestException {
+ if (feedFileValid(image)) {
+ download(context, image, new File(getImagefilePath(context),
+ getImagefileName(image)), false, null, null, false);
+ }
+ }
+
+ public void downloadMedia(Context context, FeedMedia feedmedia)
+ throws DownloadRequestException {
+ if (feedFileValid(feedmedia)) {
+ Feed feed = feedmedia.getItem().getFeed();
+ String username;
+ String password;
+ if (feed != null && feed.getPreferences() != null) {
+ username = feed.getPreferences().getUsername();
+ password = feed.getPreferences().getPassword();
+ } else {
+ username = null;
+ password = null;
+ }
+
+ File dest;
+ if (feedmedia.getFile_url() != null) {
+ dest = new File(feedmedia.getFile_url());
+ } else {
+ dest = new File(getMediafilePath(context, feedmedia),
+ getMediafilename(feedmedia));
+ }
+ download(context, feedmedia,
+ dest, false, username, password, false
+ );
+ }
+ }
+
+ /**
+ * Throws a DownloadRequestException if the feedfile or the download url of
+ * the feedfile is null.
+ *
+ * @throws DownloadRequestException
+ */
+ private boolean feedFileValid(FeedFile f) throws DownloadRequestException {
+ if (f == null) {
+ throw new DownloadRequestException("Feedfile was null");
+ } else if (f.getDownload_url() == null) {
+ throw new DownloadRequestException("File has no download URL");
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Cancels a running download.
+ */
+ public void cancelDownload(final Context context, final FeedFile f) {
+ cancelDownload(context, f.getDownload_url());
+ }
+
+ /**
+ * Cancels a running download.
+ */
+ public void cancelDownload(final Context context, final String downloadUrl) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Cancelling download with url " + downloadUrl);
+ Intent cancelIntent = new Intent(DownloadService.ACTION_CANCEL_DOWNLOAD);
+ cancelIntent.putExtra(DownloadService.EXTRA_DOWNLOAD_URL, downloadUrl);
+ context.sendBroadcast(cancelIntent);
+ }
+
+ /**
+ * Cancels all running downloads
+ */
+ public void cancelAllDownloads(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Cancelling all running downloads");
+ context.sendBroadcast(new Intent(
+ DownloadService.ACTION_CANCEL_ALL_DOWNLOADS));
+ }
+
+ /**
+ * Returns true if there is at least one Feed in the downloads queue.
+ */
+ public boolean isDownloadingFeeds() {
+ for (DownloadRequest r : downloads.values()) {
+ if (r.getFeedfileType() == Feed.FEEDFILETYPE_FEED) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if feedfile is in the downloads list
+ */
+ public boolean isDownloadingFile(FeedFile item) {
+ if (item.getDownload_url() != null) {
+ return downloads.containsKey(item.getDownload_url());
+ }
+ return false;
+ }
+
+ public DownloadRequest getDownload(String downloadUrl) {
+ return downloads.get(downloadUrl);
+ }
+
+ /**
+ * Checks if feedfile with the given download url is in the downloads list
+ */
+ public boolean isDownloadingFile(String downloadUrl) {
+ return downloads.get(downloadUrl) != null;
+ }
+
+ public boolean hasNoDownloads() {
+ return downloads.isEmpty();
+ }
+
+ /**
+ * Remove an object from the downloads-list of the requester.
+ */
+ public void removeDownload(DownloadRequest r) {
+ if (downloads.remove(r.getSource()) == null) {
+ Log.e(TAG,
+ "Could not remove object with url " + r.getSource());
+ }
+ }
+
+ /**
+ * Get the number of uncompleted Downloads
+ */
+ public int getNumberOfDownloads() {
+ return downloads.size();
+ }
+
+ public String getFeedfilePath(Context context)
+ throws DownloadRequestException {
+ return getExternalFilesDirOrThrowException(context, FEED_DOWNLOADPATH)
+ .toString() + "/";
+ }
+
+ public String getFeedfileName(Feed feed) {
+ String filename = feed.getDownload_url();
+ if (feed.getTitle() != null && !feed.getTitle().isEmpty()) {
+ filename = feed.getTitle();
+ }
+ return "feed-" + FileNameGenerator.generateFileName(filename);
+ }
+
+ public String getImagefilePath(Context context)
+ throws DownloadRequestException {
+ return getExternalFilesDirOrThrowException(context, IMAGE_DOWNLOADPATH)
+ .toString() + "/";
+ }
+
+ public String getImagefileName(FeedImage image) {
+ String filename = image.getDownload_url();
+ if (image.getOwner() != null && image.getOwner().getHumanReadableIdentifier() != null) {
+ filename = image.getOwner().getHumanReadableIdentifier();
+ }
+ return "image-" + FileNameGenerator.generateFileName(filename);
+ }
+
+ public String getMediafilePath(Context context, FeedMedia media)
+ throws DownloadRequestException {
+ File externalStorage = getExternalFilesDirOrThrowException(
+ context,
+ MEDIA_DOWNLOADPATH
+ + FileNameGenerator.generateFileName(media.getItem()
+ .getFeed().getTitle()) + "/"
+ );
+ return externalStorage.toString();
+ }
+
+ private File getExternalFilesDirOrThrowException(Context context,
+ String type) throws DownloadRequestException {
+ File result = UserPreferences.getDataFolder(context, type);
+ if (result == null) {
+ throw new DownloadRequestException(
+ "Failed to access external storage");
+ }
+ return result;
+ }
+
+ public String getMediafilename(FeedMedia media) {
+ String filename;
+ String titleBaseFilename = "";
+
+ // Try to generate the filename by the item title
+ if (media.getItem() != null && media.getItem().getTitle() != null) {
+ String title = media.getItem().getTitle();
+ // Delete reserved characters
+ titleBaseFilename = title.replaceAll("[\\\\/%\\?\\*:|<>\"\\p{Cntrl}]", "");
+ titleBaseFilename = titleBaseFilename.trim();
+ }
+
+ String URLBaseFilename = URLUtil.guessFileName(media.getDownload_url(),
+ null, media.getMime_type());
+ ;
+
+ if (titleBaseFilename != "") {
+ // Append extension
+ filename = titleBaseFilename + FilenameUtils.EXTENSION_SEPARATOR +
+ FilenameUtils.getExtension(URLBaseFilename);
+ } else {
+ // Fall back on URL file name
+ filename = URLBaseFilename;
+ }
+ return filename;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java b/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java
new file mode 100644
index 000000000..8cb040756
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/FeedItemStatistics.java
@@ -0,0 +1,70 @@
+package de.danoeh.antennapod.storage;
+
+import java.util.Date;
+
+/**
+ * Contains information about a feed's items.
+ */
+public class FeedItemStatistics {
+ private long feedID;
+ private int numberOfItems;
+ private int numberOfNewItems;
+ private int numberOfInProgressItems;
+ private Date lastUpdate;
+ private static final Date UNKNOWN_DATE = new Date(0);
+
+
+ /**
+ * Creates new FeedItemStatistics object.
+ *
+ * @param feedID ID of the feed.
+ * @param numberOfItems Number of items that this feed has.
+ * @param numberOfNewItems Number of unread items this feed has.
+ * @param numberOfInProgressItems Number of items that the user has started listening to.
+ * @param lastUpdate pubDate of the latest episode. A lastUpdate value of 0 will be interpreted as DATE_UNKOWN if
+ * numberOfItems is 0.
+ */
+ public FeedItemStatistics(long feedID, int numberOfItems, int numberOfNewItems, int numberOfInProgressItems, Date lastUpdate) {
+ this.feedID = feedID;
+ this.numberOfItems = numberOfItems;
+ this.numberOfNewItems = numberOfNewItems;
+ this.numberOfInProgressItems = numberOfInProgressItems;
+ if (numberOfItems > 0) {
+ this.lastUpdate = (lastUpdate != null) ? (Date) lastUpdate.clone() : null;
+ } else {
+ this.lastUpdate = UNKNOWN_DATE;
+ }
+ }
+
+ public long getFeedID() {
+ return feedID;
+ }
+
+ public int getNumberOfItems() {
+ return numberOfItems;
+ }
+
+ public int getNumberOfNewItems() {
+ return numberOfNewItems;
+ }
+
+ public int getNumberOfInProgressItems() {
+ return numberOfInProgressItems;
+ }
+
+ /**
+ * Returns the pubDate of the latest item in the feed. Users of this method
+ * should check if this value is unkown or not by calling lastUpdateKnown() first.
+ */
+ public Date getLastUpdate() {
+ return (lastUpdate != null) ? (Date) lastUpdate.clone() : null;
+ }
+
+ /**
+ * Returns true if the lastUpdate value is known. The lastUpdate value is unkown if the
+ * feed has no items.
+ */
+ public boolean lastUpdateKnown() {
+ return lastUpdate != UNKNOWN_DATE;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java b/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java
new file mode 100644
index 000000000..e7aa93f83
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/FeedSearcher.java
@@ -0,0 +1,57 @@
+package de.danoeh.antennapod.storage;
+
+import android.content.Context;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.SearchResult;
+import de.danoeh.antennapod.util.comparator.SearchResultValueComparator;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+/**
+ * Performs search on Feeds and FeedItems
+ */
+public class FeedSearcher {
+ private static final String TAG = "FeedSearcher";
+
+
+ /**
+ * Performs a search in all feeds or one specific feed.
+ */
+ public static List<SearchResult> performSearch(final Context context,
+ final String query, final long selectedFeed) {
+ final int values[] = {0, 0, 1, 2};
+ final String[] subtitles = {context.getString(R.string.found_in_shownotes_label),
+ context.getString(R.string.found_in_shownotes_label),
+ context.getString(R.string.found_in_chapters_label),
+ context.getString(R.string.found_in_title_label)};
+
+ List<SearchResult> result = new ArrayList<SearchResult>();
+
+ FutureTask<List<FeedItem>>[] tasks = new FutureTask[4];
+ (tasks[0] = DBTasks.searchFeedItemContentEncoded(context, selectedFeed, query)).run();
+ (tasks[1] = DBTasks.searchFeedItemDescription(context, selectedFeed, query)).run();
+ (tasks[2] = DBTasks.searchFeedItemChapters(context, selectedFeed, query)).run();
+ (tasks[3] = DBTasks.searchFeedItemTitle(context, selectedFeed, query)).run();
+ try {
+ for (int i = 0; i < tasks.length; i++) {
+ FutureTask task = tasks[i];
+ List<FeedItem> items = (List<FeedItem>) task.get();
+ for (FeedItem item : items) {
+ result.add(new SearchResult(item, values[i], subtitles[i]));
+ }
+
+ }
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ e.printStackTrace();
+ }
+ Collections.sort(result, new SearchResultValueComparator());
+ return result;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java b/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java
new file mode 100644
index 000000000..671ac30d5
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/storage/PodDBAdapter.java
@@ -0,0 +1,1391 @@
+package de.danoeh.antennapod.storage;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.MergeCursor;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDatabase.CursorFactory;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.util.Log;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.Arrays;
+import java.util.List;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedComponent;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.FeedPreferences;
+import de.danoeh.antennapod.service.download.DownloadStatus;
+import de.danoeh.antennapod.util.flattr.FlattrStatus;
+
+// TODO Remove media column from feeditem table
+
+/**
+ * Implements methods for accessing the database
+ */
+public class PodDBAdapter {
+ private static final String TAG = "PodDBAdapter";
+ private static final int DATABASE_VERSION = 12;
+ public static final String DATABASE_NAME = "Antennapod.db";
+
+ /**
+ * Maximum number of arguments for IN-operator.
+ */
+ public static final int IN_OPERATOR_MAXIMUM = 800;
+
+ /**
+ * Maximum number of entries per search request.
+ */
+ public static final int SEARCH_LIMIT = 30;
+
+ // ----------- Column indices
+ // ----------- General indices
+ public static final int KEY_ID_INDEX = 0;
+ public static final int KEY_TITLE_INDEX = 1;
+ public static final int KEY_FILE_URL_INDEX = 2;
+ public static final int KEY_DOWNLOAD_URL_INDEX = 3;
+ public static final int KEY_DOWNLOADED_INDEX = 4;
+ public static final int KEY_LINK_INDEX = 5;
+ public static final int KEY_DESCRIPTION_INDEX = 6;
+ public static final int KEY_PAYMENT_LINK_INDEX = 7;
+ // ----------- Feed indices
+ public static final int KEY_LAST_UPDATE_INDEX = 8;
+ public static final int KEY_LANGUAGE_INDEX = 9;
+ public static final int KEY_AUTHOR_INDEX = 10;
+ public static final int KEY_IMAGE_INDEX = 11;
+ public static final int KEY_TYPE_INDEX = 12;
+ public static final int KEY_FEED_IDENTIFIER_INDEX = 13;
+ public static final int KEY_FEED_FLATTR_STATUS_INDEX = 14;
+ public static final int KEY_FEED_USERNAME_INDEX = 15;
+ public static final int KEY_FEED_PASSWORD_INDEX = 16;
+ // ----------- FeedItem indices
+ public static final int KEY_CONTENT_ENCODED_INDEX = 2;
+ public static final int KEY_PUBDATE_INDEX = 3;
+ public static final int KEY_READ_INDEX = 4;
+ public static final int KEY_MEDIA_INDEX = 8;
+ public static final int KEY_FEED_INDEX = 9;
+ public static final int KEY_HAS_SIMPLECHAPTERS_INDEX = 10;
+ public static final int KEY_ITEM_IDENTIFIER_INDEX = 11;
+ public static final int KEY_ITEM_FLATTR_STATUS_INDEX = 12;
+ // ---------- FeedMedia indices
+ public static final int KEY_DURATION_INDEX = 1;
+ public static final int KEY_POSITION_INDEX = 5;
+ public static final int KEY_SIZE_INDEX = 6;
+ public static final int KEY_MIME_TYPE_INDEX = 7;
+ public static final int KEY_PLAYBACK_COMPLETION_DATE_INDEX = 8;
+ public static final int KEY_MEDIA_FEEDITEM_INDEX = 9;
+ public static final int KEY_PLAYED_DURATION_INDEX = 10;
+ // --------- Download log indices
+ public static final int KEY_FEEDFILE_INDEX = 1;
+ public static final int KEY_FEEDFILETYPE_INDEX = 2;
+ public static final int KEY_REASON_INDEX = 3;
+ public static final int KEY_SUCCESSFUL_INDEX = 4;
+ public static final int KEY_COMPLETION_DATE_INDEX = 5;
+ public static final int KEY_REASON_DETAILED_INDEX = 6;
+ public static final int KEY_DOWNLOADSTATUS_TITLE_INDEX = 7;
+ // --------- Queue indices
+ public static final int KEY_FEEDITEM_INDEX = 1;
+ public static final int KEY_QUEUE_FEED_INDEX = 2;
+ // --------- Chapters indices
+ public static final int KEY_CHAPTER_START_INDEX = 2;
+ public static final int KEY_CHAPTER_FEEDITEM_INDEX = 3;
+ public static final int KEY_CHAPTER_LINK_INDEX = 4;
+ public static final int KEY_CHAPTER_TYPE_INDEX = 5;
+
+ // Key-constants
+ public static final String KEY_ID = "id";
+ public static final String KEY_TITLE = "title";
+ public static final String KEY_NAME = "name";
+ public static final String KEY_LINK = "link";
+ public static final String KEY_DESCRIPTION = "description";
+ public static final String KEY_FILE_URL = "file_url";
+ public static final String KEY_DOWNLOAD_URL = "download_url";
+ public static final String KEY_PUBDATE = "pubDate";
+ public static final String KEY_READ = "read";
+ public static final String KEY_DURATION = "duration";
+ public static final String KEY_POSITION = "position";
+ public static final String KEY_SIZE = "filesize";
+ public static final String KEY_MIME_TYPE = "mime_type";
+ public static final String KEY_IMAGE = "image";
+ public static final String KEY_FEED = "feed";
+ public static final String KEY_MEDIA = "media";
+ public static final String KEY_DOWNLOADED = "downloaded";
+ public static final String KEY_LASTUPDATE = "last_update";
+ public static final String KEY_FEEDFILE = "feedfile";
+ public static final String KEY_REASON = "reason";
+ public static final String KEY_SUCCESSFUL = "successful";
+ public static final String KEY_FEEDFILETYPE = "feedfile_type";
+ public static final String KEY_COMPLETION_DATE = "completion_date";
+ public static final String KEY_FEEDITEM = "feeditem";
+ public static final String KEY_CONTENT_ENCODED = "content_encoded";
+ public static final String KEY_PAYMENT_LINK = "payment_link";
+ public static final String KEY_START = "start";
+ public static final String KEY_LANGUAGE = "language";
+ public static final String KEY_AUTHOR = "author";
+ public static final String KEY_HAS_CHAPTERS = "has_simple_chapters";
+ public static final String KEY_TYPE = "type";
+ public static final String KEY_ITEM_IDENTIFIER = "item_identifier";
+ public static final String KEY_FLATTR_STATUS = "flattr_status";
+ public static final String KEY_FEED_IDENTIFIER = "feed_identifier";
+ public static final String KEY_REASON_DETAILED = "reason_detailed";
+ public static final String KEY_DOWNLOADSTATUS_TITLE = "title";
+ public static final String KEY_CHAPTER_TYPE = "type";
+ public static final String KEY_PLAYBACK_COMPLETION_DATE = "playback_completion_date";
+ public static final String KEY_AUTO_DOWNLOAD = "auto_download";
+ public static final String KEY_PLAYED_DURATION = "played_duration";
+ public static final String KEY_USERNAME = "username";
+ public static final String KEY_PASSWORD = "password";
+
+ // Table names
+ public static final String TABLE_NAME_FEEDS = "Feeds";
+ public static final String TABLE_NAME_FEED_ITEMS = "FeedItems";
+ public static final String TABLE_NAME_FEED_IMAGES = "FeedImages";
+ public static final String TABLE_NAME_FEED_MEDIA = "FeedMedia";
+ public static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog";
+ public static final String TABLE_NAME_QUEUE = "Queue";
+ public static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters";
+
+ // SQL Statements for creating new tables
+ private static final String TABLE_PRIMARY_KEY = KEY_ID
+ + " INTEGER PRIMARY KEY AUTOINCREMENT ,";
+
+ private static final String CREATE_TABLE_FEEDS = "CREATE TABLE "
+ + TABLE_NAME_FEEDS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE
+ + " TEXT," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL + " TEXT,"
+ + KEY_DOWNLOADED + " INTEGER," + KEY_LINK + " TEXT,"
+ + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT,"
+ + KEY_LASTUPDATE + " TEXT," + KEY_LANGUAGE + " TEXT," + KEY_AUTHOR
+ + " TEXT," + KEY_IMAGE + " INTEGER," + KEY_TYPE + " TEXT,"
+ + KEY_FEED_IDENTIFIER + " TEXT," + KEY_AUTO_DOWNLOAD + " INTEGER DEFAULT 1,"
+ + KEY_FLATTR_STATUS + " INTEGER,"
+ + KEY_USERNAME + " TEXT,"
+ + KEY_PASSWORD + " TEXT)";
+
+ private static final String CREATE_TABLE_FEED_ITEMS = "CREATE TABLE "
+ + TABLE_NAME_FEED_ITEMS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE
+ + " TEXT," + KEY_CONTENT_ENCODED + " TEXT," + KEY_PUBDATE
+ + " INTEGER," + KEY_READ + " INTEGER," + KEY_LINK + " TEXT,"
+ + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT,"
+ + KEY_MEDIA + " INTEGER," + KEY_FEED + " INTEGER,"
+ + KEY_HAS_CHAPTERS + " INTEGER," + KEY_ITEM_IDENTIFIER + " TEXT,"
+ + KEY_FLATTR_STATUS + " INTEGER,"
+ + KEY_IMAGE + " INTEGER)";
+
+ private static final String CREATE_TABLE_FEED_IMAGES = "CREATE TABLE "
+ + TABLE_NAME_FEED_IMAGES + " (" + TABLE_PRIMARY_KEY + KEY_TITLE
+ + " TEXT," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL + " TEXT,"
+ + KEY_DOWNLOADED + " INTEGER)";
+
+ private static final String CREATE_TABLE_FEED_MEDIA = "CREATE TABLE "
+ + TABLE_NAME_FEED_MEDIA + " (" + TABLE_PRIMARY_KEY + KEY_DURATION
+ + " INTEGER," + KEY_FILE_URL + " TEXT," + KEY_DOWNLOAD_URL
+ + " TEXT," + KEY_DOWNLOADED + " INTEGER," + KEY_POSITION
+ + " INTEGER," + KEY_SIZE + " INTEGER," + KEY_MIME_TYPE + " TEXT,"
+ + KEY_PLAYBACK_COMPLETION_DATE + " INTEGER,"
+ + KEY_FEEDITEM + " INTEGER,"
+ + KEY_PLAYED_DURATION + " INTEGER)";
+
+ private static final String CREATE_TABLE_DOWNLOAD_LOG = "CREATE TABLE "
+ + TABLE_NAME_DOWNLOAD_LOG + " (" + TABLE_PRIMARY_KEY + KEY_FEEDFILE
+ + " INTEGER," + KEY_FEEDFILETYPE + " INTEGER," + KEY_REASON
+ + " INTEGER," + KEY_SUCCESSFUL + " INTEGER," + KEY_COMPLETION_DATE
+ + " INTEGER," + KEY_REASON_DETAILED + " TEXT,"
+ + KEY_DOWNLOADSTATUS_TITLE + " TEXT)";
+
+ private static final String CREATE_TABLE_QUEUE = "CREATE TABLE "
+ + TABLE_NAME_QUEUE + "(" + KEY_ID + " INTEGER PRIMARY KEY,"
+ + KEY_FEEDITEM + " INTEGER," + KEY_FEED + " INTEGER)";
+
+ private static final String CREATE_TABLE_SIMPLECHAPTERS = "CREATE TABLE "
+ + TABLE_NAME_SIMPLECHAPTERS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE
+ + " TEXT," + KEY_START + " INTEGER," + KEY_FEEDITEM + " INTEGER,"
+ + KEY_LINK + " TEXT," + KEY_CHAPTER_TYPE + " INTEGER)";
+
+ private SQLiteDatabase db;
+ private final Context context;
+ private PodDBHelper helper;
+
+ /**
+ * Select all columns from the feed-table
+ */
+ private static final String[] FEED_SEL_STD = {
+ TABLE_NAME_FEEDS + "." + KEY_ID,
+ TABLE_NAME_FEEDS + "." + KEY_TITLE,
+ TABLE_NAME_FEEDS + "." + KEY_FILE_URL,
+ TABLE_NAME_FEEDS + "." + KEY_DOWNLOAD_URL,
+ TABLE_NAME_FEEDS + "." + KEY_DOWNLOADED,
+ TABLE_NAME_FEEDS + "." + KEY_LINK,
+ TABLE_NAME_FEEDS + "." + KEY_DESCRIPTION,
+ TABLE_NAME_FEEDS + "." + KEY_PAYMENT_LINK,
+ TABLE_NAME_FEEDS + "." + KEY_LASTUPDATE,
+ TABLE_NAME_FEEDS + "." + KEY_LANGUAGE,
+ TABLE_NAME_FEEDS + "." + KEY_AUTHOR,
+ TABLE_NAME_FEEDS + "." + KEY_IMAGE,
+ TABLE_NAME_FEEDS + "." + KEY_TYPE,
+ TABLE_NAME_FEEDS + "." + KEY_FEED_IDENTIFIER,
+ TABLE_NAME_FEEDS + "." + KEY_AUTO_DOWNLOAD,
+ TABLE_NAME_FEEDS + "." + KEY_FLATTR_STATUS,
+ TABLE_NAME_FEEDS + "." + KEY_USERNAME,
+ TABLE_NAME_FEEDS + "." + KEY_PASSWORD
+ };
+
+ // column indices for FEED_SEL_STD
+ public static final int IDX_FEED_SEL_STD_ID = 0;
+ public static final int IDX_FEED_SEL_STD_TITLE = 1;
+ public static final int IDX_FEED_SEL_STD_FILE_URL = 2;
+ public static final int IDX_FEED_SEL_STD_DOWNLOAD_URL = 3;
+ public static final int IDX_FEED_SEL_STD_DOWNLOADED = 4;
+ public static final int IDX_FEED_SEL_STD_LINK = 5;
+ public static final int IDX_FEED_SEL_STD_DESCRIPTION = 6;
+ public static final int IDX_FEED_SEL_STD_PAYMENT_LINK = 7;
+ public static final int IDX_FEED_SEL_STD_LASTUPDATE = 8;
+ public static final int IDX_FEED_SEL_STD_LANGUAGE = 9;
+ public static final int IDX_FEED_SEL_STD_AUTHOR = 10;
+ public static final int IDX_FEED_SEL_STD_IMAGE = 11;
+ public static final int IDX_FEED_SEL_STD_TYPE = 12;
+ public static final int IDX_FEED_SEL_STD_FEED_IDENTIFIER = 13;
+ public static final int IDX_FEED_SEL_PREFERENCES_AUTO_DOWNLOAD = 14;
+ public static final int IDX_FEED_SEL_STD_FLATTR_STATUS = 15;
+ public static final int IDX_FEED_SEL_PREFERENCES_USERNAME = 16;
+ public static final int IDX_FEED_SEL_PREFERENCES_PASSWORD = 17;
+
+
+ /**
+ * Select all columns from the feeditems-table except description and
+ * content-encoded.
+ */
+ private static final String[] FEEDITEM_SEL_FI_SMALL = {
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ID,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_TITLE,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PUBDATE,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_READ,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_LINK,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PAYMENT_LINK, KEY_MEDIA,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_FEED,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_HAS_CHAPTERS,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ITEM_IDENTIFIER,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_FLATTR_STATUS,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_IMAGE};
+
+ /**
+ * Contains FEEDITEM_SEL_FI_SMALL as comma-separated list. Useful for raw queries.
+ */
+ private static final String SEL_FI_SMALL_STR;
+
+ static {
+ String selFiSmall = Arrays.toString(FEEDITEM_SEL_FI_SMALL);
+ SEL_FI_SMALL_STR = selFiSmall.substring(1, selFiSmall.length() - 1);
+ }
+
+ // column indices for FEEDITEM_SEL_FI_SMALL
+
+ public static final int IDX_FI_SMALL_ID = 0;
+ public static final int IDX_FI_SMALL_TITLE = 1;
+ public static final int IDX_FI_SMALL_PUBDATE = 2;
+ public static final int IDX_FI_SMALL_READ = 3;
+ public static final int IDX_FI_SMALL_LINK = 4;
+ public static final int IDX_FI_SMALL_PAYMENT_LINK = 5;
+ public static final int IDX_FI_SMALL_MEDIA = 6;
+ public static final int IDX_FI_SMALL_FEED = 7;
+ public static final int IDX_FI_SMALL_HAS_CHAPTERS = 8;
+ public static final int IDX_FI_SMALL_ITEM_IDENTIFIER = 9;
+ public static final int IDX_FI_SMALL_FLATTR_STATUS = 10;
+ public static final int IDX_FI_SMALL_IMAGE = 11;
+
+ /**
+ * Select id, description and content-encoded column from feeditems.
+ */
+ private static final String[] SEL_FI_EXTRA = {KEY_ID, KEY_DESCRIPTION,
+ KEY_CONTENT_ENCODED, KEY_FEED};
+
+ // column indices for SEL_FI_EXTRA
+
+ public static final int IDX_FI_EXTRA_ID = 0;
+ public static final int IDX_FI_EXTRA_DESCRIPTION = 1;
+ public static final int IDX_FI_EXTRA_CONTENT_ENCODED = 2;
+ public static final int IDX_FI_EXTRA_FEED = 3;
+
+ static PodDBHelper dbHelperSingleton;
+
+ private static synchronized PodDBHelper getDbHelperSingleton(Context appContext) {
+ if (dbHelperSingleton == null) {
+ dbHelperSingleton = new PodDBHelper(appContext, DATABASE_NAME, null, DATABASE_VERSION);
+ }
+ return dbHelperSingleton;
+ }
+
+ public PodDBAdapter(Context c) {
+ this.context = c;
+ helper = getDbHelperSingleton(c.getApplicationContext());
+ }
+
+ public PodDBAdapter open() {
+ if (db == null || !db.isOpen() || db.isReadOnly()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Opening DB");
+ try {
+ db = helper.getWritableDatabase();
+ } catch (SQLException ex) {
+ ex.printStackTrace();
+ db = helper.getReadableDatabase();
+ }
+ }
+ return this;
+ }
+
+ public void close() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Closing DB");
+ //db.close();
+ }
+
+ public static boolean deleteDatabase(Context context) {
+ Log.w(TAG, "Deleting database");
+ dbHelperSingleton.close();
+ dbHelperSingleton = null;
+ return context.deleteDatabase(DATABASE_NAME);
+ }
+
+ /**
+ * Inserts or updates a feed entry
+ *
+ * @return the id of the entry
+ */
+ public long setFeed(Feed feed) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_TITLE, feed.getTitle());
+ values.put(KEY_LINK, feed.getLink());
+ values.put(KEY_DESCRIPTION, feed.getDescription());
+ values.put(KEY_PAYMENT_LINK, feed.getPaymentLink());
+ values.put(KEY_AUTHOR, feed.getAuthor());
+ values.put(KEY_LANGUAGE, feed.getLanguage());
+ if (feed.getImage() != null) {
+ if (feed.getImage().getId() == 0) {
+ setImage(feed.getImage());
+ }
+ values.put(KEY_IMAGE, feed.getImage().getId());
+ }
+
+ values.put(KEY_FILE_URL, feed.getFile_url());
+ values.put(KEY_DOWNLOAD_URL, feed.getDownload_url());
+ values.put(KEY_DOWNLOADED, feed.isDownloaded());
+ values.put(KEY_LASTUPDATE, feed.getLastUpdate().getTime());
+ values.put(KEY_TYPE, feed.getType());
+ values.put(KEY_FEED_IDENTIFIER, feed.getFeedIdentifier());
+
+ Log.d(TAG, "Setting feed with flattr status " + feed.getTitle() + ": " + feed.getFlattrStatus().toLong());
+
+ values.put(KEY_FLATTR_STATUS, feed.getFlattrStatus().toLong());
+ if (feed.getId() == 0) {
+ // Create new entry
+ if (BuildConfig.DEBUG)
+ Log.d(this.toString(), "Inserting new Feed into db");
+ feed.setId(db.insert(TABLE_NAME_FEEDS, null, values));
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(this.toString(), "Updating existing Feed in db");
+ db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?",
+ new String[]{String.valueOf(feed.getId())});
+
+ }
+ return feed.getId();
+ }
+
+ public void setFeedPreferences(FeedPreferences prefs) {
+ if (prefs.getFeedID() == 0) {
+ throw new IllegalArgumentException("Feed ID of preference must not be null");
+ }
+ ContentValues values = new ContentValues();
+ values.put(KEY_AUTO_DOWNLOAD, prefs.getAutoDownload());
+ values.put(KEY_USERNAME, prefs.getUsername());
+ values.put(KEY_PASSWORD, prefs.getPassword());
+ db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(prefs.getFeedID())});
+ }
+
+ /**
+ * Inserts or updates an image entry
+ *
+ * @return the id of the entry
+ */
+ public long setImage(FeedImage image) {
+ db.beginTransaction();
+ ContentValues values = new ContentValues();
+ values.put(KEY_TITLE, image.getTitle());
+ values.put(KEY_DOWNLOAD_URL, image.getDownload_url());
+ values.put(KEY_DOWNLOADED, image.isDownloaded());
+ values.put(KEY_FILE_URL, image.getFile_url());
+ if (image.getId() == 0) {
+ image.setId(db.insert(TABLE_NAME_FEED_IMAGES, null, values));
+ } else {
+ db.update(TABLE_NAME_FEED_IMAGES, values, KEY_ID + "=?",
+ new String[]{String.valueOf(image.getId())});
+ }
+
+ final FeedComponent owner = image.getOwner();
+ if (owner != null && owner.getId() != 0) {
+ values.clear();
+ values.put(KEY_IMAGE, image.getId());
+ if (owner instanceof Feed) {
+ db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(image.getOwner().getId())});
+ }
+ }
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ return image.getId();
+ }
+
+ /**
+ * Inserts or updates an image entry
+ *
+ * @return the id of the entry
+ */
+ public long setMedia(FeedMedia media) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_DURATION, media.getDuration());
+ values.put(KEY_POSITION, media.getPosition());
+ values.put(KEY_SIZE, media.getSize());
+ values.put(KEY_MIME_TYPE, media.getMime_type());
+ values.put(KEY_DOWNLOAD_URL, media.getDownload_url());
+ values.put(KEY_DOWNLOADED, media.isDownloaded());
+ values.put(KEY_FILE_URL, media.getFile_url());
+
+ if (media.getPlaybackCompletionDate() != null) {
+ values.put(KEY_PLAYBACK_COMPLETION_DATE, media
+ .getPlaybackCompletionDate().getTime());
+ } else {
+ values.put(KEY_PLAYBACK_COMPLETION_DATE, 0);
+ }
+ if (media.getItem() != null) {
+ values.put(KEY_FEEDITEM, media.getItem().getId());
+ }
+ if (media.getId() == 0) {
+ media.setId(db.insert(TABLE_NAME_FEED_MEDIA, null, values));
+ } else {
+ db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?",
+ new String[]{String.valueOf(media.getId())});
+ }
+ return media.getId();
+ }
+
+ public void setFeedMediaPlaybackInformation(FeedMedia media) {
+ if (media.getId() != 0) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_POSITION, media.getPosition());
+ values.put(KEY_DURATION, media.getDuration());
+ values.put(KEY_PLAYED_DURATION, media.getPlayedDuration());
+ db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?",
+ new String[]{String.valueOf(media.getId())});
+ } else {
+ Log.e(TAG, "setFeedMediaPlaybackInformation: ID of media was 0");
+ }
+ }
+
+ public void setFeedMediaPlaybackCompletionDate(FeedMedia media) {
+ if (media.getId() != 0) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_PLAYBACK_COMPLETION_DATE, media.getPlaybackCompletionDate().getTime());
+ values.put(KEY_PLAYED_DURATION, media.getPlayedDuration());
+ db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?",
+ new String[]{String.valueOf(media.getId())});
+ } else {
+ Log.e(TAG, "setFeedMediaPlaybackCompletionDate: ID of media was 0");
+ }
+ }
+
+ /**
+ * Insert all FeedItems of a feed and the feed object itself in a single
+ * transaction
+ */
+ public void setCompleteFeed(Feed... feeds) {
+ db.beginTransaction();
+ for (Feed feed : feeds) {
+ setFeed(feed);
+ if (feed.getItems() != null) {
+ for (FeedItem item : feed.getItems()) {
+ setFeedItem(item, false);
+ }
+ }
+ if (feed.getPreferences() != null) {
+ setFeedPreferences(feed.getPreferences());
+ }
+ }
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ /**
+ * Update the flattr status of a feed
+ */
+ public void setFeedFlattrStatus(Feed feed) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_FLATTR_STATUS, feed.getFlattrStatus().toLong());
+ db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(feed.getId())});
+ }
+
+ /**
+ * Get all feeds in the flattr queue.
+ */
+ public Cursor getFeedsInFlattrQueueCursor() {
+ return db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_FLATTR_STATUS + "=?",
+ new String[]{String.valueOf(FlattrStatus.STATUS_QUEUE)}, null, null, null);
+ }
+
+ /**
+ * Get all feed items in the flattr queue.
+ */
+ public Cursor getFeedItemsInFlattrQueueCursor() {
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FLATTR_STATUS + "=?",
+ new String[]{String.valueOf(FlattrStatus.STATUS_QUEUE)}, null, null, null);
+ }
+
+ /**
+ * Counts feeds and feed items in the flattr queue
+ */
+ public int getFlattrQueueSize() {
+ int res = 0;
+ Cursor c = db.rawQuery(String.format("SELECT count(*) FROM %s WHERE %s=%s",
+ TABLE_NAME_FEEDS, KEY_FLATTR_STATUS, String.valueOf(FlattrStatus.STATUS_QUEUE)), null);
+ if (c.moveToFirst()) {
+ res = c.getInt(0);
+ c.close();
+ } else {
+ Log.e(TAG, "Unable to determine size of flattr queue: Could not count number of feeds");
+ }
+ c = db.rawQuery(String.format("SELECT count(*) FROM %s WHERE %s=%s",
+ TABLE_NAME_FEED_ITEMS, KEY_FLATTR_STATUS, String.valueOf(FlattrStatus.STATUS_QUEUE)), null);
+ if (c.moveToFirst()) {
+ res += c.getInt(0);
+ c.close();
+ } else {
+ Log.e(TAG, "Unable to determine size of flattr queue: Could not count number of feed items");
+ }
+
+ return res;
+ }
+
+ /**
+ * Updates the download URL of a Feed.
+ */
+ public void setFeedDownloadUrl(String original, String updated) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_DOWNLOAD_URL, updated);
+ db.update(TABLE_NAME_FEEDS, values, KEY_DOWNLOAD_URL + "=?", new String[]{original});
+ }
+
+ public void setFeedItemlist(List<FeedItem> items) {
+ db.beginTransaction();
+ for (FeedItem item : items) {
+ setFeedItem(item, true);
+ }
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public long setSingleFeedItem(FeedItem item) {
+ db.beginTransaction();
+ long result = setFeedItem(item, true);
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ return result;
+ }
+
+ /**
+ * Update the flattr status of a FeedItem
+ */
+ public void setFeedItemFlattrStatus(FeedItem feedItem) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_FLATTR_STATUS, feedItem.getFlattrStatus().toLong());
+ db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(feedItem.getId())});
+ }
+
+ /**
+ * Update the flattr status of a feed or feed item specified by its payment link
+ * and the new flattr status to use
+ */
+ public void setItemFlattrStatus(String url, FlattrStatus status) {
+ //Log.d(TAG, "setItemFlattrStatus(" + url + ") = " + status.toString());
+ ContentValues values = new ContentValues();
+ values.put(KEY_FLATTR_STATUS, status.toLong());
+
+ // regexps in sqlite would be neat!
+ String[] query_urls = new String[]{
+ "*" + url + "&*",
+ "*" + url + "%2F&*",
+ "*" + url + "",
+ "*" + url + "%2F"
+ };
+
+ if (db.update(TABLE_NAME_FEEDS, values,
+ KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?", query_urls
+ ) > 0) {
+ Log.i(TAG, "setItemFlattrStatus found match for " + url + " = " + status.toLong() + " in Feeds table");
+ return;
+ }
+ if (db.update(TABLE_NAME_FEED_ITEMS, values,
+ KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?"
+ + " OR " + KEY_PAYMENT_LINK + " GLOB ?", query_urls
+ ) > 0) {
+ Log.i(TAG, "setItemFlattrStatus found match for " + url + " = " + status.toLong() + " in FeedsItems table");
+ }
+ }
+
+ /**
+ * Reset flattr status to unflattrd for all items
+ */
+ public void clearAllFlattrStatus() {
+ ContentValues values = new ContentValues();
+ values.put(KEY_FLATTR_STATUS, 0);
+ db.update(TABLE_NAME_FEEDS, values, null, null);
+ db.update(TABLE_NAME_FEED_ITEMS, values, null, null);
+ }
+
+ /**
+ * Inserts or updates a feeditem entry
+ *
+ * @param item The FeedItem
+ * @param saveFeed true if the Feed of the item should also be saved. This should be set to
+ * false if the method is executed on a list of FeedItems of the same Feed.
+ * @return the id of the entry
+ */
+ private long setFeedItem(FeedItem item, boolean saveFeed) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_TITLE, item.getTitle());
+ values.put(KEY_LINK, item.getLink());
+ if (item.getDescription() != null) {
+ values.put(KEY_DESCRIPTION, item.getDescription());
+ }
+ if (item.getContentEncoded() != null) {
+ values.put(KEY_CONTENT_ENCODED, item.getContentEncoded());
+ }
+ values.put(KEY_PUBDATE, item.getPubDate().getTime());
+ values.put(KEY_PAYMENT_LINK, item.getPaymentLink());
+ if (saveFeed && item.getFeed() != null) {
+ setFeed(item.getFeed());
+ }
+ values.put(KEY_FEED, item.getFeed().getId());
+ values.put(KEY_READ, item.isRead());
+ values.put(KEY_HAS_CHAPTERS, item.getChapters() != null);
+ values.put(KEY_ITEM_IDENTIFIER, item.getItemIdentifier());
+ values.put(KEY_FLATTR_STATUS, item.getFlattrStatus().toLong());
+ if (item.hasItemImage()) {
+ if (item.getImage().getId() == 0) {
+ setImage(item.getImage());
+ }
+ values.put(KEY_IMAGE, item.getImage().getId());
+ }
+
+ if (item.getId() == 0) {
+ item.setId(db.insert(TABLE_NAME_FEED_ITEMS, null, values));
+ } else {
+ db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?",
+ new String[]{String.valueOf(item.getId())});
+ }
+ if (item.getMedia() != null) {
+ setMedia(item.getMedia());
+ }
+ if (item.getChapters() != null) {
+ setChapters(item);
+ }
+ return item.getId();
+ }
+
+ public void setFeedItemRead(boolean read, long itemId, long mediaId,
+ boolean resetMediaPosition) {
+ db.beginTransaction();
+ ContentValues values = new ContentValues();
+
+ values.put(KEY_READ, read);
+ db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(itemId)});
+
+ if (resetMediaPosition) {
+ values.clear();
+ values.put(KEY_POSITION, 0);
+ db.update(TABLE_NAME_FEED_MEDIA, values, KEY_ID + "=?", new String[]{String.valueOf(mediaId)});
+ }
+
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public void setFeedItemRead(boolean read, long... itemIds) {
+ db.beginTransaction();
+ ContentValues values = new ContentValues();
+ for (long id : itemIds) {
+ values.clear();
+ values.put(KEY_READ, read);
+ db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", new String[]{String.valueOf(id)});
+ }
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public void setChapters(FeedItem item) {
+ ContentValues values = new ContentValues();
+ for (Chapter chapter : item.getChapters()) {
+ values.put(KEY_TITLE, chapter.getTitle());
+ values.put(KEY_START, chapter.getStart());
+ values.put(KEY_FEEDITEM, item.getId());
+ values.put(KEY_LINK, chapter.getLink());
+ values.put(KEY_CHAPTER_TYPE, chapter.getChapterType());
+ if (chapter.getId() == 0) {
+ chapter.setId(db
+ .insert(TABLE_NAME_SIMPLECHAPTERS, null, values));
+ } else {
+ db.update(TABLE_NAME_SIMPLECHAPTERS, values, KEY_ID + "=?",
+ new String[]{String.valueOf(chapter.getId())});
+ }
+ }
+ }
+
+ /**
+ * Inserts or updates a download status.
+ */
+ public long setDownloadStatus(DownloadStatus status) {
+ ContentValues values = new ContentValues();
+ values.put(KEY_FEEDFILE, status.getFeedfileId());
+ values.put(KEY_FEEDFILETYPE, status.getFeedfileType());
+ values.put(KEY_REASON, status.getReason().getCode());
+ values.put(KEY_SUCCESSFUL, status.isSuccessful());
+ values.put(KEY_COMPLETION_DATE, status.getCompletionDate().getTime());
+ values.put(KEY_REASON_DETAILED, status.getReasonDetailed());
+ values.put(KEY_DOWNLOADSTATUS_TITLE, status.getTitle());
+ if (status.getId() == 0) {
+ status.setId(db.insert(TABLE_NAME_DOWNLOAD_LOG, null, values));
+ } else {
+ db.update(TABLE_NAME_DOWNLOAD_LOG, values, KEY_ID + "=?",
+ new String[]{String.valueOf(status.getId())});
+ }
+ return status.getId();
+ }
+
+ public long getDownloadLogSize() {
+ final String query = String.format("SELECT COUNT(%s) FROM %s", KEY_ID, TABLE_NAME_DOWNLOAD_LOG);
+ Cursor result = db.rawQuery(query, null);
+ long count = 0;
+ if (result.moveToFirst()) {
+ count = result.getLong(0);
+ }
+ result.close();
+ return count;
+ }
+
+ public void removeDownloadLogItems(long count) {
+ if (count > 0) {
+ final String sql = String.format("DELETE FROM %s WHERE %s in (SELECT %s from %s ORDER BY %s ASC LIMIT %d)",
+ TABLE_NAME_DOWNLOAD_LOG, KEY_ID, KEY_ID, TABLE_NAME_DOWNLOAD_LOG, KEY_COMPLETION_DATE, count);
+ db.execSQL(sql, null);
+ }
+ }
+
+ public void setQueue(List<FeedItem> queue) {
+ ContentValues values = new ContentValues();
+ db.beginTransaction();
+ db.delete(TABLE_NAME_QUEUE, null, null);
+ for (int i = 0; i < queue.size(); i++) {
+ FeedItem item = queue.get(i);
+ values.put(KEY_ID, i);
+ values.put(KEY_FEEDITEM, item.getId());
+ values.put(KEY_FEED, item.getFeed().getId());
+ db.insertWithOnConflict(TABLE_NAME_QUEUE, null, values,
+ SQLiteDatabase.CONFLICT_REPLACE);
+ }
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public void clearQueue() {
+ db.delete(TABLE_NAME_QUEUE, null, null);
+ }
+
+ public void removeFeedMedia(FeedMedia media) {
+ db.delete(TABLE_NAME_FEED_MEDIA, KEY_ID + "=?",
+ new String[]{String.valueOf(media.getId())});
+ }
+
+ public void removeChaptersOfItem(FeedItem item) {
+ db.delete(TABLE_NAME_SIMPLECHAPTERS, KEY_FEEDITEM + "=?",
+ new String[]{String.valueOf(item.getId())});
+ }
+
+ public void removeFeedImage(FeedImage image) {
+ db.delete(TABLE_NAME_FEED_IMAGES, KEY_ID + "=?",
+ new String[]{String.valueOf(image.getId())});
+ }
+
+ /**
+ * Remove a FeedItem and its FeedMedia entry.
+ */
+ public void removeFeedItem(FeedItem item) {
+ if (item.getMedia() != null) {
+ removeFeedMedia(item.getMedia());
+ }
+ if (item.getChapters() != null) {
+ removeChaptersOfItem(item);
+ }
+ if (item.hasItemImage()) {
+ removeFeedImage(item.getImage());
+ }
+ db.delete(TABLE_NAME_FEED_ITEMS, KEY_ID + "=?",
+ new String[]{String.valueOf(item.getId())});
+ }
+
+ /**
+ * Remove a feed with all its FeedItems and Media entries.
+ */
+ public void removeFeed(Feed feed) {
+ db.beginTransaction();
+ if (feed.getImage() != null) {
+ removeFeedImage(feed.getImage());
+ }
+ if (feed.getItems() != null) {
+ for (FeedItem item : feed.getItems()) {
+ removeFeedItem(item);
+ }
+ }
+
+ db.delete(TABLE_NAME_FEEDS, KEY_ID + "=?",
+ new String[]{String.valueOf(feed.getId())});
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+
+ public void removeDownloadStatus(DownloadStatus remove) {
+ db.delete(TABLE_NAME_DOWNLOAD_LOG, KEY_ID + "=?",
+ new String[]{String.valueOf(remove.getId())});
+ }
+
+ public void clearPlaybackHistory() {
+ ContentValues values = new ContentValues();
+ values.put(KEY_PLAYBACK_COMPLETION_DATE, 0);
+ db.update(TABLE_NAME_FEED_MEDIA, values, null, null);
+ }
+
+ /**
+ * Get all Feeds from the Feed Table.
+ *
+ * @return The cursor of the query
+ */
+ public final Cursor getAllFeedsCursor() {
+ Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, null, null, null, null,
+ KEY_TITLE + " COLLATE NOCASE ASC");
+ return c;
+ }
+
+ public final Cursor getFeedCursorDownloadUrls() {
+ return db.query(TABLE_NAME_FEEDS, new String[]{KEY_ID, KEY_DOWNLOAD_URL}, null, null, null, null, null);
+ }
+
+ public final Cursor getExpiredFeedsCursor(long expirationTime) {
+ Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_LASTUPDATE + " < " + String.valueOf(System.currentTimeMillis() - expirationTime),
+ null, null, null,
+ null);
+ return c;
+ }
+
+ /**
+ * Returns a cursor with all FeedItems of a Feed. Uses FEEDITEM_SEL_FI_SMALL
+ *
+ * @param feed The feed you want to get the FeedItems from.
+ * @return The cursor of the query
+ */
+ public final Cursor getAllItemsOfFeedCursor(final Feed feed) {
+ return getAllItemsOfFeedCursor(feed.getId());
+ }
+
+ public final Cursor getAllItemsOfFeedCursor(final long feedId) {
+ Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED
+ + "=?", new String[]{String.valueOf(feedId)}, null, null,
+ null
+ );
+ return c;
+ }
+
+ /**
+ * Return a cursor with the SEL_FI_EXTRA selection of a single feeditem.
+ */
+ public final Cursor getExtraInformationOfItem(final FeedItem item) {
+ Cursor c = db
+ .query(TABLE_NAME_FEED_ITEMS, SEL_FI_EXTRA, KEY_ID + "=?",
+ new String[]{String.valueOf(item.getId())}, null,
+ null, null);
+ return c;
+ }
+
+ /**
+ * Returns a cursor for a DB query in the FeedMedia table for a given ID.
+ *
+ * @param item The item you want to get the FeedMedia from
+ * @return The cursor of the query
+ */
+ public final Cursor getFeedMediaOfItemCursor(final FeedItem item) {
+ Cursor c = db.query(TABLE_NAME_FEED_MEDIA, null, KEY_ID + "=?",
+ new String[]{String.valueOf(item.getMedia().getId())}, null,
+ null, null);
+ return c;
+ }
+
+ /**
+ * Returns a cursor for a DB query in the FeedImages table for a given ID.
+ *
+ * @param id ID of the FeedImage
+ * @return The cursor of the query
+ */
+ public final Cursor getImageCursor(final long id) {
+ Cursor c = db.query(TABLE_NAME_FEED_IMAGES, null, KEY_ID + "=?",
+ new String[]{String.valueOf(id)}, null, null, null);
+ return c;
+ }
+
+ public final Cursor getSimpleChaptersOfFeedItemCursor(final FeedItem item) {
+ Cursor c = db.query(TABLE_NAME_SIMPLECHAPTERS, null, KEY_FEEDITEM
+ + "=?", new String[]{String.valueOf(item.getId())}, null,
+ null, null
+ );
+ return c;
+ }
+
+ public final Cursor getDownloadLogCursor(final int limit) {
+ Cursor c = db.query(TABLE_NAME_DOWNLOAD_LOG, null, null, null, null,
+ null, KEY_COMPLETION_DATE + " DESC LIMIT " + limit);
+ return c;
+ }
+
+ /**
+ * Returns a cursor which contains all feed items in the queue. The returned
+ * cursor uses the FEEDITEM_SEL_FI_SMALL selection.
+ */
+ public final Cursor getQueueCursor() {
+ Object[] args = (Object[]) new String[]{
+ SEL_FI_SMALL_STR + "," + TABLE_NAME_QUEUE + "." + KEY_ID,
+ TABLE_NAME_FEED_ITEMS, TABLE_NAME_QUEUE,
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ID,
+ TABLE_NAME_QUEUE + "." + KEY_FEEDITEM,
+ TABLE_NAME_QUEUE + "." + KEY_ID};
+ String query = String.format(
+ "SELECT %s FROM %s INNER JOIN %s ON %s=%s ORDER BY %s", args);
+ Cursor c = db.rawQuery(query, null);
+ /*
+ * Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL,
+ * "INNER JOIN ? ON ?=?", new String[] { TABLE_NAME_QUEUE,
+ * TABLE_NAME_FEED_ITEMS + "." + KEY_ID, TABLE_NAME_QUEUE + "." +
+ * KEY_FEEDITEM }, null, null, TABLE_NAME_QUEUE + "." + KEY_FEEDITEM);
+ */
+ return c;
+ }
+
+ public Cursor getQueueIDCursor() {
+ Cursor c = db.query(TABLE_NAME_QUEUE, new String[]{KEY_FEEDITEM}, null, null, null, null, KEY_ID + " ASC", null);
+ return c;
+ }
+
+ /**
+ * Returns a cursor which contains all feed items in the unread items list.
+ * The returned cursor uses the FEEDITEM_SEL_FI_SMALL selection.
+ */
+ public final Cursor getUnreadItemsCursor() {
+ Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_READ
+ + "=0", null, null, null, KEY_PUBDATE + " DESC");
+ return c;
+ }
+
+ public final Cursor getUnreadItemIdsCursor() {
+ Cursor c = db.query(TABLE_NAME_FEED_ITEMS, new String[]{KEY_ID},
+ KEY_READ + "=0", null, null, null, KEY_PUBDATE + " DESC");
+ return c;
+
+ }
+
+ public final Cursor getRecentlyPublishedItemsCursor(int limit) {
+ Cursor c = db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, null, null, null, null, KEY_PUBDATE + " DESC LIMIT " + limit);
+ return c;
+ }
+
+ public Cursor getDownloadedItemsCursor() {
+ final String query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS
+ + " INNER JOIN " + TABLE_NAME_FEED_MEDIA + " ON "
+ + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + "="
+ + TABLE_NAME_FEED_MEDIA + "." + KEY_FEEDITEM + " WHERE "
+ + TABLE_NAME_FEED_MEDIA + "." + KEY_DOWNLOADED + ">0";
+ Cursor c = db.rawQuery(query, null);
+ return c;
+ }
+
+ /**
+ * Returns a cursor which contains feed media objects with a playback
+ * completion date in ascending order.
+ *
+ * @param limit The maximum row count of the returned cursor. Must be an
+ * integer >= 0.
+ * @throws IllegalArgumentException if limit < 0
+ */
+ public final Cursor getCompletedMediaCursor(int limit) {
+ Validate.isTrue(limit >= 0, "Limit must be >= 0");
+
+ Cursor c = db.query(TABLE_NAME_FEED_MEDIA, null,
+ KEY_PLAYBACK_COMPLETION_DATE + " > 0 LIMIT " + limit, null, null,
+ null, null);
+ return c;
+ }
+
+ public final Cursor getSingleFeedMediaCursor(long id) {
+ return db.query(TABLE_NAME_FEED_MEDIA, null, KEY_ID + "=?", new String[]{String.valueOf(id)}, null, null, null);
+ }
+
+ public final Cursor getFeedMediaCursorByItemID(String... mediaIds) {
+ int length = mediaIds.length;
+ if (length > IN_OPERATOR_MAXIMUM) {
+ Log.w(TAG, "Length of id array is larger than "
+ + IN_OPERATOR_MAXIMUM + ". Creating multiple cursors");
+ int numCursors = (int) (((double) length) / (IN_OPERATOR_MAXIMUM)) + 1;
+ Cursor[] cursors = new Cursor[numCursors];
+ for (int i = 0; i < numCursors; i++) {
+ int neededLength = 0;
+ String[] parts = null;
+ final int elementsLeft = length - i * IN_OPERATOR_MAXIMUM;
+
+ if (elementsLeft >= IN_OPERATOR_MAXIMUM) {
+ neededLength = IN_OPERATOR_MAXIMUM;
+ parts = Arrays.copyOfRange(mediaIds, i
+ * IN_OPERATOR_MAXIMUM, (i + 1)
+ * IN_OPERATOR_MAXIMUM);
+ } else {
+ neededLength = elementsLeft;
+ parts = Arrays.copyOfRange(mediaIds, i
+ * IN_OPERATOR_MAXIMUM, (i * IN_OPERATOR_MAXIMUM)
+ + neededLength);
+ }
+
+ cursors[i] = db.rawQuery("SELECT * FROM "
+ + TABLE_NAME_FEED_MEDIA + " WHERE " + KEY_FEEDITEM + " IN "
+ + buildInOperator(neededLength), parts);
+ }
+ return new MergeCursor(cursors);
+ } else {
+ return db.query(TABLE_NAME_FEED_MEDIA, null, KEY_FEEDITEM + " IN "
+ + buildInOperator(length), mediaIds, null, null, null);
+ }
+ }
+
+ /**
+ * Builds an IN-operator argument depending on the number of items.
+ */
+ private String buildInOperator(int size) {
+ if (size == 1) {
+ return "(?)";
+ }
+ StringBuffer buffer = new StringBuffer("(");
+ for (int i = 0; i < size - 1; i++) {
+ buffer.append("?,");
+ }
+ buffer.append("?)");
+ return buffer.toString();
+ }
+
+ public final Cursor getFeedCursor(final long id) {
+ Cursor c = db.query(TABLE_NAME_FEEDS, FEED_SEL_STD, KEY_ID + "=" + id, null,
+ null, null, null);
+ return c;
+ }
+
+ public final Cursor getFeedItemCursor(final String... ids) {
+ if (ids.length > IN_OPERATOR_MAXIMUM) {
+ throw new IllegalArgumentException(
+ "number of IDs must not be larger than "
+ + IN_OPERATOR_MAXIMUM
+ );
+ }
+
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_ID + " IN "
+ + buildInOperator(ids.length), ids, null, null, null);
+
+ }
+
+ public int getQueueSize() {
+ final String query = String.format("SELECT COUNT(%s) FROM %s", KEY_ID, TABLE_NAME_QUEUE);
+ Cursor c = db.rawQuery(query, null);
+ int result = 0;
+ if (c.moveToFirst()) {
+ result = c.getInt(0);
+ }
+ c.close();
+ return result;
+ }
+
+ public final int getNumberOfUnreadItems() {
+ final String query = "SELECT COUNT(DISTINCT " + KEY_ID + ") AS count FROM " + TABLE_NAME_FEED_ITEMS +
+ " WHERE " + KEY_READ + " = 0";
+ Cursor c = db.rawQuery(query, null);
+ int result = 0;
+ if (c.moveToFirst()) {
+ result = c.getInt(0);
+ }
+ c.close();
+ return result;
+ }
+
+ public final int getNumberOfDownloadedEpisodes() {
+ final String query = "SELECT COUNT(DISTINCT " + KEY_ID + ") AS count FROM " + TABLE_NAME_FEED_MEDIA +
+ " WHERE " + KEY_DOWNLOADED + " > 0";
+
+ Cursor c = db.rawQuery(query, null);
+ int result = 0;
+ if (c.moveToFirst()) {
+ result = c.getInt(0);
+ }
+ c.close();
+ return result;
+ }
+
+ /**
+ * Uses DatabaseUtils to escape a search query and removes ' at the
+ * beginning and the end of the string returned by the escape method.
+ */
+ private String prepareSearchQuery(String query) {
+ StringBuilder builder = new StringBuilder();
+ DatabaseUtils.appendEscapedSQLString(builder, query);
+ builder.deleteCharAt(0);
+ builder.deleteCharAt(builder.length() - 1);
+ return builder.toString();
+ }
+
+ /**
+ * Searches for the given query in the description of all items or the items
+ * of a specified feed.
+ *
+ * @return A cursor with all search results in SEL_FI_EXTRA selection.
+ */
+ public Cursor searchItemDescriptions(long feedID, String query) {
+ if (feedID != 0) {
+ // search items in specific feed
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED
+ + "=? AND " + KEY_DESCRIPTION + " LIKE '%"
+ + prepareSearchQuery(query) + "%'",
+ new String[]{String.valueOf(feedID)}, null, null,
+ null
+ );
+ } else {
+ // search through all items
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL,
+ KEY_DESCRIPTION + " LIKE '%" + prepareSearchQuery(query)
+ + "%'", null, null, null, null
+ );
+ }
+ }
+
+ /**
+ * Searches for the given query in the content-encoded field of all items or
+ * the items of a specified feed.
+ *
+ * @return A cursor with all search results in SEL_FI_EXTRA selection.
+ */
+ public Cursor searchItemContentEncoded(long feedID, String query) {
+ if (feedID != 0) {
+ // search items in specific feed
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED
+ + "=? AND " + KEY_CONTENT_ENCODED + " LIKE '%"
+ + prepareSearchQuery(query) + "%'",
+ new String[]{String.valueOf(feedID)}, null, null,
+ null
+ );
+ } else {
+ // search through all items
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL,
+ KEY_CONTENT_ENCODED + " LIKE '%"
+ + prepareSearchQuery(query) + "%'", null, null,
+ null, null
+ );
+ }
+ }
+
+ public Cursor searchItemTitles(long feedID, String query) {
+ if (feedID != 0) {
+ // search items in specific feed
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL, KEY_FEED
+ + "=? AND " + KEY_TITLE + " LIKE '%"
+ + prepareSearchQuery(query) + "%'",
+ new String[]{String.valueOf(feedID)}, null, null,
+ null
+ );
+ } else {
+ // search through all items
+ return db.query(TABLE_NAME_FEED_ITEMS, FEEDITEM_SEL_FI_SMALL,
+ KEY_TITLE + " LIKE '%"
+ + prepareSearchQuery(query) + "%'", null, null,
+ null, null
+ );
+ }
+ }
+
+ public Cursor searchItemChapters(long feedID, String searchQuery) {
+ final String query;
+ if (feedID != 0) {
+ query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS + " INNER JOIN " +
+ TABLE_NAME_SIMPLECHAPTERS + " ON " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_FEEDITEM + "=" +
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ID + " WHERE " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" +
+ feedID + " AND " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_TITLE + " LIKE '%"
+ + prepareSearchQuery(searchQuery) + "%'";
+ } else {
+ query = "SELECT " + SEL_FI_SMALL_STR + " FROM " + TABLE_NAME_FEED_ITEMS + " INNER JOIN " +
+ TABLE_NAME_SIMPLECHAPTERS + " ON " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_FEEDITEM + "=" +
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ID + " WHERE " + TABLE_NAME_SIMPLECHAPTERS + "." + KEY_TITLE + " LIKE '%"
+ + prepareSearchQuery(searchQuery) + "%'";
+ }
+ return db.rawQuery(query, null);
+ }
+
+
+ public static final int IDX_FEEDSTATISTICS_FEED = 0;
+ public static final int IDX_FEEDSTATISTICS_NUM_ITEMS = 1;
+ public static final int IDX_FEEDSTATISTICS_NEW_ITEMS = 2;
+ public static final int IDX_FEEDSTATISTICS_LATEST_EPISODE = 3;
+ public static final int IDX_FEEDSTATISTICS_IN_PROGRESS_EPISODES = 4;
+
+ /**
+ * Select number of items, new items, the date of the latest episode and the number of episodes in progress. The result
+ * is sorted by the title of the feed.
+ */
+ private static final String FEED_STATISTICS_QUERY = "SELECT Feeds.id, num_items, new_items, latest_episode, in_progress FROM " +
+ " Feeds LEFT JOIN " +
+ "(SELECT feed,count(*) AS num_items," +
+ " COUNT(CASE WHEN read=0 THEN 1 END) AS new_items," +
+ " MAX(pubDate) AS latest_episode," +
+ " COUNT(CASE WHEN position>0 THEN 1 END) AS in_progress," +
+ " COUNT(CASE WHEN downloaded=1 THEN 1 END) AS episodes_downloaded " +
+ " FROM FeedItems LEFT JOIN FeedMedia ON FeedItems.id=FeedMedia.feeditem GROUP BY FeedItems.feed)" +
+ " ON Feeds.id = feed ORDER BY Feeds.title COLLATE NOCASE ASC;";
+
+ public Cursor getFeedStatisticsCursor() {
+ return db.rawQuery(FEED_STATISTICS_QUERY, null);
+ }
+
+ /**
+ * Helper class for opening the Antennapod database.
+ */
+ private static class PodDBHelper extends SQLiteOpenHelper {
+ /**
+ * Constructor.
+ *
+ * @param context Context to use
+ * @param name Name of the database
+ * @param factory to use for creating cursor objects
+ * @param version number of the database
+ */
+ public PodDBHelper(final Context context, final String name,
+ final CursorFactory factory, final int version) {
+ super(context, name, factory, version);
+ }
+
+ @Override
+ public void onCreate(final SQLiteDatabase db) {
+ db.execSQL(CREATE_TABLE_FEEDS);
+ db.execSQL(CREATE_TABLE_FEED_ITEMS);
+ db.execSQL(CREATE_TABLE_FEED_IMAGES);
+ db.execSQL(CREATE_TABLE_FEED_MEDIA);
+ db.execSQL(CREATE_TABLE_DOWNLOAD_LOG);
+ db.execSQL(CREATE_TABLE_QUEUE);
+ db.execSQL(CREATE_TABLE_SIMPLECHAPTERS);
+ }
+
+ @Override
+ public void onUpgrade(final SQLiteDatabase db, final int oldVersion,
+ final int newVersion) {
+ Log.w("DBAdapter", "Upgrading from version " + oldVersion + " to "
+ + newVersion + ".");
+ if (oldVersion <= 1) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + " ADD COLUMN "
+ + KEY_TYPE + " TEXT");
+ }
+ if (oldVersion <= 2) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_SIMPLECHAPTERS
+ + " ADD COLUMN " + KEY_LINK + " TEXT");
+ }
+ if (oldVersion <= 3) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS
+ + " ADD COLUMN " + KEY_ITEM_IDENTIFIER + " TEXT");
+ }
+ if (oldVersion <= 4) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS + " ADD COLUMN "
+ + KEY_FEED_IDENTIFIER + " TEXT");
+ }
+ if (oldVersion <= 5) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_DOWNLOAD_LOG
+ + " ADD COLUMN " + KEY_REASON_DETAILED + " TEXT");
+ db.execSQL("ALTER TABLE " + TABLE_NAME_DOWNLOAD_LOG
+ + " ADD COLUMN " + KEY_DOWNLOADSTATUS_TITLE + " TEXT");
+ }
+ if (oldVersion <= 6) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_SIMPLECHAPTERS
+ + " ADD COLUMN " + KEY_CHAPTER_TYPE + " INTEGER");
+ }
+ if (oldVersion <= 7) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA
+ + " ADD COLUMN " + KEY_PLAYBACK_COMPLETION_DATE
+ + " INTEGER");
+ }
+ if (oldVersion <= 8) {
+ final int KEY_ID_POSITION = 0;
+ final int KEY_MEDIA_POSITION = 1;
+
+ // Add feeditem column to feedmedia table
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA
+ + " ADD COLUMN " + KEY_FEEDITEM
+ + " INTEGER");
+ Cursor feeditemCursor = db.query(TABLE_NAME_FEED_ITEMS, new String[]{KEY_ID, KEY_MEDIA}, "? > 0", new String[]{KEY_MEDIA}, null, null, null);
+ if (feeditemCursor.moveToFirst()) {
+ db.beginTransaction();
+ ContentValues contentValues = new ContentValues();
+ do {
+ long mediaId = feeditemCursor.getLong(KEY_MEDIA_POSITION);
+ contentValues.put(KEY_FEEDITEM, feeditemCursor.getLong(KEY_ID_POSITION));
+ db.update(TABLE_NAME_FEED_MEDIA, contentValues, KEY_ID + "=?", new String[]{String.valueOf(mediaId)});
+ contentValues.clear();
+ } while (feeditemCursor.moveToNext());
+ db.setTransactionSuccessful();
+ db.endTransaction();
+ }
+ feeditemCursor.close();
+ }
+ if (oldVersion <= 9) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS
+ + " ADD COLUMN " + KEY_AUTO_DOWNLOAD
+ + " INTEGER DEFAULT 1");
+ }
+ if (oldVersion <= 10) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS
+ + " ADD COLUMN " + KEY_FLATTR_STATUS
+ + " INTEGER");
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS
+ + " ADD COLUMN " + KEY_FLATTR_STATUS
+ + " INTEGER");
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_MEDIA
+ + " ADD COLUMN " + KEY_PLAYED_DURATION
+ + " INTEGER");
+ }
+ if (oldVersion <= 11) {
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS
+ + " ADD COLUMN " + KEY_USERNAME
+ + " TEXT");
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEEDS
+ + " ADD COLUMN " + KEY_PASSWORD
+ + " TEXT");
+ db.execSQL("ALTER TABLE " + TABLE_NAME_FEED_ITEMS
+ + " ADD COLUMN " + KEY_IMAGE
+ + " INTEGER");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java
new file mode 100644
index 000000000..aafa1c209
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandler.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import de.danoeh.antennapod.feed.Feed;
+import org.apache.commons.io.input.XmlStreamReader;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import java.io.File;
+import java.io.IOException;
+import java.io.Reader;
+
+public class FeedHandler {
+
+ public FeedHandlerResult parseFeed(Feed feed) throws SAXException, IOException,
+ ParserConfigurationException, UnsupportedFeedtypeException {
+ TypeGetter tg = new TypeGetter();
+ TypeGetter.Type type = tg.getType(feed);
+ SyndHandler handler = new SyndHandler(feed, type);
+
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+ SAXParser saxParser = factory.newSAXParser();
+ File file = new File(feed.getFile_url());
+ Reader inputStreamReader = new XmlStreamReader(file);
+ InputSource inputSource = new InputSource(inputStreamReader);
+
+ saxParser.parse(inputSource, handler);
+ inputStreamReader.close();
+ return new FeedHandlerResult(handler.state.feed, handler.state.alternateUrls);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java
new file mode 100644
index 000000000..41aa29b52
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/FeedHandlerResult.java
@@ -0,0 +1,19 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import de.danoeh.antennapod.feed.Feed;
+
+import java.util.Map;
+
+/**
+ * Container for results returned by the Feed parser
+ */
+public class FeedHandlerResult {
+
+ public Feed feed;
+ public Map<String, String> alternateFeedUrls;
+
+ public FeedHandlerResult(Feed feed, Map<String, String> alternateFeedUrls) {
+ this.feed = feed;
+ this.alternateFeedUrls = alternateFeedUrls;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java
new file mode 100644
index 000000000..17f84724f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/HandlerState.java
@@ -0,0 +1,98 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.syndication.namespace.Namespace;
+import de.danoeh.antennapod.syndication.namespace.SyndElement;
+
+import java.util.*;
+
+/**
+ * Contains all relevant information to describe the current state of a
+ * SyndHandler.
+ */
+public class HandlerState {
+
+ /**
+ * Feed that the Handler is currently processing.
+ */
+ protected Feed feed;
+ /**
+ * Contains links to related feeds, e.g. feeds with enclosures in other formats. The key of the map is the
+ * URL of the feed, the value is the title
+ */
+ protected Map<String, String> alternateUrls;
+ protected ArrayList<FeedItem> items;
+ protected FeedItem currentItem;
+ protected Stack<SyndElement> tagstack;
+ /**
+ * Namespaces that have been defined so far.
+ */
+ protected HashMap<String, Namespace> namespaces;
+ protected Stack<Namespace> defaultNamespaces;
+ /**
+ * Buffer for saving characters.
+ */
+ protected StringBuffer contentBuf;
+
+ public HandlerState(Feed feed) {
+ this.feed = feed;
+ alternateUrls = new LinkedHashMap<String, String>();
+ items = new ArrayList<FeedItem>();
+ tagstack = new Stack<SyndElement>();
+ namespaces = new HashMap<String, Namespace>();
+ defaultNamespaces = new Stack<Namespace>();
+ }
+
+ public Feed getFeed() {
+ return feed;
+ }
+
+ public ArrayList<FeedItem> getItems() {
+ return items;
+ }
+
+ public FeedItem getCurrentItem() {
+ return currentItem;
+ }
+
+ public Stack<SyndElement> getTagstack() {
+ return tagstack;
+ }
+
+ public void setFeed(Feed feed) {
+ this.feed = feed;
+ }
+
+ public void setCurrentItem(FeedItem currentItem) {
+ this.currentItem = currentItem;
+ }
+
+ /**
+ * Returns the SyndElement that comes after the top element of the tagstack.
+ */
+ public SyndElement getSecondTag() {
+ SyndElement top = tagstack.pop();
+ SyndElement second = tagstack.peek();
+ tagstack.push(top);
+ return second;
+ }
+
+ public SyndElement getThirdTag() {
+ SyndElement top = tagstack.pop();
+ SyndElement second = tagstack.pop();
+ SyndElement third = tagstack.peek();
+ tagstack.push(second);
+ tagstack.push(top);
+ return third;
+ }
+
+ public StringBuffer getContentBuf() {
+ return contentBuf;
+ }
+
+ public void addAlternateFeedUrl(String title, String url) {
+ alternateUrls.put(url, title);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java
new file mode 100644
index 000000000..15dc94d65
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/SyndHandler.java
@@ -0,0 +1,126 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.syndication.namespace.*;
+import de.danoeh.antennapod.syndication.namespace.atom.NSAtom;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+/** Superclass for all SAX Handlers which process Syndication formats */
+public class SyndHandler extends DefaultHandler {
+ private static final String TAG = "SyndHandler";
+ private static final String DEFAULT_PREFIX = "";
+ protected HandlerState state;
+
+ public SyndHandler(Feed feed, TypeGetter.Type type) {
+ state = new HandlerState(feed);
+ if (type == TypeGetter.Type.RSS20 || type == TypeGetter.Type.RSS091) {
+ state.defaultNamespaces.push(new NSRSS20());
+ }
+ }
+
+ @Override
+ public void startElement(String uri, String localName, String qName,
+ Attributes attributes) throws SAXException {
+ state.contentBuf = new StringBuffer();
+ Namespace handler = getHandlingNamespace(uri, qName);
+ if (handler != null) {
+ SyndElement element = handler.handleElementStart(localName, state,
+ attributes);
+ state.tagstack.push(element);
+
+ }
+ }
+
+ @Override
+ public void characters(char[] ch, int start, int length)
+ throws SAXException {
+ if (!state.tagstack.empty()) {
+ if (state.getTagstack().size() >= 2) {
+ if (state.contentBuf != null) {
+ state.contentBuf.append(ch, start, length);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void endElement(String uri, String localName, String qName)
+ throws SAXException {
+ Namespace handler = getHandlingNamespace(uri, qName);
+ if (handler != null) {
+ handler.handleElementEnd(localName, state);
+ state.tagstack.pop();
+
+ }
+ state.contentBuf = null;
+
+ }
+
+ @Override
+ public void endPrefixMapping(String prefix) throws SAXException {
+ if (state.defaultNamespaces.size() > 1 && prefix.equals(DEFAULT_PREFIX)) {
+ state.defaultNamespaces.pop();
+ }
+ }
+
+ @Override
+ public void startPrefixMapping(String prefix, String uri)
+ throws SAXException {
+ // Find the right namespace
+ if (!state.namespaces.containsKey(uri)) {
+ if (uri.equals(NSAtom.NSURI)) {
+ if (prefix.equals(DEFAULT_PREFIX)) {
+ state.defaultNamespaces.push(new NSAtom());
+ } else if (prefix.equals(NSAtom.NSTAG)) {
+ state.namespaces.put(uri, new NSAtom());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized Atom namespace");
+ }
+ } else if (uri.equals(NSContent.NSURI)
+ && prefix.equals(NSContent.NSTAG)) {
+ state.namespaces.put(uri, new NSContent());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized Content namespace");
+ } else if (uri.equals(NSITunes.NSURI)
+ && prefix.equals(NSITunes.NSTAG)) {
+ state.namespaces.put(uri, new NSITunes());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized ITunes namespace");
+ } else if (uri.equals(NSSimpleChapters.NSURI)
+ && prefix.matches(NSSimpleChapters.NSTAG)) {
+ state.namespaces.put(uri, new NSSimpleChapters());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized SimpleChapters namespace");
+ } else if (uri.equals(NSMedia.NSURI)
+ && prefix.equals(NSMedia.NSTAG)) {
+ state.namespaces.put(uri, new NSMedia());
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized media namespace");
+ }
+ }
+ }
+
+ private Namespace getHandlingNamespace(String uri, String qName) {
+ Namespace handler = state.namespaces.get(uri);
+ if (handler == null && !state.defaultNamespaces.empty()
+ && !qName.contains(":")) {
+ handler = state.defaultNamespaces.peek();
+ }
+ return handler;
+ }
+
+ @Override
+ public void endDocument() throws SAXException {
+ super.endDocument();
+ state.getFeed().setItems(state.getItems());
+ }
+
+ public HandlerState getState() {
+ return state;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java
new file mode 100644
index 000000000..2496e112a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/TypeGetter.java
@@ -0,0 +1,112 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Feed;
+import org.apache.commons.io.input.XmlStreamReader;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.Reader;
+
+/** Gets the type of a specific feed by reading the root element. */
+public class TypeGetter {
+ private static final String TAG = "TypeGetter";
+
+ public enum Type {
+ RSS20, RSS091, ATOM, INVALID
+ }
+
+ private static final String ATOM_ROOT = "feed";
+ private static final String RSS_ROOT = "rss";
+
+ public Type getType(Feed feed) throws UnsupportedFeedtypeException {
+ XmlPullParserFactory factory;
+ if (feed.getFile_url() != null) {
+ try {
+ factory = XmlPullParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+ XmlPullParser xpp = factory.newPullParser();
+ xpp.setInput(createReader(feed));
+ int eventType = xpp.getEventType();
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG) {
+ String tag = xpp.getName();
+ if (tag.equals(ATOM_ROOT)) {
+ feed.setType(Feed.TYPE_ATOM1);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized type Atom");
+ return Type.ATOM;
+ } else if (tag.equals(RSS_ROOT)) {
+ String strVersion = xpp.getAttributeValue(null,
+ "version");
+ if (strVersion != null) {
+
+ if (strVersion.equals("2.0")) {
+ feed.setType(Feed.TYPE_RSS2);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized type RSS 2.0");
+ return Type.RSS20;
+ } else if (strVersion.equals("0.91")
+ || strVersion.equals("0.92")) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Recognized type RSS 0.91/0.92");
+ return Type.RSS091;
+ }
+ }
+ throw new UnsupportedFeedtypeException(Type.INVALID);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Type is invalid");
+ throw new UnsupportedFeedtypeException(Type.INVALID, tag);
+ }
+ } else {
+ eventType = xpp.next();
+ }
+ }
+
+ } catch (XmlPullParserException e) {
+ e.printStackTrace();
+ // XML document might actually be a HTML document -> try to parse as HTML
+ String rootElement = null;
+ try {
+ if (Jsoup.parse(new File(feed.getFile_url()), null) != null) {
+ rootElement = "html";
+ }
+ } catch (IOException e1) {
+ e1.printStackTrace();
+ } finally {
+ throw new UnsupportedFeedtypeException(Type.INVALID, rootElement);
+ }
+
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Type is invalid");
+ throw new UnsupportedFeedtypeException(Type.INVALID);
+ }
+
+ private Reader createReader(Feed feed) {
+ Reader reader;
+ try {
+ reader = new XmlStreamReader(new File(feed.getFile_url()));
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ return null;
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ return reader;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java b/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java
new file mode 100644
index 000000000..605dad2fb
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/handler/UnsupportedFeedtypeException.java
@@ -0,0 +1,38 @@
+package de.danoeh.antennapod.syndication.handler;
+
+import de.danoeh.antennapod.syndication.handler.TypeGetter.Type;
+
+public class UnsupportedFeedtypeException extends Exception {
+ private static final long serialVersionUID = 9105878964928170669L;
+ private TypeGetter.Type type;
+ private String rootElement;
+
+ public UnsupportedFeedtypeException(Type type) {
+ super();
+ this.type = type;
+ }
+
+ public UnsupportedFeedtypeException(Type type, String rootElement) {
+ this.type = type;
+ this.rootElement = rootElement;
+ }
+
+ public TypeGetter.Type getType() {
+ return type;
+ }
+
+ public String getRootElement() {
+ return rootElement;
+ }
+
+ @Override
+ public String getMessage() {
+ if (type == TypeGetter.Type.INVALID) {
+ return "Invalid type";
+ } else {
+ return "Type " + type + " not supported";
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java
new file mode 100644
index 000000000..9ad3026be
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSContent.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import org.xml.sax.Attributes;
+
+public class NSContent extends Namespace {
+ public static final String NSTAG = "content";
+ public static final String NSURI = "http://purl.org/rss/1.0/modules/content/";
+
+ private static final String ENCODED = "encoded";
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+ if (localName.equals(ENCODED)) {
+ state.getCurrentItem().setContentEncoded(state.getContentBuf().toString());
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java
new file mode 100644
index 000000000..d8cbe040b
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSITunes.java
@@ -0,0 +1,51 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import org.xml.sax.Attributes;
+
+public class NSITunes extends Namespace {
+ public static final String NSTAG = "itunes";
+ public static final String NSURI = "http://www.itunes.com/dtds/podcast-1.0.dtd";
+
+ private static final String IMAGE = "image";
+ private static final String IMAGE_TITLE = "image";
+ private static final String IMAGE_HREF = "href";
+
+ private static final String AUTHOR = "author";
+
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ if (localName.equals(IMAGE)) {
+ FeedImage image = new FeedImage();
+ image.setTitle(IMAGE_TITLE);
+ image.setDownload_url(attributes.getValue(IMAGE_HREF));
+
+ if (state.getCurrentItem() != null) {
+ // this is an items image
+ image.setTitle(state.getCurrentItem().getTitle() + IMAGE_TITLE);
+ state.getCurrentItem().setImage(image);
+
+ } else {
+ // this is the feed image
+ if (state.getFeed().getImage() == null) {
+ state.getFeed().setImage(image);
+ }
+ }
+
+ }
+
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+ if (localName.equals(AUTHOR)) {
+ state.getFeed().setAuthor(state.getContentBuf().toString());
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java
new file mode 100644
index 000000000..cc23167c1
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSMedia.java
@@ -0,0 +1,68 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import de.danoeh.antennapod.syndication.util.SyndTypeUtils;
+import org.xml.sax.Attributes;
+
+import java.util.concurrent.TimeUnit;
+
+/** Processes tags from the http://search.yahoo.com/mrss/ namespace. */
+public class NSMedia extends Namespace {
+ private static final String TAG = "NSMedia";
+
+ public static final String NSTAG = "media";
+ public static final String NSURI = "http://search.yahoo.com/mrss/";
+
+ private static final String CONTENT = "content";
+ private static final String DOWNLOAD_URL = "url";
+ private static final String SIZE = "fileSize";
+ private static final String MIME_TYPE = "type";
+ private static final String DURATION = "duration";
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ if (localName.equals(CONTENT)) {
+ String url = attributes.getValue(DOWNLOAD_URL);
+ String type = attributes.getValue(MIME_TYPE);
+ if (state.getCurrentItem().getMedia() == null
+ && url != null
+ && (SyndTypeUtils.enclosureTypeValid(type) || ((type = SyndTypeUtils
+ .getValidMimeTypeFromUrl(url)) != null))) {
+
+ long size = 0;
+ try {
+ size = Long.parseLong(attributes.getValue(SIZE));
+ } catch (NumberFormatException e) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Length attribute could not be parsed.");
+ }
+
+ int duration = 0;
+ try {
+ String durationStr = attributes.getValue(DURATION);
+ if (durationStr != null) {
+ duration = (int) TimeUnit.MILLISECONDS.convert(
+ Long.parseLong(durationStr), TimeUnit.SECONDS);
+ }
+ } catch (NumberFormatException e) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Duration attribute could not be parsed");
+ }
+
+ state.getCurrentItem().setMedia(
+ new FeedMedia(state.getCurrentItem(), url, size, type));
+ }
+ }
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java
new file mode 100644
index 000000000..9572f87ae
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSRSS20.java
@@ -0,0 +1,141 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import de.danoeh.antennapod.syndication.util.SyndDateUtils;
+import de.danoeh.antennapod.syndication.util.SyndTypeUtils;
+import org.xml.sax.Attributes;
+
+/**
+ * SAX-Parser for reading RSS-Feeds
+ *
+ * @author daniel
+ *
+ */
+public class NSRSS20 extends Namespace {
+ private static final String TAG = "NSRSS20";
+ public static final String NSTAG = "rss";
+ public static final String NSURI = "";
+
+ public final static String CHANNEL = "channel";
+ public final static String ITEM = "item";
+ public final static String GUID = "guid";
+ public final static String TITLE = "title";
+ public final static String LINK = "link";
+ public final static String DESCR = "description";
+ public final static String PUBDATE = "pubDate";
+ public final static String ENCLOSURE = "enclosure";
+ public final static String IMAGE = "image";
+ public final static String URL = "url";
+ public final static String LANGUAGE = "language";
+
+ public final static String ENC_URL = "url";
+ public final static String ENC_LEN = "length";
+ public final static String ENC_TYPE = "type";
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ if (localName.equals(ITEM)) {
+ state.setCurrentItem(new FeedItem());
+ state.getItems().add(state.getCurrentItem());
+ state.getCurrentItem().setFeed(state.getFeed());
+
+ } else if (localName.equals(ENCLOSURE)) {
+ String type = attributes.getValue(ENC_TYPE);
+ String url = attributes.getValue(ENC_URL);
+ if (state.getCurrentItem().getMedia() == null
+ && (SyndTypeUtils.enclosureTypeValid(type) || ((type = SyndTypeUtils
+ .getValidMimeTypeFromUrl(url)) != null))) {
+
+ long size = 0;
+ try {
+ size = Long.parseLong(attributes.getValue(ENC_LEN));
+ } catch (NumberFormatException e) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Length attribute could not be parsed.");
+ }
+ state.getCurrentItem().setMedia(
+ new FeedMedia(state.getCurrentItem(), url, size, type));
+ }
+
+ } else if (localName.equals(IMAGE)) {
+ if (state.getTagstack().size() >= 1) {
+ String parent = state.getTagstack().peek().getName();
+ if (parent.equals(CHANNEL)) {
+ state.getFeed().setImage(new FeedImage());
+ }
+ }
+ }
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+ if (localName.equals(ITEM)) {
+ if (state.getCurrentItem() != null) {
+ // the title tag is optional in RSS 2.0. The description is used
+ // as a
+ // title if the item has no title-tag.
+ if (state.getCurrentItem().getTitle() == null) {
+ state.getCurrentItem().setTitle(
+ state.getCurrentItem().getDescription());
+ }
+ }
+ state.setCurrentItem(null);
+ } else if (state.getTagstack().size() >= 2
+ && state.getContentBuf() != null) {
+ String content = state.getContentBuf().toString();
+ SyndElement topElement = state.getTagstack().peek();
+ String top = topElement.getName();
+ SyndElement secondElement = state.getSecondTag();
+ String second = secondElement.getName();
+ String third = null;
+ if (state.getTagstack().size() >= 3) {
+ third = state.getThirdTag().getName();
+ }
+
+ if (top.equals(GUID) && second.equals(ITEM)) {
+ // some feed creators include an empty or non-standard guid-element in their feed, which should be ignored
+ if (!content.isEmpty()) {
+ state.getCurrentItem().setItemIdentifier(content);
+ }
+ } else if (top.equals(TITLE)) {
+ if (second.equals(ITEM)) {
+ state.getCurrentItem().setTitle(content);
+ } else if (second.equals(CHANNEL)) {
+ state.getFeed().setTitle(content);
+ } else if (second.equals(IMAGE) && third != null
+ && third.equals(CHANNEL)) {
+ state.getFeed().getImage().setTitle(content);
+ }
+ } else if (top.equals(LINK)) {
+ if (second.equals(CHANNEL)) {
+ state.getFeed().setLink(content);
+ } else if (second.equals(ITEM)) {
+ state.getCurrentItem().setLink(content);
+ }
+ } else if (top.equals(PUBDATE) && second.equals(ITEM)) {
+ state.getCurrentItem().setPubDate(
+ SyndDateUtils.parseRFC822Date(content));
+ } else if (top.equals(URL) && second.equals(IMAGE) && third != null
+ && third.equals(CHANNEL)) {
+ state.getFeed().getImage().setDownload_url(content);
+ } else if (localName.equals(DESCR)) {
+ if (second.equals(CHANNEL)) {
+ state.getFeed().setDescription(content);
+ } else if (second.equals(ITEM)) {
+ state.getCurrentItem().setDescription(content);
+ }
+
+ } else if (localName.equals(LANGUAGE)) {
+ state.getFeed().setLanguage(content.toLowerCase());
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java
new file mode 100644
index 000000000..3f983ee88
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/NSSimpleChapters.java
@@ -0,0 +1,42 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.SimpleChapter;
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import de.danoeh.antennapod.syndication.util.SyndDateUtils;
+import org.xml.sax.Attributes;
+
+import java.util.ArrayList;
+
+public class NSSimpleChapters extends Namespace {
+ public static final String NSTAG = "psc|sc";
+ public static final String NSURI = "http://podlove.org/simple-chapters";
+
+ public static final String CHAPTERS = "chapters";
+ public static final String CHAPTER = "chapter";
+ public static final String START = "start";
+ public static final String TITLE = "title";
+ public static final String HREF = "href";
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ if (localName.equals(CHAPTERS)) {
+ state.getCurrentItem().setChapters(new ArrayList<Chapter>());
+ } else if (localName.equals(CHAPTER)) {
+ state.getCurrentItem()
+ .getChapters()
+ .add(new SimpleChapter(SyndDateUtils
+ .parseTimeString(attributes.getValue(START)),
+ attributes.getValue(TITLE), state.getCurrentItem(),
+ attributes.getValue(HREF)));
+ }
+
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java
new file mode 100644
index 000000000..910131feb
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/Namespace.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import org.xml.sax.Attributes;
+
+
+public abstract class Namespace {
+ public static final String NSTAG = null;
+ public static final String NSURI = null;
+
+ /** Called by a Feedhandler when in startElement and it detects a namespace element
+ * @return The SyndElement to push onto the stack
+ * */
+ public abstract SyndElement handleElementStart(String localName, HandlerState state, Attributes attributes);
+
+ /** Called by a Feedhandler when in endElement and it detects a namespace element
+ * @return true if namespace handled the element, false if it ignored it
+ * */
+ public abstract void handleElementEnd(String localName, HandlerState state);
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java
new file mode 100644
index 000000000..187312c9e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/SyndElement.java
@@ -0,0 +1,22 @@
+package de.danoeh.antennapod.syndication.namespace;
+
+/** Defines a XML Element that is pushed on the tagstack */
+public class SyndElement {
+ protected String name;
+ protected Namespace namespace;
+
+ public SyndElement(String name, Namespace namespace) {
+ this.name = name;
+ this.namespace = namespace;
+ }
+
+ public Namespace getNamespace() {
+ return namespace;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java
new file mode 100644
index 000000000..86b80d2ed
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/AtomText.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.syndication.namespace.atom;
+
+import de.danoeh.antennapod.syndication.namespace.Namespace;
+import de.danoeh.antennapod.syndication.namespace.SyndElement;
+import org.apache.commons.lang3.StringEscapeUtils;
+
+/** Represents Atom Element which contains text (content, title, summary). */
+public class AtomText extends SyndElement {
+ public static final String TYPE_TEXT = "text";
+ public static final String TYPE_HTML = "html";
+ public static final String TYPE_XHTML = "xhtml";
+
+ private String type;
+ private String content;
+
+ public AtomText(String name, Namespace namespace, String type) {
+ super(name, namespace);
+ this.type = type;
+ }
+
+ /** Processes the content according to the type and returns it. */
+ public String getProcessedContent() {
+ if (type == null) {
+ return content;
+ } else if (type.equals(TYPE_HTML)) {
+ return StringEscapeUtils.unescapeHtml4(content);
+ } else if (type.equals(TYPE_XHTML)) {
+ return content;
+ } else { // Handle as text by default
+ return content;
+ }
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java
new file mode 100644
index 000000000..2c8e232ff
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/namespace/atom/NSAtom.java
@@ -0,0 +1,194 @@
+package de.danoeh.antennapod.syndication.namespace.atom;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.FeedImage;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.syndication.handler.HandlerState;
+import de.danoeh.antennapod.syndication.namespace.NSRSS20;
+import de.danoeh.antennapod.syndication.namespace.Namespace;
+import de.danoeh.antennapod.syndication.namespace.SyndElement;
+import de.danoeh.antennapod.syndication.util.SyndDateUtils;
+import de.danoeh.antennapod.syndication.util.SyndTypeUtils;
+import org.xml.sax.Attributes;
+
+public class NSAtom extends Namespace {
+ private static final String TAG = "NSAtom";
+ public static final String NSTAG = "atom";
+ public static final String NSURI = "http://www.w3.org/2005/Atom";
+
+ private static final String FEED = "feed";
+ private static final String ID = "id";
+ private static final String TITLE = "title";
+ private static final String ENTRY = "entry";
+ private static final String LINK = "link";
+ private static final String UPDATED = "updated";
+ private static final String AUTHOR = "author";
+ private static final String CONTENT = "content";
+ private static final String IMAGE = "logo";
+ private static final String SUBTITLE = "subtitle";
+ private static final String PUBLISHED = "published";
+
+ private static final String TEXT_TYPE = "type";
+ // Link
+ private static final String LINK_HREF = "href";
+ private static final String LINK_REL = "rel";
+ private static final String LINK_TYPE = "type";
+ private static final String LINK_TITLE = "title";
+ private static final String LINK_LENGTH = "length";
+ // rel-values
+ private static final String LINK_REL_ALTERNATE = "alternate";
+ private static final String LINK_REL_ENCLOSURE = "enclosure";
+ private static final String LINK_REL_PAYMENT = "payment";
+ private static final String LINK_REL_RELATED = "related";
+ private static final String LINK_REL_SELF = "self";
+ // type-values
+ private static final String LINK_TYPE_ATOM = "application/atom+xml";
+ private static final String LINK_TYPE_HTML = "text/html";
+ private static final String LINK_TYPE_XHTML = "application/xml+xhtml";
+
+ private static final String LINK_TYPE_RSS = "application/rss+xml";
+
+ /**
+ * Regexp to test whether an Element is a Text Element.
+ */
+ private static final String isText = TITLE + "|" + CONTENT + "|" + "|"
+ + SUBTITLE;
+
+ public static final String isFeed = FEED + "|" + NSRSS20.CHANNEL;
+ public static final String isFeedItem = ENTRY + "|" + NSRSS20.ITEM;
+
+ @Override
+ public SyndElement handleElementStart(String localName, HandlerState state,
+ Attributes attributes) {
+ if (localName.equals(ENTRY)) {
+ state.setCurrentItem(new FeedItem());
+ state.getItems().add(state.getCurrentItem());
+ state.getCurrentItem().setFeed(state.getFeed());
+ } else if (localName.matches(isText)) {
+ String type = attributes.getValue(TEXT_TYPE);
+ return new AtomText(localName, this, type);
+ } else if (localName.equals(LINK)) {
+ String href = attributes.getValue(LINK_HREF);
+ String rel = attributes.getValue(LINK_REL);
+ SyndElement parent = state.getTagstack().peek();
+ if (parent.getName().matches(isFeedItem)) {
+ if (rel == null || rel.equals(LINK_REL_ALTERNATE)) {
+ state.getCurrentItem().setLink(href);
+ } else if (rel.equals(LINK_REL_ENCLOSURE)) {
+ String strSize = attributes.getValue(LINK_LENGTH);
+ long size = 0;
+ try {
+ if (strSize != null) {
+ size = Long.parseLong(strSize);
+ }
+ } catch (NumberFormatException e) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Length attribute could not be parsed.");
+ }
+ String type = attributes.getValue(LINK_TYPE);
+ if (SyndTypeUtils.enclosureTypeValid(type)
+ || (type = SyndTypeUtils
+ .getValidMimeTypeFromUrl(href)) != null) {
+ state.getCurrentItem().setMedia(
+ new FeedMedia(state.getCurrentItem(), href,
+ size, type)
+ );
+ }
+ } else if (rel.equals(LINK_REL_PAYMENT)) {
+ state.getCurrentItem().setPaymentLink(href);
+ }
+ } else if (parent.getName().matches(isFeed)) {
+ if (rel == null || rel.equals(LINK_REL_ALTERNATE)) {
+ String type = attributes.getValue(LINK_TYPE);
+ /*
+ * Use as link if a) no type-attribute is given and
+ * feed-object has no link yet b) type of link is
+ * LINK_TYPE_HTML or LINK_TYPE_XHTML
+ */
+ if ((type == null && state.getFeed().getLink() == null)
+ || (type != null && (type.equals(LINK_TYPE_HTML) || type.equals(LINK_TYPE_XHTML)))) {
+ state.getFeed().setLink(href);
+ } else if (type != null && (type.equals(LINK_TYPE_ATOM) || type.equals(LINK_TYPE_RSS))) {
+ // treat as podlove alternate feed
+ String title = attributes.getValue(LINK_TITLE);
+ if (title == null) {
+ title = href;
+ }
+ state.addAlternateFeedUrl(title, href);
+ }
+ } else if (rel.equals(LINK_REL_PAYMENT)) {
+ state.getFeed().setPaymentLink(href);
+ }
+ }
+ }
+ return new SyndElement(localName, this);
+ }
+
+ @Override
+ public void handleElementEnd(String localName, HandlerState state) {
+ if (localName.equals(ENTRY)) {
+ state.setCurrentItem(null);
+ }
+
+ if (state.getTagstack().size() >= 2) {
+ AtomText textElement = null;
+ String content;
+ if (state.getContentBuf() != null) {
+ content = state.getContentBuf().toString();
+ } else {
+ content = "";
+ }
+ SyndElement topElement = state.getTagstack().peek();
+ String top = topElement.getName();
+ SyndElement secondElement = state.getSecondTag();
+ String second = secondElement.getName();
+
+ if (top.matches(isText)) {
+ textElement = (AtomText) topElement;
+ textElement.setContent(content);
+ }
+
+ if (top.equals(ID)) {
+ if (second.equals(FEED)) {
+ state.getFeed().setFeedIdentifier(content);
+ } else if (second.equals(ENTRY)) {
+ state.getCurrentItem().setItemIdentifier(content);
+ }
+ } else if (top.equals(TITLE)) {
+
+ if (second.equals(FEED)) {
+ state.getFeed().setTitle(textElement.getProcessedContent());
+ } else if (second.equals(ENTRY)) {
+ state.getCurrentItem().setTitle(
+ textElement.getProcessedContent());
+ }
+ } else if (top.equals(SUBTITLE)) {
+ if (second.equals(FEED)) {
+ state.getFeed().setDescription(
+ textElement.getProcessedContent());
+ }
+ } else if (top.equals(CONTENT)) {
+ if (second.equals(ENTRY)) {
+ state.getCurrentItem().setDescription(
+ textElement.getProcessedContent());
+ }
+ } else if (top.equals(UPDATED)) {
+ if (second.equals(ENTRY)
+ && state.getCurrentItem().getPubDate() == null) {
+ state.getCurrentItem().setPubDate(
+ SyndDateUtils.parseRFC3339Date(content));
+ }
+ } else if (top.equals(PUBLISHED)) {
+ if (second.equals(ENTRY)) {
+ state.getCurrentItem().setPubDate(
+ SyndDateUtils.parseRFC3339Date(content));
+ }
+ } else if (top.equals(IMAGE)) {
+ state.getFeed().setImage(new FeedImage(content, null));
+ }
+
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java
new file mode 100644
index 000000000..3138f087a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndDateUtils.java
@@ -0,0 +1,153 @@
+package de.danoeh.antennapod.syndication.util;
+
+import android.util.Log;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/** Parses several date formats. */
+public class SyndDateUtils {
+ private static final String TAG = "DateUtils";
+
+ private static final String[] RFC822DATES = { "dd MMM yy HH:mm:ss Z", };
+
+ /** RFC 3339 date format for UTC dates. */
+ public static final String RFC3339UTC = "yyyy-MM-dd'T'HH:mm:ss'Z'";
+
+ /** RFC 3339 date format for localtime dates with offset. */
+ public static final String RFC3339LOCAL = "yyyy-MM-dd'T'HH:mm:ssZ";
+
+ private static ThreadLocal<SimpleDateFormat> RFC822Formatter = new ThreadLocal<SimpleDateFormat>() {
+ @Override
+ protected SimpleDateFormat initialValue() {
+ return new SimpleDateFormat(RFC822DATES[0], Locale.US);
+ }
+
+ };
+
+ private static ThreadLocal<SimpleDateFormat> RFC3339Formatter = new ThreadLocal<SimpleDateFormat>() {
+ @Override
+ protected SimpleDateFormat initialValue() {
+ return new SimpleDateFormat(RFC3339UTC, Locale.US);
+ }
+
+ };
+
+ public static Date parseRFC822Date(String date) {
+ Date result = null;
+ if (date.contains("PDT")) {
+ date = date.replace("PDT", "PST8PDT");
+ }
+ if (date.contains(",")) {
+ // Remove day of the week
+ date = date.substring(date.indexOf(",") + 1).trim();
+ }
+ SimpleDateFormat format = RFC822Formatter.get();
+ for (int i = 0; i < RFC822DATES.length; i++) {
+ try {
+ format.applyPattern(RFC822DATES[i]);
+ result = format.parse(date);
+ break;
+ } catch (ParseException e) {
+ e.printStackTrace();
+ }
+ }
+ if (result == null) {
+ Log.e(TAG, "Unable to parse feed date correctly");
+ }
+
+ return result;
+ }
+
+ public static Date parseRFC3339Date(String date) {
+ Date result = null;
+ SimpleDateFormat format = RFC3339Formatter.get();
+ boolean isLocal = date.endsWith("Z");
+ if (date.contains(".")) {
+ // remove secfrac
+ int fracIndex = date.indexOf(".");
+ String first = date.substring(0, fracIndex);
+ String second = null;
+ if (isLocal) {
+ second = date.substring(date.length() - 1);
+ } else {
+ if (date.contains("+")) {
+ second = date.substring(date.indexOf("+"));
+ } else {
+ second = date.substring(date.indexOf("-"));
+ }
+ }
+
+ date = first + second;
+ }
+ if (isLocal) {
+ try {
+ result = format.parse(date);
+ } catch (ParseException e) {
+ e.printStackTrace();
+ }
+ } else {
+ format.applyPattern(RFC3339LOCAL);
+ // remove last colon
+ StringBuffer buf = new StringBuffer(date.length() - 1);
+ int colonIdx = date.lastIndexOf(':');
+ for (int x = 0; x < date.length(); x++) {
+ if (x != colonIdx)
+ buf.append(date.charAt(x));
+ }
+ String bufStr = buf.toString();
+ try {
+ result = format.parse(bufStr);
+ } catch (ParseException e) {
+ e.printStackTrace();
+ Log.e(TAG, "Unable to parse date");
+ } finally {
+ format.applyPattern(RFC3339UTC);
+ }
+
+ }
+
+ return result;
+
+ }
+
+ /**
+ * Takes a string of the form [HH:]MM:SS[.mmm] and converts it to
+ * milliseconds.
+ */
+ public static long parseTimeString(final String time) {
+ String[] parts = time.split(":");
+ long result = 0;
+ int idx = 0;
+ if (parts.length == 3) {
+ // string has hours
+ result += Integer.valueOf(parts[idx]) * 3600000L;
+ idx++;
+ }
+ result += Integer.valueOf(parts[idx]) * 60000L;
+ idx++;
+ result += (Float.valueOf(parts[idx])) * 1000L;
+ return result;
+ }
+
+ public static String formatRFC822Date(Date date) {
+ SimpleDateFormat format = RFC822Formatter.get();
+ return format.format(date);
+ }
+
+ public static String formatRFC3339Local(Date date) {
+ SimpleDateFormat format = RFC3339Formatter.get();
+ format.applyPattern(RFC3339LOCAL);
+ String result = format.format(date);
+ format.applyPattern(RFC3339UTC);
+ return result;
+ }
+
+ public static String formatRFC3339UTC(Date date) {
+ SimpleDateFormat format = RFC3339Formatter.get();
+ format.applyPattern(RFC3339UTC);
+ return format.format(date);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java
new file mode 100644
index 000000000..d0fa3a5fc
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/syndication/util/SyndTypeUtils.java
@@ -0,0 +1,42 @@
+package de.danoeh.antennapod.syndication.util;
+
+import android.webkit.MimeTypeMap;
+import org.apache.commons.io.FilenameUtils;
+
+/** Utility class for handling MIME-Types of enclosures */
+public class SyndTypeUtils {
+
+ private final static String VALID_MIMETYPE = "audio/.*" + "|" + "video/.*"
+ + "|" + "application/ogg";
+
+ private SyndTypeUtils() {
+
+ }
+
+ public static boolean enclosureTypeValid(String type) {
+ if (type == null) {
+ return false;
+ } else {
+ return type.matches(VALID_MIMETYPE);
+ }
+ }
+
+ /**
+ * Should be used if mime-type of enclosure tag is not supported. This
+ * method will check if the mime-type of the file extension is supported. If
+ * the type is not supported, this method will return null.
+ */
+ public static String getValidMimeTypeFromUrl(String url) {
+ if (url != null) {
+ String extension = FilenameUtils.getExtension(url);
+ if (extension != null) {
+ String type = MimeTypeMap.getSingleton()
+ .getMimeTypeFromExtension(extension);
+ if (type != null && enclosureTypeValid(type)) {
+ return type;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java
new file mode 100644
index 000000000..9e1c50674
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/ChapterUtils.java
@@ -0,0 +1,261 @@
+package de.danoeh.antennapod.util;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.util.comparator.ChapterStartTimeComparator;
+import de.danoeh.antennapod.util.id3reader.ChapterReader;
+import de.danoeh.antennapod.util.id3reader.ID3ReaderException;
+import de.danoeh.antennapod.util.playback.Playable;
+import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentChapterReader;
+import de.danoeh.antennapod.util.vorbiscommentreader.VorbisCommentReaderException;
+import org.apache.commons.io.IOUtils;
+
+import java.io.*;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.List;
+
+/** Utility class for getting chapter data from media files. */
+public class ChapterUtils {
+ private static final String TAG = "ChapterUtils";
+
+ private ChapterUtils() {
+ }
+
+ /**
+ * Uses the download URL of a media object of a feeditem to read its ID3
+ * chapters.
+ */
+ public static void readID3ChaptersFromPlayableStreamUrl(Playable p) {
+ if (p != null && p.getStreamUrl() != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle());
+ InputStream in = null;
+ try {
+ URL url = new URL(p.getStreamUrl());
+ ChapterReader reader = new ChapterReader();
+
+ in = url.openStream();
+ reader.readInputStream(in);
+ List<Chapter> chapters = reader.getChapters();
+
+ if (chapters != null) {
+ Collections
+ .sort(chapters, new ChapterStartTimeComparator());
+ processChapters(chapters, p);
+ if (chaptersValid(chapters)) {
+ p.setChapters(chapters);
+ Log.i(TAG, "Chapters loaded");
+ } else {
+ Log.e(TAG, "Chapter data was invalid");
+ }
+ } else {
+ Log.i(TAG, "ChapterReader could not find any ID3 chapters");
+ }
+ } catch (MalformedURLException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (ID3ReaderException e) {
+ e.printStackTrace();
+ } finally {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ } else {
+ Log.e(TAG,
+ "Unable to read ID3 chapters: media or download URL was null");
+ }
+ }
+
+ /**
+ * Uses the file URL of a media object of a feeditem to read its ID3
+ * chapters.
+ */
+ public static void readID3ChaptersFromPlayableFileUrl(Playable p) {
+ if (p != null && p.localFileAvailable() && p.getLocalMediaUrl() != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle());
+ File source = new File(p.getLocalMediaUrl());
+ if (source.exists()) {
+ ChapterReader reader = new ChapterReader();
+ InputStream in = null;
+
+ try {
+ in = new BufferedInputStream(new FileInputStream(source));
+ reader.readInputStream(in);
+ List<Chapter> chapters = reader.getChapters();
+
+ if (chapters != null) {
+ Collections.sort(chapters,
+ new ChapterStartTimeComparator());
+ processChapters(chapters, p);
+ if (chaptersValid(chapters)) {
+ p.setChapters(chapters);
+ Log.i(TAG, "Chapters loaded");
+ } else {
+ Log.e(TAG, "Chapter data was invalid");
+ }
+ } else {
+ Log.i(TAG,
+ "ChapterReader could not find any ID3 chapters");
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (ID3ReaderException e) {
+ e.printStackTrace();
+ } finally {
+ if (in != null) {
+ try {
+ in.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ } else {
+ Log.e(TAG, "Unable to read id3 chapters: Source doesn't exist");
+ }
+ }
+ }
+
+ public static void readOggChaptersFromPlayableStreamUrl(Playable media) {
+ if (media != null && media.streamAvailable()) {
+ InputStream input = null;
+ try {
+ URL url = new URL(media.getStreamUrl());
+ input = url.openStream();
+ if (input != null) {
+ readOggChaptersFromInputStream(media, input);
+ }
+ } catch (MalformedURLException e) {
+ e.printStackTrace();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ IOUtils.closeQuietly(input);
+ }
+ }
+ }
+
+ public static void readOggChaptersFromPlayableFileUrl(Playable media) {
+ if (media != null && media.getLocalMediaUrl() != null) {
+ File source = new File(media.getLocalMediaUrl());
+ if (source.exists()) {
+ InputStream input = null;
+ try {
+ input = new BufferedInputStream(new FileInputStream(source));
+ readOggChaptersFromInputStream(media, input);
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ } finally {
+ IOUtils.closeQuietly(input);
+ }
+ }
+ }
+ }
+
+ private static void readOggChaptersFromInputStream(Playable p,
+ InputStream input) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Trying to read chapters from item with title "
+ + p.getEpisodeTitle());
+ try {
+ VorbisCommentChapterReader reader = new VorbisCommentChapterReader();
+ reader.readInputStream(input);
+ List<Chapter> chapters = reader.getChapters();
+ if (chapters != null) {
+ Collections.sort(chapters, new ChapterStartTimeComparator());
+ processChapters(chapters, p);
+ if (chaptersValid(chapters)) {
+ p.setChapters(chapters);
+ Log.i(TAG, "Chapters loaded");
+ } else {
+ Log.e(TAG, "Chapter data was invalid");
+ }
+ } else {
+ Log.i(TAG,
+ "ChapterReader could not find any Ogg vorbis chapters");
+ }
+ } catch (VorbisCommentReaderException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /** Makes sure that chapter does a title and an item attribute. */
+ private static void processChapters(List<Chapter> chapters, Playable p) {
+ for (int i = 0; i < chapters.size(); i++) {
+ Chapter c = chapters.get(i);
+ if (c.getTitle() == null) {
+ c.setTitle(Integer.toString(i));
+ }
+ }
+ }
+
+ private static boolean chaptersValid(List<Chapter> chapters) {
+ if (chapters.isEmpty()) {
+ return false;
+ }
+ for (Chapter c : chapters) {
+ if (c.getTitle() == null) {
+ return false;
+ }
+ if (c.getStart() < 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Calls getCurrentChapter with current position. */
+ public static Chapter getCurrentChapter(Playable media) {
+ if (media.getChapters() != null) {
+ List<Chapter> chapters = media.getChapters();
+ Chapter current = null;
+ if (chapters != null) {
+ current = chapters.get(0);
+ for (Chapter sc : chapters) {
+ if (sc.getStart() > media.getPosition()) {
+ break;
+ } else {
+ current = sc;
+ }
+ }
+ }
+ return current;
+ } else {
+ return null;
+ }
+ }
+
+ public static void loadChaptersFromStreamUrl(Playable media) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Starting chapterLoader thread");
+ ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media);
+ if (media.getChapters() == null) {
+ ChapterUtils.readOggChaptersFromPlayableStreamUrl(media);
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "ChapterLoaderThread has finished");
+ }
+
+ public static void loadChaptersFromFileUrl(Playable media) {
+ if (media.localFileAvailable()) {
+ ChapterUtils.readID3ChaptersFromPlayableFileUrl(media);
+ if (media.getChapters() == null) {
+ ChapterUtils.readOggChaptersFromPlayableFileUrl(media);
+ }
+ } else {
+ Log.e(TAG, "Could not load chapters from file url: local file not available");
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/Converter.java b/app/src/main/java/de/danoeh/antennapod/util/Converter.java
new file mode 100644
index 000000000..f4c2b2f66
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/Converter.java
@@ -0,0 +1,103 @@
+package de.danoeh.antennapod.util;
+
+import android.util.Log;
+
+/** Provides methods for converting various units. */
+public final class Converter {
+ /** Class shall not be instantiated. */
+ private Converter() {
+ }
+
+ /** Logging tag. */
+ private static final String TAG = "Converter";
+
+
+ /** Indicates that the value is in the Byte range.*/
+ private static final int B_RANGE = 0;
+ /** Indicates that the value is in the Kilobyte range.*/
+ private static final int KB_RANGE = 1;
+ /** Indicates that the value is in the Megabyte range.*/
+ private static final int MB_RANGE = 2;
+ /** Indicates that the value is in the Gigabyte range.*/
+ private static final int GB_RANGE = 3;
+ /** Determines the length of the number for best readability.*/
+ private static final int NUM_LENGTH = 1024;
+
+
+ private static final int HOURS_MIL = 3600000;
+ private static final int MINUTES_MIL = 60000;
+ private static final int SECONDS_MIL = 1000;
+
+ /** Takes a byte-value and converts it into a more readable
+ * String.
+ * @param input The value to convert
+ * @return The converted String with a unit
+ * */
+ public static String byteToString(final long input) {
+ int i = 0;
+ int result = 0;
+
+ for (i = 0; i < GB_RANGE + 1; i++) {
+ result = (int) (input / Math.pow(1024, i));
+ if (result < NUM_LENGTH) {
+ break;
+ }
+ }
+
+ switch (i) {
+ case B_RANGE:
+ return result + " B";
+ case KB_RANGE:
+ return result + " KB";
+ case MB_RANGE:
+ return result + " MB";
+ case GB_RANGE:
+ return result + " GB";
+ default:
+ Log.e(TAG, "Error happened in byteToString");
+ return "ERROR";
+ }
+ }
+
+ /** Converts milliseconds to a string containing hours, minutes and seconds */
+ public static String getDurationStringLong(int duration) {
+ int h = duration / HOURS_MIL;
+ int rest = duration - h * HOURS_MIL;
+ int m = rest / MINUTES_MIL;
+ rest -= m * MINUTES_MIL;
+ int s = rest / SECONDS_MIL;
+
+ return String.format("%02d:%02d:%02d", h, m, s);
+ }
+
+ /** Converts milliseconds to a string containing hours and minutes */
+ public static String getDurationStringShort(int duration) {
+ int h = duration / HOURS_MIL;
+ int rest = duration - h * HOURS_MIL;
+ int m = rest / MINUTES_MIL;
+
+ return String.format("%02d:%02d", h, m);
+ }
+
+ /** Converts long duration string (HH:MM:SS) to milliseconds. */
+ public static int durationStringLongToMs(String input) {
+ String[] parts = input.split(":");
+ if (parts.length != 3) {
+ return 0;
+ }
+ return Integer.valueOf(parts[0]) * 3600 * 1000 +
+ Integer.valueOf(parts[1]) * 60 * 1000 +
+ Integer.valueOf(parts[2]) * 1000;
+ }
+
+ /** Converts short duration string (HH:MM) to milliseconds. */
+ public static int durationStringShortToMs(String input) {
+ String[] parts = input.split(":");
+ if (parts.length != 2) {
+ return 0;
+ }
+ return Integer.valueOf(parts[0]) * 3600 * 1000 +
+ Integer.valueOf(parts[1]) * 1000 * 60;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java b/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java
new file mode 100644
index 000000000..1a64991a6
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/DownloadError.java
@@ -0,0 +1,52 @@
+package de.danoeh.antennapod.util;
+
+import android.content.Context;
+import de.danoeh.antennapod.R;
+
+/** Utility class for Download Errors. */
+public enum DownloadError {
+ SUCCESS(0, R.string.download_successful),
+ ERROR_PARSER_EXCEPTION(1, R.string.download_error_parser_exception),
+ ERROR_UNSUPPORTED_TYPE(2, R.string.download_error_unsupported_type),
+ ERROR_CONNECTION_ERROR(3, R.string.download_error_connection_error),
+ ERROR_MALFORMED_URL(4, R.string.download_error_error_unknown),
+ ERROR_IO_ERROR(5, R.string.download_error_io_error),
+ ERROR_FILE_EXISTS(6, R.string.download_error_error_unknown),
+ ERROR_DOWNLOAD_CANCELLED(7, R.string.download_error_error_unknown),
+ ERROR_DEVICE_NOT_FOUND(8, R.string.download_error_device_not_found),
+ ERROR_HTTP_DATA_ERROR(9, R.string.download_error_http_data_error),
+ ERROR_NOT_ENOUGH_SPACE(10, R.string.download_error_insufficient_space),
+ ERROR_UNKNOWN_HOST(11, R.string.download_error_unknown_host),
+ ERROR_REQUEST_ERROR(12, R.string.download_error_request_error),
+ ERROR_DB_ACCESS_ERROR(13, R.string.download_error_db_access),
+ ERROR_UNAUTHORIZED(14, R.string.download_error_unauthorized);
+
+ private final int code;
+ private final int resId;
+
+ private DownloadError(int code, int resId) {
+ this.code = code;
+ this.resId = resId;
+ }
+
+ /** Return DownloadError from its associated code. */
+ public static DownloadError fromCode(int code) {
+ for (DownloadError reason : values()) {
+ if (reason.getCode() == code) {
+ return reason;
+ }
+ }
+ throw new IllegalArgumentException("unknown code: " + code);
+ }
+
+ /** Get machine-readable code. */
+ public int getCode() {
+ return code;
+ }
+
+ /** Get a human-readable string. */
+ public String getErrorString(Context context) {
+ return context.getString(resId);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/DuckType.java b/app/src/main/java/de/danoeh/antennapod/util/DuckType.java
new file mode 100644
index 000000000..163110418
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/DuckType.java
@@ -0,0 +1,117 @@
+/* Adapted from: http://thinking-in-code.blogspot.com/2008/11/duck-typing-in-java-using-dynamic.html */
+
+package de.danoeh.antennapod.util;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+import de.danoeh.antennapod.BuildConfig;
+
+/**
+ * Allows "duck typing" or dynamic invocation based on method signature rather
+ * than type hierarchy. In other words, rather than checking whether something
+ * IS-a duck, check whether it WALKS-like-a duck or QUACKS-like a duck.
+ *
+ * To use first use the coerce static method to indicate the object you want to
+ * do Duck Typing for, then specify an interface to the to method which you want
+ * to coerce the type to, e.g:
+ *
+ * public interface Foo { void aMethod(); } class Bar { ... public void
+ * aMethod() { ... } ... } Bar bar = ...; Foo foo =
+ * DuckType.coerce(bar).to(Foo.class); foo.aMethod();
+ *
+ *
+ */
+public class DuckType {
+
+ private final Object objectToCoerce;
+
+ private DuckType(Object objectToCoerce) {
+ this.objectToCoerce = objectToCoerce;
+ }
+
+ private class CoercedProxy implements InvocationHandler {
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+ Method delegateMethod = findMethodBySignature(method);
+ assert delegateMethod != null;
+ return delegateMethod.invoke(DuckType.this.objectToCoerce, args);
+ }
+ }
+
+ /**
+ * Specify the duck typed object to coerce.
+ *
+ * @param object
+ * the object to coerce
+ * @return
+ */
+ public static DuckType coerce(Object object) {
+ return new DuckType(object);
+ }
+
+ /**
+ * Coerce the Duck Typed object to the given interface providing it
+ * implements all the necessary methods.
+ *
+ * @param
+ * @param iface
+ * @return an instance of the given interface that wraps the duck typed
+ * class
+ * @throws ClassCastException
+ * if the object being coerced does not implement all the
+ * methods in the given interface.
+ */
+ @SuppressWarnings({ "rawtypes", "unchecked" })
+ public <T> T to(Class iface) {
+ if (BuildConfig.DEBUG && !iface.isInterface()) throw new AssertionError("cannot coerce object to a class, must be an interface");
+ if (isA(iface)) {
+ return (T) iface.cast(objectToCoerce);
+ }
+ if (quacksLikeA(iface)) {
+ return generateProxy(iface);
+ }
+ throw new ClassCastException("Could not coerce object of type " + objectToCoerce.getClass() + " to " + iface);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private boolean isA(Class iface) {
+ return objectToCoerce.getClass().isInstance(iface);
+ }
+
+ /**
+ * Determine whether the duck typed object can be used with the given
+ * interface.
+ *
+ * @param Type
+ * of the interface to check.
+ * @param iface
+ * Interface class to check
+ * @return true if the object will support all the methods in the interface,
+ * false otherwise.
+ */
+ @SuppressWarnings("rawtypes")
+ public boolean quacksLikeA(Class iface) {
+ for (Method method : iface.getMethods()) {
+ if (findMethodBySignature(method) == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @SuppressWarnings({ "unchecked", "rawtypes" })
+ private <T> T generateProxy(Class iface) {
+ return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[] { iface }, new CoercedProxy());
+ }
+
+ private Method findMethodBySignature(Method method) {
+ try {
+ return objectToCoerce.getClass().getMethod(method.getName(), method.getParameterTypes());
+ } catch (NoSuchMethodException e) {
+ return null;
+ }
+ }
+
+} \ No newline at end of file
diff --git a/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java b/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java
new file mode 100644
index 000000000..115913bca
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/EpisodeFilter.java
@@ -0,0 +1,49 @@
+package de.danoeh.antennapod.util;
+
+import de.danoeh.antennapod.feed.FeedItem;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class EpisodeFilter {
+ private EpisodeFilter() {
+
+ }
+
+ /** Return a copy of the itemlist without items which have no media. */
+ public static ArrayList<FeedItem> getEpisodeList(List<FeedItem> items) {
+ ArrayList<FeedItem> episodes = new ArrayList<FeedItem>(items);
+ for (FeedItem item : items) {
+ if (item.getMedia() == null) {
+ episodes.remove(item);
+ }
+ }
+ return episodes;
+ }
+
+ public static int countItemsWithEpisodes(List<FeedItem> items) {
+ int count = 0;
+ for (FeedItem item : items) {
+ if (item.getMedia() != null) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public static FeedItem accessEpisodeByIndex(List<FeedItem> items,
+ int position) {
+ int count = 0;
+ for (FeedItem item : items) {
+
+ if (item.getMedia() != null) {
+ if (count == position) {
+ return item;
+ } else {
+ count++;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java b/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java
new file mode 100644
index 000000000..112b6678d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/FeedtitleComparator.java
@@ -0,0 +1,15 @@
+package de.danoeh.antennapod.util;
+
+import de.danoeh.antennapod.feed.Feed;
+
+import java.util.Comparator;
+
+/** Compares the title of two feeds for sorting. */
+public class FeedtitleComparator implements Comparator<Feed> {
+
+ @Override
+ public int compare(Feed lhs, Feed rhs) {
+ return lhs.getTitle().compareToIgnoreCase(rhs.getTitle());
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java b/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java
new file mode 100644
index 000000000..702df62b8
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/FileNameGenerator.java
@@ -0,0 +1,36 @@
+package de.danoeh.antennapod.util;
+
+import java.util.Arrays;
+
+/** Generates valid filenames for a given string. */
+public class FileNameGenerator {
+
+ private static final char[] ILLEGAL_CHARACTERS = { '/', '\\', '?', '%',
+ '*', ':', '|', '"', '<', '>' };
+ static {
+ Arrays.sort(ILLEGAL_CHARACTERS);
+ }
+
+ private FileNameGenerator() {
+
+ }
+
+ /**
+ * This method will return a new string that doesn't contain any illegal
+ * characters of the given string.
+ */
+ public static String generateFileName(String string) {
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < string.length(); i++) {
+ char c = string.charAt(i);
+ if (Arrays.binarySearch(ILLEGAL_CHARACTERS, c) < 0) {
+ builder.append(c);
+ }
+ }
+ return builder.toString().replaceFirst(" *$","");
+ }
+
+ public static long generateLong(final String str) {
+ return str.hashCode();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java b/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java
new file mode 100644
index 000000000..50adae216
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/InvalidFeedException.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.util;
+
+/** Thrown if a feed has invalid attribute values. */
+public class InvalidFeedException extends Exception {
+
+ public InvalidFeedException() {
+ }
+
+ public InvalidFeedException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public InvalidFeedException(Throwable throwable) {
+ super(throwable);
+ }
+
+ public InvalidFeedException(String detailMessage, Throwable throwable) {
+ super(detailMessage, throwable);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java b/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java
new file mode 100644
index 000000000..e6e1d8399
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/LangUtils.java
@@ -0,0 +1,120 @@
+package de.danoeh.antennapod.util;
+
+import java.nio.charset.Charset;
+import java.util.HashMap;
+
+public class LangUtils {
+ public static final Charset UTF_8 = Charset.forName("UTF-8");
+
+ private static HashMap<String, String> languages;
+ static {
+ languages = new HashMap<String, String>();
+ languages.put("af", "Afrikaans");
+ languages.put("sq", "Albanian");
+ languages.put("sq", "Albanian");
+ languages.put("eu", "Basque");
+ languages.put("be", "Belarusian");
+ languages.put("bg", "Bulgarian");
+ languages.put("ca", "Catalan");
+ languages.put("Chinese (Simplified)", "zh-cn");
+ languages.put("Chinese (Traditional)", "zh-tw");
+ languages.put("hr", "Croatian");
+ languages.put("cs", "Czech");
+ languages.put("da", "Danish");
+ languages.put("nl", "Dutch");
+ languages.put("nl-be", "Dutch (Belgium)");
+ languages.put("nl-nl", "Dutch (Netherlands)");
+ languages.put("en", "English");
+ languages.put("en-au", "English (Australia)");
+ languages.put("en-bz", "English (Belize)");
+ languages.put("en-ca", "English (Canada)");
+ languages.put("en-ie", "English (Ireland)");
+ languages.put("en-jm", "English (Jamaica)");
+ languages.put("en-nz", "English (New Zealand)");
+ languages.put("en-ph", "English (Phillipines)");
+ languages.put("en-za", "English (South Africa)");
+ languages.put("en-tt", "English (Trinidad)");
+ languages.put("en-gb", "English (United Kingdom)");
+ languages.put("en-us", "English (United States)");
+ languages.put("en-zw", "English (Zimbabwe)");
+ languages.put("et", "Estonian");
+ languages.put("fo", "Faeroese");
+ languages.put("fi", "Finnish");
+ languages.put("fr", "French");
+ languages.put("fr-be", "French (Belgium)");
+ languages.put("fr-ca", "French (Canada)");
+ languages.put("fr-fr", "French (France)");
+ languages.put("fr-lu", "French (Luxembourg)");
+ languages.put("fr-mc", "French (Monaco)");
+ languages.put("fr-ch", "French (Switzerland)");
+ languages.put("gl", "Galician");
+ languages.put("gd", "Gaelic");
+ languages.put("de", "German");
+ languages.put("de-at", "German (Austria)");
+ languages.put("de-de", "German (Germany)");
+ languages.put("de-li", "German (Liechtenstein)");
+ languages.put("de-lu", "German (Luxembourg)");
+ languages.put("de-ch", "German (Switzerland)");
+ languages.put("el", "Greek");
+ languages.put("haw", "Hawaiian");
+ languages.put("hu", "Hungarian");
+ languages.put("is", "Icelandic");
+ languages.put("in", "Indonesian");
+ languages.put("ga", "Irish");
+ languages.put("it", "Italian");
+ languages.put("it-it", "Italian (Italy)");
+ languages.put("it-ch", "Italian (Switzerland)");
+ languages.put("ja", "Japanese");
+ languages.put("ko", "Korean");
+ languages.put("mk", "Macedonian");
+ languages.put("no", "Norwegian");
+ languages.put("pl", "Polish");
+ languages.put("pt", "Portugese");
+ languages.put("pt-br", "Portugese (Brazil)");
+ languages.put("pt-pt", "Portugese (Portugal");
+ languages.put("ro", "Romanian");
+ languages.put("ro-mo", "Romanian (Moldova)");
+ languages.put("ro-ro", "Romanian (Romania");
+ languages.put("ru", "Russian");
+ languages.put("ru-mo", "Russian (Moldova)");
+ languages.put("ru-ru", "Russian (Russia)");
+ languages.put("sr", "Serbian");
+ languages.put("sk", "Slovak");
+ languages.put("sl", "Slovenian");
+ languages.put("es", "Spanish");
+ languages.put("es-ar", "Spanish (Argentinia)");
+ languages.put("es=bo", "Spanish (Bolivia)");
+ languages.put("es-cl", "Spanish (Chile)");
+ languages.put("es-co", "Spanish (Colombia)");
+ languages.put("es-cr", "Spanish (Costa Rica)");
+ languages.put("es-do", "Spanish (Dominican Republic)");
+ languages.put("es-ec", "Spanish (Ecuador)");
+ languages.put("es-sv", "Spanish (El Salvador)");
+ languages.put("es-gt", "Spanish (Guatemala)");
+ languages.put("es-hn", "Spanish (Honduras)");
+ languages.put("es-mx", "Spanish (Mexico)");
+ languages.put("es-ni", "Spanish (Nicaragua)");
+ languages.put("es-pa", "Spanish (Panama)");
+ languages.put("es-py", "Spanish (Paraguay)");
+ languages.put("es-pe", "Spanish (Peru)");
+ languages.put("es-pr", "Spanish (Puerto Rico)");
+ languages.put("es-es", "Spanish (Spain)");
+ languages.put("es-uy", "Spanish (Uruguay)");
+ languages.put("es-ve", "Spanish (Venezuela)");
+ languages.put("sv", "Swedish");
+ languages.put("sv-fi", "Swedish (Finland)");
+ languages.put("sv-se", "Swedish (Sweden)");
+ languages.put("tr", "Turkish");
+ languages.put("uk", "Ukranian");
+ }
+
+ /** Finds language string for key or returns the language key if it can't be found. */
+ public static String getLanguageString(String key) {
+ String language = languages.get(key);
+ if (language != null) {
+ return language;
+ } else {
+ return key;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java b/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java
new file mode 100644
index 000000000..0c8065e94
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/NetworkUtils.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.util;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.wifi.WifiInfo;
+import android.net.wifi.WifiManager;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class NetworkUtils {
+ private static final String TAG = "NetworkUtils";
+
+ private NetworkUtils() {
+
+ }
+
+ /**
+ * Returns true if the device is connected to Wi-Fi and the Wi-Fi filter for
+ * automatic downloads is disabled or the device is connected to a Wi-Fi
+ * network that is on the 'selected networks' list of the Wi-Fi filter for
+ * automatic downloads and false otherwise.
+ * */
+ public static boolean autodownloadNetworkAvailable(Context context) {
+ ConnectivityManager cm = (ConnectivityManager) context
+ .getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = cm.getActiveNetworkInfo();
+ if (networkInfo != null) {
+ if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Device is connected to Wi-Fi");
+ if (networkInfo.isConnected()) {
+ if (!UserPreferences.isEnableAutodownloadWifiFilter()) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Auto-dl filter is disabled");
+ return true;
+ } else {
+ WifiManager wm = (WifiManager) context
+ .getSystemService(Context.WIFI_SERVICE);
+ WifiInfo wifiInfo = wm.getConnectionInfo();
+ List<String> selectedNetworks = Arrays
+ .asList(UserPreferences
+ .getAutodownloadSelectedNetworks());
+ if (selectedNetworks.contains(Integer.toString(wifiInfo
+ .getNetworkId()))) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "Current network is on the selected networks list");
+ return true;
+ }
+ }
+ }
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Network for auto-dl is not available");
+ return false;
+ }
+
+ public static boolean networkAvailable(Context context) {
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo info = cm.getActiveNetworkInfo();
+ return info != null && info.isConnected();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java b/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java
new file mode 100644
index 000000000..7a1c7fef2
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/QueueAccess.java
@@ -0,0 +1,93 @@
+package de.danoeh.antennapod.util;
+
+import de.danoeh.antennapod.feed.FeedItem;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Provides methods for accessing the queue. It is possible to load only a part of the information about the queue that
+ * is stored in the database (e.g. sometimes the user just has to test if a specific item is contained in the List.
+ * QueueAccess provides an interface for accessing the queue without having to care about the type of the queue
+ * representation.
+ */
+public abstract class QueueAccess {
+ /**
+ * Returns true if the item is in the queue, false otherwise.
+ */
+ public abstract boolean contains(long id);
+
+ /**
+ * Removes the item from the queue.
+ *
+ * @return true if the queue was modified by this operation.
+ */
+ public abstract boolean remove(long id);
+
+ private QueueAccess() {
+
+ }
+
+ public static QueueAccess IDListAccess(final List<Long> ids) {
+ return new QueueAccess() {
+ @Override
+ public boolean contains(long id) {
+ return (ids != null) && ids.contains(id);
+ }
+
+ @Override
+ public boolean remove(long id) {
+ return ids.remove(id);
+ }
+
+
+ };
+ }
+
+ public static QueueAccess ItemListAccess(final List<FeedItem> items) {
+ return new QueueAccess() {
+ @Override
+ public boolean contains(long id) {
+ if (items == null) {
+ return false;
+ }
+ for (FeedItem item : items) {
+ if (item.getId() == id) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean remove(long id) {
+ Iterator<FeedItem> it = items.iterator();
+ FeedItem item;
+ while (it.hasNext()) {
+ item = it.next();
+ if (item.getId() == id) {
+ it.remove();
+ return true;
+ }
+ }
+ return false;
+ }
+ };
+ }
+
+ public static QueueAccess NotInQueueAccess() {
+ return new QueueAccess() {
+ @Override
+ public boolean contains(long id) {
+ return false;
+ }
+
+ @Override
+ public boolean remove(long id) {
+ return false;
+ }
+ };
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java
new file mode 100644
index 000000000..941fc62ae
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/ShareUtils.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.util;
+
+import android.content.Context;
+import android.content.Intent;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.feed.FeedItem;
+
+/** Utility methods for sharing data */
+public class ShareUtils {
+ private static final String TAG = "ShareUtils";
+
+ private ShareUtils() {}
+
+ public static void shareLink(Context context, String link) {
+ Intent i = new Intent(Intent.ACTION_SEND);
+ i.setType("text/plain");
+ i.putExtra(Intent.EXTRA_SUBJECT, "Sharing URL");
+ i.putExtra(Intent.EXTRA_TEXT, link);
+ context.startActivity(Intent.createChooser(i, "Share URL"));
+ }
+
+ public static void shareFeedItemLink(Context context, FeedItem item) {
+ shareLink(context, item.getLink());
+ }
+
+ public static void shareFeedDownloadLink(Context context, Feed feed) {
+ shareLink(context, feed.getDownload_url());
+ }
+
+ public static void shareFeedlink(Context context, Feed feed) {
+ shareLink(context, feed.getLink());
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java b/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java
new file mode 100644
index 000000000..8345ca34d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/ShownotesProvider.java
@@ -0,0 +1,16 @@
+package de.danoeh.antennapod.util;
+
+import java.util.concurrent.Callable;
+
+/**
+ * Created by daniel on 04.08.13.
+ */
+public interface ShownotesProvider {
+ /**
+ * Loads shownotes. If the shownotes have to be loaded from a file or from a
+ * database, it should be done in a separate thread. After the shownotes
+ * have been loaded, callback.onShownotesLoaded should be called.
+ */
+ public Callable<String> loadShownotes();
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java b/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java
new file mode 100644
index 000000000..ff0bde280
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/StorageUtils.java
@@ -0,0 +1,66 @@
+package de.danoeh.antennapod.util;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.StatFs;
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.activity.StorageErrorActivity;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+import java.io.File;
+
+/** Utility functions for handling storage errors */
+public class StorageUtils {
+ private static final String TAG = "StorageUtils";
+
+ public static boolean storageAvailable(Context context) {
+ File dir = UserPreferences.getDataFolder(context, null);
+ if (dir != null) {
+ return dir.exists() && dir.canRead() && dir.canWrite();
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Storage not available: data folder is null");
+ return false;
+ }
+ }
+
+ /**
+ * Checks if external storage is available. If external storage isn't
+ * available, the current activity is finsished an an error activity is
+ * launched.
+ *
+ * @param activity
+ * the activity which would be finished if no storage is
+ * available
+ * @return true if external storage is available
+ */
+ public static boolean checkStorageAvailability(Activity activity) {
+ boolean storageAvailable = storageAvailable(activity);
+ if (!storageAvailable) {
+ activity.finish();
+ activity.startActivity(new Intent(activity,
+ StorageErrorActivity.class));
+ }
+ return storageAvailable;
+ }
+
+ /** Get the number of free bytes that are available on the external storage. */
+ public static long getFreeSpaceAvailable() {
+ StatFs stat = new StatFs(UserPreferences.getDataFolder(
+ PodcastApp.getInstance(), null).getAbsolutePath());
+ long availableBlocks;
+ long blockSize;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+ availableBlocks = stat.getAvailableBlocksLong();
+ blockSize = stat.getBlockSizeLong();
+ } else {
+ availableBlocks = stat.getAvailableBlocks();
+ blockSize = stat.getBlockSize();
+ }
+ return availableBlocks * blockSize;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java b/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java
new file mode 100644
index 000000000..8e593f3fb
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/ThemeUtils.java
@@ -0,0 +1,22 @@
+package de.danoeh.antennapod.util;
+
+import android.util.Log;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.preferences.UserPreferences;
+
+public class ThemeUtils {
+ private static final String TAG = "ThemeUtils";
+
+ public static int getSelectionBackgroundColor() {
+ switch (UserPreferences.getTheme()) {
+ case R.style.Theme_AntennaPod_Dark:
+ return R.color.selection_background_color_dark;
+ case R.style.Theme_AntennaPod_Light:
+ return R.color.selection_background_color_light;
+ default:
+ Log.e(TAG,
+ "getSelectionBackgroundColor could not match the current theme to any color!");
+ return R.color.selection_background_color_light;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java b/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java
new file mode 100644
index 000000000..5af40d591
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/URIUtil.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.util;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/**
+ * Utility methods for dealing with URL encoding.
+ */
+public class URIUtil {
+ private static final String TAG = "URIUtil";
+
+ private URIUtil() {}
+
+ public static URI getURIFromRequestUrl(String source) {
+ // try without encoding the URI
+ try {
+ return new URI(source);
+ } catch (URISyntaxException e) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Source is not encoded, encoding now");
+ }
+ try {
+ URL url = new URL(source);
+ return new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef());
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException(e);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java b/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java
new file mode 100644
index 000000000..9997daaf7
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/URLChecker.java
@@ -0,0 +1,51 @@
+package de.danoeh.antennapod.util;
+
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.danoeh.antennapod.BuildConfig;
+
+/**
+ * Provides methods for checking and editing a URL.
+ */
+public final class URLChecker {
+
+ /**
+ * Class shall not be instantiated.
+ */
+ private URLChecker() {
+ }
+
+ /**
+ * Logging tag.
+ */
+ private static final String TAG = "URLChecker";
+
+ /**
+ * Checks if URL is valid and modifies it if necessary.
+ *
+ * @param url The url which is going to be prepared
+ * @return The prepared url
+ */
+ public static String prepareURL(String url) {
+ StringBuilder builder = new StringBuilder();
+ url = StringUtils.trim(url);
+ if (url.startsWith("feed://")) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Replacing feed:// with http://");
+ url = url.replaceFirst("feed://", "http://");
+ } else if (url.startsWith("pcast://")) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Replacing pcast:// with http://");
+ url = url.replaceFirst("pcast://", "http://");
+ } else if (url.startsWith("itpc")) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Replacing itpc:// with http://");
+ url = url.replaceFirst("itpc://", "http://");
+ } else if (!(url.startsWith("http://") || url.startsWith("https://"))) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Adding http:// at the beginning of the URL");
+ builder.append("http://");
+ }
+ builder.append(url);
+
+ return builder.toString();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java b/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java
new file mode 100644
index 000000000..332cc22e0
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/UndoBarController.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2012 Roman Nurik
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package de.danoeh.antennapod.util;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.TextView;
+import com.nineoldandroids.animation.Animator;
+import com.nineoldandroids.animation.AnimatorListenerAdapter;
+import com.nineoldandroids.view.ViewHelper;
+import com.nineoldandroids.view.ViewPropertyAnimator;
+import de.danoeh.antennapod.R;
+
+import static com.nineoldandroids.view.ViewPropertyAnimator.animate;
+
+public class UndoBarController {
+ private View mBarView;
+ private TextView mMessageView;
+ private ViewPropertyAnimator mBarAnimator;
+ private Handler mHideHandler = new Handler();
+
+ private UndoListener mUndoListener;
+
+ // State objects
+ private Parcelable mUndoToken;
+ private CharSequence mUndoMessage;
+
+ public interface UndoListener {
+ void onUndo(Parcelable token);
+ }
+
+ public UndoBarController(View undoBarView, UndoListener undoListener) {
+ mBarView = undoBarView;
+ mBarAnimator = animate(mBarView);
+ mUndoListener = undoListener;
+
+ mMessageView = (TextView) mBarView.findViewById(R.id.undobar_message);
+ mBarView.findViewById(R.id.undobar_button)
+ .setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ hideUndoBar(false);
+ mUndoListener.onUndo(mUndoToken);
+ }
+ });
+
+ hideUndoBar(true);
+ }
+
+ public void showUndoBar(boolean immediate, CharSequence message, Parcelable undoToken) {
+ mUndoToken = undoToken;
+ mUndoMessage = message;
+ mMessageView.setText(mUndoMessage);
+
+ mHideHandler.removeCallbacks(mHideRunnable);
+ mHideHandler.postDelayed(mHideRunnable,
+ mBarView.getResources().getInteger(R.integer.undobar_hide_delay));
+
+ mBarView.setVisibility(View.VISIBLE);
+ if (immediate) {
+ ViewHelper.setAlpha(mBarView, 1);
+ } else {
+ mBarAnimator.cancel();
+ mBarAnimator
+ .alpha(1)
+ .setDuration(
+ mBarView.getResources()
+ .getInteger(android.R.integer.config_shortAnimTime))
+ .setListener(null);
+ }
+ }
+
+ public void hideUndoBar(boolean immediate) {
+ mHideHandler.removeCallbacks(mHideRunnable);
+ if (immediate) {
+ mBarView.setVisibility(View.GONE);
+ ViewHelper.setAlpha(mBarView, 0);
+ mUndoMessage = null;
+ mUndoToken = null;
+
+ } else {
+ mBarAnimator.cancel();
+ mBarAnimator
+ .alpha(0)
+ .setDuration(mBarView.getResources()
+ .getInteger(android.R.integer.config_shortAnimTime))
+ .setListener(new AnimatorListenerAdapter() {
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ mBarView.setVisibility(View.GONE);
+ mUndoMessage = null;
+ mUndoToken = null;
+ }
+ });
+ }
+ }
+
+ public void onSaveInstanceState(Bundle outState) {
+ outState.putCharSequence("undo_message", mUndoMessage);
+ outState.putParcelable("undo_token", mUndoToken);
+ }
+
+ public void onRestoreInstanceState(Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ mUndoMessage = savedInstanceState.getCharSequence("undo_message");
+ mUndoToken = savedInstanceState.getParcelable("undo_token");
+
+ if (mUndoToken != null || !TextUtils.isEmpty(mUndoMessage)) {
+ showUndoBar(true, mUndoMessage, mUndoToken);
+ }
+ }
+ }
+
+ private Runnable mHideRunnable = new Runnable() {
+ @Override
+ public void run() {
+ hideUndoBar(false);
+ }
+ };
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java
new file mode 100644
index 000000000..bfc2fd057
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/ChapterStartTimeComparator.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.util.comparator;
+
+import de.danoeh.antennapod.feed.Chapter;
+
+import java.util.Comparator;
+
+public class ChapterStartTimeComparator implements Comparator<Chapter> {
+
+ @Override
+ public int compare(Chapter lhs, Chapter rhs) {
+ if (lhs.getStart() == rhs.getStart()) {
+ return 0;
+ } else if (lhs.getStart() < rhs.getStart()) {
+ return -1;
+ } else {
+ return 1;
+ }
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java
new file mode 100644
index 000000000..14b8f1194
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/DownloadStatusComparator.java
@@ -0,0 +1,15 @@
+package de.danoeh.antennapod.util.comparator;
+
+import de.danoeh.antennapod.service.download.DownloadStatus;
+
+import java.util.Comparator;
+
+/** Compares the completion date of two Downloadstatus objects. */
+public class DownloadStatusComparator implements Comparator<DownloadStatus> {
+
+ @Override
+ public int compare(DownloadStatus lhs, DownloadStatus rhs) {
+ return rhs.getCompletionDate().compareTo(lhs.getCompletionDate());
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java
new file mode 100644
index 000000000..f92c23d05
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/FeedItemPubdateComparator.java
@@ -0,0 +1,19 @@
+package de.danoeh.antennapod.util.comparator;
+
+import de.danoeh.antennapod.feed.FeedItem;
+
+import java.util.Comparator;
+
+/** Compares the pubDate of two FeedItems for sorting*/
+public class FeedItemPubdateComparator implements Comparator<FeedItem> {
+
+ /** Returns a new instance of this comparator in reverse order.
+ public static FeedItemPubdateComparator newInstance() {
+ FeedItemPubdateComparator
+ }*/
+ @Override
+ public int compare(FeedItem lhs, FeedItem rhs) {
+ return rhs.getPubDate().compareTo(lhs.getPubDate());
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java
new file mode 100644
index 000000000..0147e0cdc
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/PlaybackCompletionDateComparator.java
@@ -0,0 +1,19 @@
+package de.danoeh.antennapod.util.comparator;
+
+import de.danoeh.antennapod.feed.FeedItem;
+
+import java.util.Comparator;
+
+public class PlaybackCompletionDateComparator implements Comparator<FeedItem> {
+
+ public int compare(FeedItem lhs, FeedItem rhs) {
+ if (lhs.getMedia() != null
+ && lhs.getMedia().getPlaybackCompletionDate() != null
+ && rhs.getMedia() != null
+ && rhs.getMedia().getPlaybackCompletionDate() != null) {
+ return rhs.getMedia().getPlaybackCompletionDate()
+ .compareTo(lhs.getMedia().getPlaybackCompletionDate());
+ }
+ return 0;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java b/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java
new file mode 100644
index 000000000..02b084a01
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/comparator/SearchResultValueComparator.java
@@ -0,0 +1,14 @@
+package de.danoeh.antennapod.util.comparator;
+
+import de.danoeh.antennapod.feed.SearchResult;
+
+import java.util.Comparator;
+
+public class SearchResultValueComparator implements Comparator<SearchResult> {
+
+ @Override
+ public int compare(SearchResult lhs, SearchResult rhs) {
+ return rhs.getValue() - lhs.getValue();
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java b/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java
new file mode 100644
index 000000000..4586cea87
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/exception/MediaFileNotFoundException.java
@@ -0,0 +1,23 @@
+package de.danoeh.antennapod.util.exception;
+
+import de.danoeh.antennapod.feed.FeedMedia;
+
+public class MediaFileNotFoundException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private FeedMedia media;
+
+ public MediaFileNotFoundException(String msg, FeedMedia media) {
+ super(msg);
+ this.media = media;
+ }
+
+ public MediaFileNotFoundException(FeedMedia media) {
+ super();
+ this.media = media;
+ }
+
+ public FeedMedia getMedia() {
+ return media;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java
new file mode 100644
index 000000000..eda83b7aa
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrServiceCreator.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.util.flattr;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import org.shredzone.flattr4j.FlattrFactory;
+import org.shredzone.flattr4j.FlattrService;
+import org.shredzone.flattr4j.oauth.AccessToken;
+
+/** Ensures that only one instance of the FlattrService class exists at a time */
+
+public class FlattrServiceCreator {
+ public static final String TAG = "FlattrServiceCreator";
+
+ private static volatile FlattrService flattrService;
+
+ public static FlattrService getService(AccessToken token) {
+ return FlattrFactory.getInstance().createFlattrService(token);
+ }
+
+ public static void deleteFlattrService() {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Deleting service instance");
+ flattrService = null;
+ }
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java
new file mode 100644
index 000000000..a1d6d3bc4
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrStatus.java
@@ -0,0 +1,68 @@
+package de.danoeh.antennapod.util.flattr;
+
+import java.util.Calendar;
+
+public class FlattrStatus {
+ public static final int STATUS_UNFLATTERED = 0;
+ public static final int STATUS_QUEUE = 1;
+ public static final int STATUS_FLATTRED = 2;
+
+ private int status = STATUS_UNFLATTERED;
+ private Calendar lastFlattred;
+
+ public FlattrStatus() {
+ status = STATUS_UNFLATTERED;
+ lastFlattred = Calendar.getInstance();
+ }
+
+ public FlattrStatus(long status) {
+ lastFlattred = Calendar.getInstance();
+ fromLong(status);
+ }
+
+ public void setFlattred() {
+ status = STATUS_FLATTRED;
+ lastFlattred = Calendar.getInstance();
+ }
+
+ public void setUnflattred() {
+ status = STATUS_UNFLATTERED;
+ }
+
+ public boolean getUnflattred() {
+ return status == STATUS_UNFLATTERED;
+ }
+
+ public void setFlattrQueue() {
+ if (flattrable())
+ status = STATUS_QUEUE;
+ }
+
+ public void fromLong(long status) {
+ if (status == STATUS_UNFLATTERED || status == STATUS_QUEUE)
+ this.status = (int) status;
+ else {
+ this.status = STATUS_FLATTRED;
+ lastFlattred.setTimeInMillis(status);
+ }
+ }
+
+ public long toLong() {
+ if (status == STATUS_UNFLATTERED || status == STATUS_QUEUE)
+ return status;
+ else {
+ return lastFlattred.getTimeInMillis();
+ }
+ }
+
+ public boolean flattrable() {
+ Calendar firstOfMonth = Calendar.getInstance();
+ firstOfMonth.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().getActualMinimum(Calendar.DAY_OF_MONTH));
+
+ return (status == STATUS_UNFLATTERED) || (status == STATUS_FLATTRED && firstOfMonth.after(lastFlattred) );
+ }
+
+ public boolean getFlattrQueue() {
+ return status == STATUS_QUEUE;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java
new file mode 100644
index 000000000..f17ef1d83
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrThing.java
@@ -0,0 +1,7 @@
+package de.danoeh.antennapod.util.flattr;
+
+public interface FlattrThing {
+ public String getTitle();
+ public String getPaymentLink();
+ public FlattrStatus getFlattrStatus();
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java
new file mode 100644
index 000000000..3e2ea3132
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/FlattrUtils.java
@@ -0,0 +1,305 @@
+package de.danoeh.antennapod.util.flattr;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+import org.apache.commons.lang3.StringUtils;
+import org.shredzone.flattr4j.FlattrService;
+import org.shredzone.flattr4j.exception.FlattrException;
+import org.shredzone.flattr4j.model.Flattr;
+import org.shredzone.flattr4j.model.Thing;
+import org.shredzone.flattr4j.oauth.AccessToken;
+import org.shredzone.flattr4j.oauth.AndroidAuthenticator;
+import org.shredzone.flattr4j.oauth.Scope;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.TimeZone;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.PodcastApp;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.FlattrAuthActivity;
+import de.danoeh.antennapod.asynctask.FlattrTokenFetcher;
+import de.danoeh.antennapod.storage.DBWriter;
+
+/**
+ * Utility methods for doing something with flattr.
+ */
+
+public class FlattrUtils {
+ private static final String TAG = "FlattrUtils";
+
+ private static final String HOST_NAME = "de.danoeh.antennapod";
+
+ private static final String PREF_ACCESS_TOKEN = "de.danoeh.antennapod.preference.flattrAccessToken";
+
+ // Flattr URL for this app.
+ public static final String APP_URL = "http://antennapod.com";
+ // Human-readable flattr-page.
+ public static final String APP_LINK = "https://flattr.com/thing/745609/";
+ public static final String APP_THING_ID = "745609";
+
+ private static volatile AccessToken cachedToken;
+
+ private static AndroidAuthenticator createAuthenticator() {
+ return new AndroidAuthenticator(HOST_NAME, BuildConfig.FLATTR_APP_KEY,
+ BuildConfig.FLATTR_APP_SECRET);
+ }
+
+ public static void startAuthProcess(Context context) throws FlattrException {
+ AndroidAuthenticator auth = createAuthenticator();
+ auth.setScope(EnumSet.of(Scope.FLATTR));
+ Intent intent = auth.createAuthenticateIntent();
+ context.startActivity(intent);
+ }
+
+ private static AccessToken retrieveToken() {
+ if (cachedToken == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Retrieving access token");
+ String token = PreferenceManager.getDefaultSharedPreferences(
+ PodcastApp.getInstance())
+ .getString(PREF_ACCESS_TOKEN, null);
+ if (token != null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Found access token. Caching.");
+ cachedToken = new AccessToken(token);
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "No access token found");
+ return null;
+ }
+ }
+ return cachedToken;
+
+ }
+
+ /**
+ * Returns true if FLATTR_APP_KEY and FLATTR_APP_SECRET in BuildConfig are not null and not empty
+ */
+ public static boolean hasAPICredentials() {
+ return StringUtils.isNotEmpty(BuildConfig.FLATTR_APP_KEY)
+ && StringUtils.isNotEmpty(BuildConfig.FLATTR_APP_SECRET);
+ }
+
+ public static boolean hasToken() {
+ return retrieveToken() != null;
+ }
+
+ public static void storeToken(AccessToken token) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Storing token");
+ SharedPreferences.Editor editor = PreferenceManager
+ .getDefaultSharedPreferences(PodcastApp.getInstance()).edit();
+ if (token != null) {
+ editor.putString(PREF_ACCESS_TOKEN, token.getToken());
+ } else {
+ editor.putString(PREF_ACCESS_TOKEN, null);
+ }
+ editor.commit();
+ cachedToken = token;
+ }
+
+ public static void deleteToken() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Deleting flattr token");
+ storeToken(null);
+ }
+
+ public static Thing getAppThing(Context context) {
+ FlattrService fs = FlattrServiceCreator.getService(retrieveToken());
+ try {
+ Thing thing = fs.getThing(Thing.withId(APP_THING_ID));
+ return thing;
+ } catch (FlattrException e) {
+ e.printStackTrace();
+ showErrorDialog(context, e.getMessage());
+ return null;
+ }
+ }
+
+ public static void clickUrl(Context context, String url)
+ throws FlattrException {
+ if (hasToken()) {
+ FlattrService fs = FlattrServiceCreator.getService(retrieveToken());
+ fs.click(url);
+ } else {
+ Log.e(TAG, "clickUrl was called with null access token");
+ }
+ }
+
+ public static List<Flattr> retrieveFlattredThings()
+ throws FlattrException {
+ ArrayList<Flattr> myFlattrs = new ArrayList<Flattr>();
+
+ if (hasToken()) {
+ FlattrService fs = FlattrServiceCreator.getService(retrieveToken());
+
+ Calendar firstOfMonth = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ firstOfMonth.set(Calendar.MILLISECOND, 0);
+ firstOfMonth.set(Calendar.SECOND, 0);
+ firstOfMonth.set(Calendar.MINUTE, 0);
+ firstOfMonth.set(Calendar.HOUR_OF_DAY, 0);
+ firstOfMonth.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().getActualMinimum(Calendar.DAY_OF_MONTH));
+
+ Date firstOfMonthDate = firstOfMonth.getTime();
+
+ // subscriptions some times get flattrd slightly before midnight - give it an hour leeway
+ firstOfMonthDate = new Date(firstOfMonthDate.getTime() - 60 * 60 * 1000);
+
+ final int FLATTR_COUNT = 30;
+ final int FLATTR_MAXPAGE = 5;
+
+ for (int page = 0; page < FLATTR_MAXPAGE; page++) {
+ for (Flattr fl : fs.getMyFlattrs(FLATTR_COUNT, page)) {
+ if (fl.getCreated().after(firstOfMonthDate))
+ myFlattrs.add(fl);
+ else
+ break;
+ }
+ }
+
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Got my flattrs list of length " + Integer.toString(myFlattrs.size()) + " comparison date" + firstOfMonthDate);
+
+ for (Flattr fl : myFlattrs) {
+ Thing thing = fl.getThing();
+ Log.d(TAG, "Flattr thing: " + fl.getThingId() + " name: " + thing.getTitle() + " url: " + thing.getUrl() + " on: " + fl.getCreated());
+ }
+ }
+
+ } else {
+ Log.e(TAG, "retrieveFlattrdThings was called with null access token");
+ }
+
+ return myFlattrs;
+ }
+
+ public static void handleCallback(Context context, Uri uri) {
+ AndroidAuthenticator auth = createAuthenticator();
+ new FlattrTokenFetcher(context, auth, uri).executeAsync();
+ }
+
+ public static void revokeAccessToken(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Revoking access token");
+ deleteToken();
+ FlattrServiceCreator.deleteFlattrService();
+ showRevokeDialog(context);
+ DBWriter.clearAllFlattrStatus(context);
+ }
+
+ // ------------------------------------------------ DIALOGS
+
+ public static void showRevokeDialog(final Context context) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.access_revoked_title);
+ builder.setMessage(R.string.access_revoked_info);
+ builder.setNeutralButton(android.R.string.ok, new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ });
+ builder.create().show();
+ }
+
+ /**
+ * Opens a dialog that ask the user to either connect the app with flattr or to be redirected to
+ * the thing's website.
+ * If no API credentials are available, the user will immediately be redirected to the thing's website.
+ * */
+ public static void showNoTokenDialogOrRedirect(final Context context, final String url) {
+ if (hasAPICredentials()) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.no_flattr_token_title);
+ builder.setMessage(R.string.no_flattr_token_msg);
+ builder.setPositiveButton(R.string.authenticate_now_label,
+ new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ context.startActivity(new Intent(context,
+ FlattrAuthActivity.class));
+ }
+
+ }
+ );
+
+ builder.setNegativeButton(R.string.visit_website_label,
+ new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Uri uri = Uri.parse(url);
+ context.startActivity(new Intent(Intent.ACTION_VIEW,
+ uri));
+ }
+
+ }
+ );
+ builder.create().show();
+ } else {
+ Uri uri = Uri.parse(url);
+ context.startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ }
+ }
+
+ public static void showForbiddenDialog(final Context context,
+ final String url) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.action_forbidden_title);
+ builder.setMessage(R.string.action_forbidden_msg);
+ builder.setPositiveButton(R.string.authenticate_now_label,
+ new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ context.startActivity(new Intent(context,
+ FlattrAuthActivity.class));
+ }
+
+ }
+ );
+ builder.setNegativeButton(R.string.visit_website_label,
+ new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Uri uri = Uri.parse(url);
+ context.startActivity(new Intent(Intent.ACTION_VIEW,
+ uri));
+ }
+
+ }
+ );
+ builder.create().show();
+ }
+
+ public static void showErrorDialog(final Context context, final String msg) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.error_label);
+ builder.setMessage(msg);
+ builder.setNeutralButton(android.R.string.ok, new OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ dialog.cancel();
+ }
+ });
+ builder.create().show();
+ }
+
+} \ No newline at end of file
diff --git a/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java b/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java
new file mode 100644
index 000000000..296610871
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/flattr/SimpleFlattrThing.java
@@ -0,0 +1,30 @@
+package de.danoeh.antennapod.util.flattr;
+
+/* SimpleFlattrThing is a trivial implementation of the FlattrThing interface */
+public class SimpleFlattrThing implements FlattrThing {
+ public SimpleFlattrThing(String title, String url, FlattrStatus status)
+ {
+ this.title = title;
+ this.url = url;
+ this.status = status;
+ }
+
+ public String getTitle()
+ {
+ return this.title;
+ }
+
+ public String getPaymentLink()
+ {
+ return this.url;
+ }
+
+ public FlattrStatus getFlattrStatus()
+ {
+ return this.status;
+ }
+
+ private String title;
+ private String url;
+ private FlattrStatus status;
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java b/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java
new file mode 100644
index 000000000..b920559db
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/gui/FeedItemUndoToken.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.util.gui;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import de.danoeh.antennapod.feed.FeedItem;
+
+/**
+ * Used by an UndoBarController for saving a removed FeedItem
+ */
+public class FeedItemUndoToken implements Parcelable {
+ private long itemId;
+ private long feedId;
+ private int position;
+
+ public FeedItemUndoToken(FeedItem item, int position) {
+ this.itemId = item.getId();
+ this.feedId = item.getFeed().getId();
+ this.position = position;
+ }
+
+ private FeedItemUndoToken(Parcel in) {
+ itemId = in.readLong();
+ feedId = in.readLong();
+ position = in.readInt();
+ }
+
+ public static final Parcelable.Creator<FeedItemUndoToken> CREATOR = new Parcelable.Creator<FeedItemUndoToken>() {
+ public FeedItemUndoToken createFromParcel(Parcel in) {
+ return new FeedItemUndoToken(in);
+ }
+
+ public FeedItemUndoToken[] newArray(int size) {
+ return new FeedItemUndoToken[size];
+ }
+ };
+
+ public int describeContents() {
+ return 0;
+ }
+
+ public void writeToParcel(Parcel out, int flags) {
+ out.writeLong(itemId);
+ out.writeLong(feedId);
+ out.writeInt(position);
+ }
+
+ public long getFeedItemId() {
+ return itemId;
+ }
+
+ public int getPosition() {
+ return position;
+ }
+}
+
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java
new file mode 100644
index 000000000..257635129
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ChapterReader.java
@@ -0,0 +1,118 @@
+package de.danoeh.antennapod.util.id3reader;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.ID3Chapter;
+import de.danoeh.antennapod.util.id3reader.model.FrameHeader;
+import de.danoeh.antennapod.util.id3reader.model.TagHeader;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ChapterReader extends ID3Reader {
+ private static final String TAG = "ID3ChapterReader";
+
+ private static final String FRAME_ID_CHAPTER = "CHAP";
+ private static final String FRAME_ID_TITLE = "TIT2";
+ private static final String FRAME_ID_LINK = "WXXX";
+
+ private List<Chapter> chapters;
+ private ID3Chapter currentChapter;
+
+ @Override
+ public int onStartTagHeader(TagHeader header) {
+ chapters = new ArrayList<Chapter>();
+ System.out.println(header.toString());
+ return ID3Reader.ACTION_DONT_SKIP;
+ }
+
+ @Override
+ public int onStartFrameHeader(FrameHeader header, InputStream input)
+ throws IOException, ID3ReaderException {
+ System.out.println(header.toString());
+ if (header.getId().equals(FRAME_ID_CHAPTER)) {
+ if (currentChapter != null) {
+ if (!hasId3Chapter(currentChapter)) {
+ chapters.add(currentChapter);
+ if (BuildConfig.DEBUG) Log.d(TAG, "Found chapter: " + currentChapter);
+ currentChapter = null;
+ }
+ }
+ StringBuffer elementId = new StringBuffer();
+ readISOString(elementId, input, Integer.MAX_VALUE);
+ char[] startTimeSource = readBytes(input, 4);
+ long startTime = ((int) startTimeSource[0] << 24)
+ | ((int) startTimeSource[1] << 16)
+ | ((int) startTimeSource[2] << 8) | startTimeSource[3];
+ currentChapter = new ID3Chapter(elementId.toString(), startTime);
+ skipBytes(input, 12);
+ return ID3Reader.ACTION_DONT_SKIP;
+ } else if (header.getId().equals(FRAME_ID_TITLE)) {
+ if (currentChapter != null && currentChapter.getTitle() == null) {
+ StringBuffer title = new StringBuffer();
+ readString(title, input, header.getSize());
+ currentChapter
+ .setTitle(title.toString());
+ if (BuildConfig.DEBUG) Log.d(TAG, "Found title: " + currentChapter.getTitle());
+
+ return ID3Reader.ACTION_DONT_SKIP;
+ }
+ } else if (header.getId().equals(FRAME_ID_LINK)) {
+ if (currentChapter != null) {
+ // skip description
+ int descriptionLength = readString(null, input, header.getSize());
+ StringBuffer link = new StringBuffer();
+ readISOString(link, input, header.getSize() - descriptionLength);
+ String decodedLink = URLDecoder.decode(link.toString(), "UTF-8");
+
+ currentChapter.setLink(decodedLink);
+
+ if (BuildConfig.DEBUG) Log.d(TAG, "Found link: " + currentChapter.getLink());
+ return ID3Reader.ACTION_DONT_SKIP;
+ }
+ } else if (header.getId().equals("APIC")) {
+ Log.d(TAG, header.toString());
+ }
+
+ return super.onStartFrameHeader(header, input);
+ }
+
+ private boolean hasId3Chapter(ID3Chapter chapter) {
+ for (Chapter c : chapters) {
+ if (((ID3Chapter) c).getId3ID().equals(chapter.getId3ID())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void onEndTag() {
+ if (currentChapter != null) {
+ if (!hasId3Chapter(currentChapter)) {
+ chapters.add(currentChapter);
+ }
+ }
+ System.out.println("Reached end of tag");
+ if (chapters != null) {
+ for (Chapter c : chapters) {
+ System.out.println(c.toString());
+ }
+ }
+ }
+
+ @Override
+ public void onNoTagHeaderFound() {
+ System.out.println("No tag header found");
+ super.onNoTagHeaderFound();
+ }
+
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java
new file mode 100644
index 000000000..252d64107
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3Reader.java
@@ -0,0 +1,250 @@
+package de.danoeh.antennapod.util.id3reader;
+
+import de.danoeh.antennapod.util.id3reader.model.FrameHeader;
+import de.danoeh.antennapod.util.id3reader.model.TagHeader;
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+
+/**
+ * Reads the ID3 Tag of a given file. In order to use this class, you should
+ * create a subclass of it and overwrite the onStart* - or onEnd* - methods.
+ */
+public class ID3Reader {
+ private static final int HEADER_LENGTH = 10;
+ private static final int ID3_LENGTH = 3;
+ private static final int FRAME_ID_LENGTH = 4;
+
+ protected static final int ACTION_SKIP = 1;
+ protected static final int ACTION_DONT_SKIP = 2;
+
+ protected int readerPosition;
+
+ private static final byte ENCODING_UTF16_WITH_BOM = 1;
+ private static final byte ENCODING_UTF16_WITHOUT_BOM = 2;
+ private static final byte ENCODING_UTF8 = 3;
+
+ private TagHeader tagHeader;
+
+ public ID3Reader() {
+ }
+
+ public final void readInputStream(InputStream input) throws IOException,
+ ID3ReaderException {
+ int rc;
+ readerPosition = 0;
+ char[] tagHeaderSource = readBytes(input, HEADER_LENGTH);
+ tagHeader = createTagHeader(tagHeaderSource);
+ if (tagHeader == null) {
+ onNoTagHeaderFound();
+ } else {
+ rc = onStartTagHeader(tagHeader);
+ if (rc == ACTION_SKIP) {
+ onEndTag();
+ } else {
+ while (readerPosition < tagHeader.getSize()) {
+ FrameHeader frameHeader = createFrameHeader(readBytes(
+ input, HEADER_LENGTH));
+ if (checkForNullString(frameHeader.getId())) {
+ break;
+ } else {
+ rc = onStartFrameHeader(frameHeader, input);
+ if (rc == ACTION_SKIP) {
+
+ if (frameHeader.getSize() + readerPosition > tagHeader
+ .getSize()) {
+ break;
+ } else {
+ skipBytes(input, frameHeader.getSize());
+ }
+ }
+ }
+ }
+ onEndTag();
+ }
+ }
+ }
+
+ /** Returns true if string only contains null-bytes. */
+ private boolean checkForNullString(String s) {
+ if (!s.isEmpty()) {
+ int i = 0;
+ if (s.charAt(i) == 0) {
+ for (i = 1; i < s.length(); i++) {
+ if (s.charAt(i) != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return false;
+ } else {
+ return true;
+ }
+
+ }
+
+ /**
+ * Read a certain number of bytes from the given input stream. This method
+ * changes the readerPosition-attribute.
+ */
+ protected char[] readBytes(InputStream input, int number)
+ throws IOException, ID3ReaderException {
+ char[] header = new char[number];
+ for (int i = 0; i < number; i++) {
+ int b = input.read();
+ readerPosition++;
+ if (b != -1) {
+ header[i] = (char) b;
+ } else {
+ throw new ID3ReaderException("Unexpected end of stream");
+ }
+ }
+ return header;
+ }
+
+ /**
+ * Skip a certain number of bytes on the given input stream. This method
+ * changes the readerPosition-attribute.
+ */
+ protected void skipBytes(InputStream input, int number) throws IOException {
+ if (number <= 0) {
+ number = 1;
+ }
+ IOUtils.skipFully(input, number);
+
+ readerPosition += number;
+ }
+
+ private TagHeader createTagHeader(char[] source) throws ID3ReaderException {
+ boolean hasTag = (source[0] == 0x49) && (source[1] == 0x44)
+ && (source[2] == 0x33);
+ if (source.length != HEADER_LENGTH) {
+ throw new ID3ReaderException("Length of header must be "
+ + HEADER_LENGTH);
+ }
+ if (hasTag) {
+ String id = new String(source, 0, ID3_LENGTH);
+ char version = (char) ((source[3] << 8) | source[4]);
+ byte flags = (byte) source[5];
+ int size = (source[6] << 24) | (source[7] << 16) | (source[8] << 8)
+ | source[9];
+ size = unsynchsafe(size);
+ return new TagHeader(id, size, version, flags);
+ } else {
+ return null;
+ }
+ }
+
+ private FrameHeader createFrameHeader(char[] source)
+ throws ID3ReaderException {
+ if (source.length != HEADER_LENGTH) {
+ throw new ID3ReaderException("Length of header must be "
+ + HEADER_LENGTH);
+ }
+ String id = new String(source, 0, FRAME_ID_LENGTH);
+
+ int size = (((int) source[4]) << 24) | (((int) source[5]) << 16)
+ | (((int) source[6]) << 8) | source[7];
+ if (tagHeader != null && tagHeader.getVersion() >= 0x0400) {
+ size = unsynchsafe(size);
+ }
+ char flags = (char) ((source[8] << 8) | source[9]);
+ return new FrameHeader(id, size, flags);
+ }
+
+ private int unsynchsafe(int in) {
+ int out = 0;
+ int mask = 0x7F000000;
+
+ while (mask != 0) {
+ out >>= 1;
+ out |= in & mask;
+ mask >>= 8;
+ }
+
+ return out;
+ }
+
+ protected int readString(StringBuffer buffer, InputStream input, int max) throws IOException,
+ ID3ReaderException {
+ if (max > 0) {
+ char[] encoding = readBytes(input, 1);
+ max--;
+
+ if (encoding[0] == ENCODING_UTF16_WITH_BOM || encoding[0] == ENCODING_UTF16_WITHOUT_BOM) {
+ return readUnicodeString(buffer, input, max, Charset.forName("UTF-16")) + 1; // take encoding byte into account
+ } else if (encoding[0] == ENCODING_UTF8) {
+ return readUnicodeString(buffer, input, max, Charset.forName("UTF-8")) + 1; // take encoding byte into account
+ } else {
+ return readISOString(buffer, input, max) + 1; // take encoding byte into account
+ }
+ } else {
+ if (buffer != null) {
+ buffer.append("");
+ }
+ return 0;
+ }
+ }
+
+ protected int readISOString(StringBuffer buffer, InputStream input, int max)
+ throws IOException, ID3ReaderException {
+
+ int bytesRead = 0;
+ char c;
+ while (++bytesRead <= max && (c = (char) input.read()) > 0) {
+ if (buffer != null) {
+ buffer.append(c);
+ }
+ }
+ return bytesRead;
+ }
+
+ private int readUnicodeString(StringBuffer strBuffer, InputStream input, int max, Charset charset)
+ throws IOException, ID3ReaderException {
+ byte[] buffer = new byte[max];
+ int c, cZero = -1;
+ int i = 0;
+ for (; i < max; i++) {
+ c = input.read();
+ if (c == -1) {
+ break;
+ } else if (c == 0) {
+ if (cZero == 0) {
+ // termination character found
+ break;
+ } else {
+ cZero = 0;
+ }
+ } else {
+ buffer[i] = (byte) c;
+ cZero = -1;
+ }
+ }
+ if (strBuffer != null) {
+ strBuffer.append(charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString());
+ }
+ return i;
+ }
+
+ public int onStartTagHeader(TagHeader header) {
+ return ACTION_SKIP;
+ }
+
+ public int onStartFrameHeader(FrameHeader header, InputStream input)
+ throws IOException, ID3ReaderException {
+ return ACTION_SKIP;
+ }
+
+ public void onEndTag() {
+
+ }
+
+ public void onNoTagHeaderFound() {
+
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java
new file mode 100644
index 000000000..c458540ee
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/ID3ReaderException.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.util.id3reader;
+
+public class ID3ReaderException extends Exception {
+
+ public ID3ReaderException() {
+ }
+
+ public ID3ReaderException(String arg0) {
+ super(arg0);
+ }
+
+ public ID3ReaderException(Throwable arg0) {
+ super(arg0);
+ }
+
+ public ID3ReaderException(String arg0, Throwable arg1) {
+ super(arg0, arg1);
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java
new file mode 100644
index 000000000..df73393a5
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/FrameHeader.java
@@ -0,0 +1,17 @@
+package de.danoeh.antennapod.util.id3reader.model;
+
+public class FrameHeader extends Header {
+
+ protected char flags;
+
+ public FrameHeader(String id, int size, char flags) {
+ super(id, size);
+ this.flags = flags;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, Integer.toBinaryString(size));
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java
new file mode 100644
index 000000000..22d5b6376
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/Header.java
@@ -0,0 +1,29 @@
+package de.danoeh.antennapod.util.id3reader.model;
+
+public abstract class Header {
+
+ protected String id;
+ protected int size;
+
+ public Header(String id, int size) {
+ super();
+ this.id = id;
+ this.size = size;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public int getSize() {
+ return size;
+ }
+
+ @Override
+ public String toString() {
+ return "Header [id=" + id + ", size=" + size + "]";
+ }
+
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java
new file mode 100644
index 000000000..ec99ef14e
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/id3reader/model/TagHeader.java
@@ -0,0 +1,26 @@
+package de.danoeh.antennapod.util.id3reader.model;
+
+public class TagHeader extends Header {
+
+ protected char version;
+ protected byte flags;
+
+ public TagHeader(String id, int size, char version, byte flags) {
+ super(id, size);
+ this.version = version;
+ this.flags = flags;
+ }
+
+ @Override
+ public String toString() {
+ return "TagHeader [version=" + version + ", flags=" + flags + ", id="
+ + id + ", size=" + size + "]";
+ }
+
+ public char getVersion() {
+ return version;
+ }
+
+
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java
new file mode 100644
index 000000000..2c7a7f074
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedItemMenuHandler.java
@@ -0,0 +1,191 @@
+package de.danoeh.antennapod.util.menuhandler;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.FeedItem;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.QueueAccess;
+import de.danoeh.antennapod.util.ShareUtils;
+
+/**
+ * Handles interactions with the FeedItemMenu.
+ */
+public class FeedItemMenuHandler {
+ private static final String TAG = "FeedItemMenuHandler";
+
+ private FeedItemMenuHandler() {
+
+ }
+
+ /**
+ * Used by the MenuHandler to access different types of menus through one
+ * interface
+ */
+ public interface MenuInterface {
+ /**
+ * Implementations of this method should call findItem(id) on their
+ * menu-object and call setVisibility(visibility) on the returned
+ * MenuItem object.
+ */
+ abstract void setItemVisibility(int id, boolean visible);
+ }
+
+ /**
+ * This method should be called in the prepare-methods of menus. It changes
+ * the visibility of the menu items depending on a FeedItem's attributes.
+ *
+ * @param mi An instance of MenuInterface that the method uses to change a
+ * MenuItem's visibility
+ * @param selectedItem The FeedItem for which the menu is supposed to be prepared
+ * @param showExtendedMenu True if MenuItems that let the user share information about
+ * the FeedItem and visit its website should be set visible. This
+ * parameter should be set to false if the menu space is limited.
+ * @param queueAccess Used for testing if the queue contains the selected item
+ * @return Returns true if selectedItem is not null.
+ */
+ public static boolean onPrepareMenu(MenuInterface mi,
+ FeedItem selectedItem, boolean showExtendedMenu, QueueAccess queueAccess) {
+ if (selectedItem == null) {
+ return false;
+ }
+ DownloadRequester requester = DownloadRequester.getInstance();
+ boolean hasMedia = selectedItem.getMedia() != null;
+ boolean downloaded = hasMedia && selectedItem.getMedia().isDownloaded();
+ boolean downloading = hasMedia
+ && requester.isDownloadingFile(selectedItem.getMedia());
+ boolean notLoadedAndNotLoading = hasMedia && (!downloaded)
+ && (!downloading);
+ boolean isPlaying = hasMedia
+ && selectedItem.getState() == FeedItem.State.PLAYING;
+
+ FeedItem.State state = selectedItem.getState();
+
+ if (!isPlaying) {
+ mi.setItemVisibility(R.id.skip_episode_item, false);
+ }
+ if (!downloaded || isPlaying) {
+ mi.setItemVisibility(R.id.play_item, false);
+ mi.setItemVisibility(R.id.remove_item, false);
+ }
+ if (!notLoadedAndNotLoading) {
+ mi.setItemVisibility(R.id.download_item, false);
+ }
+ if (!(notLoadedAndNotLoading | downloading) | isPlaying) {
+ mi.setItemVisibility(R.id.stream_item, false);
+ }
+ if (!downloading) {
+ mi.setItemVisibility(R.id.cancel_download_item, false);
+ }
+
+ boolean isInQueue = queueAccess.contains(selectedItem.getId());
+ if (!isInQueue || isPlaying) {
+ mi.setItemVisibility(R.id.remove_from_queue_item, false);
+ }
+ if (!(!isInQueue && selectedItem.getMedia() != null)) {
+ mi.setItemVisibility(R.id.add_to_queue_item, false);
+ }
+ if (!showExtendedMenu || selectedItem.getLink() == null) {
+ mi.setItemVisibility(R.id.share_link_item, false);
+ }
+
+ if (!BuildConfig.DEBUG
+ || !(state == FeedItem.State.IN_PROGRESS || state == FeedItem.State.READ)) {
+ mi.setItemVisibility(R.id.mark_unread_item, false);
+ }
+ if (!(state == FeedItem.State.NEW || state == FeedItem.State.IN_PROGRESS)) {
+ mi.setItemVisibility(R.id.mark_read_item, false);
+ }
+
+ if (!showExtendedMenu || selectedItem.getLink() == null) {
+ mi.setItemVisibility(R.id.visit_website_item, false);
+ }
+
+ if (selectedItem.getPaymentLink() == null || !selectedItem.getFlattrStatus().flattrable()) {
+ mi.setItemVisibility(R.id.support_item, false);
+ }
+ return true;
+ }
+
+ /**
+ * The same method as onPrepareMenu(MenuInterface, FeedItem, boolean, QueueAccess), but lets the
+ * caller also specify a list of menu items that should not be shown.
+ *
+ * @param excludeIds Menu item that should be excluded
+ * @return true if selectedItem is not null.
+ */
+ public static boolean onPrepareMenu(MenuInterface mi,
+ FeedItem selectedItem, boolean showExtendedMenu, QueueAccess queueAccess, int... excludeIds) {
+ boolean rc = onPrepareMenu(mi, selectedItem, showExtendedMenu, queueAccess);
+ if (rc && excludeIds != null) {
+ for (int id : excludeIds) {
+ mi.setItemVisibility(id, false);
+ }
+ }
+
+ return rc;
+ }
+
+ public static boolean onMenuItemClicked(Context context, int menuItemId,
+ FeedItem selectedItem) throws DownloadRequestException {
+ DownloadRequester requester = DownloadRequester.getInstance();
+ switch (menuItemId) {
+ case R.id.skip_episode_item:
+ context.sendBroadcast(new Intent(
+ PlaybackService.ACTION_SKIP_CURRENT_EPISODE));
+ break;
+ case R.id.download_item:
+ DBTasks.downloadFeedItems(context, selectedItem);
+ break;
+ case R.id.play_item:
+ DBTasks.playMedia(context, selectedItem.getMedia(), true, true,
+ false);
+ break;
+ case R.id.remove_item:
+ DBWriter.deleteFeedMediaOfItem(context, selectedItem.getMedia().getId());
+ break;
+ case R.id.cancel_download_item:
+ requester.cancelDownload(context, selectedItem.getMedia());
+ break;
+ case R.id.mark_read_item:
+ DBWriter.markItemRead(context, selectedItem, true, true);
+ break;
+ case R.id.mark_unread_item:
+ DBWriter.markItemRead(context, selectedItem, false, true);
+ break;
+ case R.id.add_to_queue_item:
+ DBWriter.addQueueItem(context, selectedItem.getId());
+ break;
+ case R.id.remove_from_queue_item:
+ DBWriter.removeQueueItem(context, selectedItem.getId(), true);
+ break;
+ case R.id.stream_item:
+ DBTasks.playMedia(context, selectedItem.getMedia(), true, true,
+ true);
+ break;
+ case R.id.visit_website_item:
+ Uri uri = Uri.parse(selectedItem.getLink());
+ context.startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ break;
+ case R.id.support_item:
+ DBTasks.flattrItemIfLoggedIn(context, selectedItem);
+ break;
+ case R.id.share_link_item:
+ ShareUtils.shareFeedItemLink(context, selectedItem);
+ break;
+ default:
+ return false;
+ }
+ // Refresh menu state
+
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java
new file mode 100644
index 000000000..a3adec66d
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/FeedMenuHandler.java
@@ -0,0 +1,87 @@
+package de.danoeh.antennapod.util.menuhandler;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.activity.FeedInfoActivity;
+import de.danoeh.antennapod.feed.Feed;
+import de.danoeh.antennapod.service.download.DownloadService;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.storage.DBWriter;
+import de.danoeh.antennapod.storage.DownloadRequestException;
+import de.danoeh.antennapod.storage.DownloadRequester;
+import de.danoeh.antennapod.util.ShareUtils;
+
+/** Handles interactions with the FeedItemMenu. */
+public class FeedMenuHandler {
+ private static final String TAG = "FeedMenuHandler";
+
+ public static boolean onCreateOptionsMenu(MenuInflater inflater, Menu menu) {
+ inflater.inflate(R.menu.feedlist, menu);
+ return true;
+ }
+
+ public static boolean onPrepareOptionsMenu(Menu menu, Feed selectedFeed) {
+ if (selectedFeed == null) {
+ return true;
+ }
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Preparing options menu");
+ menu.findItem(R.id.mark_all_read_item).setVisible(
+ selectedFeed.hasNewItems(true));
+ if (selectedFeed.getPaymentLink() != null && selectedFeed.getFlattrStatus().flattrable())
+ menu.findItem(R.id.support_item).setVisible(true);
+ else
+ menu.findItem(R.id.support_item).setVisible(false);
+ MenuItem refresh = menu.findItem(R.id.refresh_item);
+ if (DownloadService.isRunning
+ && DownloadRequester.getInstance().isDownloadingFile(
+ selectedFeed)) {
+ refresh.setVisible(false);
+ } else {
+ refresh.setVisible(true);
+ }
+
+ return true;
+ }
+
+ /**
+ * NOTE: This method does not handle clicks on the 'remove feed' - item.
+ *
+ * @throws DownloadRequestException
+ */
+ public static boolean onOptionsItemClicked(Context context, MenuItem item,
+ Feed selectedFeed) throws DownloadRequestException {
+ switch (item.getItemId()) {
+ case R.id.refresh_item:
+ DBTasks.refreshFeed(context, selectedFeed);
+ break;
+ case R.id.mark_all_read_item:
+ DBWriter.markFeedRead(context, selectedFeed.getId());
+ break;
+ case R.id.visit_website_item:
+ Uri uri = Uri.parse(selectedFeed.getLink());
+ context.startActivity(new Intent(Intent.ACTION_VIEW, uri));
+ break;
+ case R.id.support_item:
+ DBTasks.flattrFeedIfLoggedIn(context, selectedFeed);
+ break;
+ case R.id.share_link_item:
+ ShareUtils.shareFeedlink(context, selectedFeed);
+ break;
+ case R.id.share_source_item:
+ ShareUtils.shareFeedDownloadLink(context, selectedFeed);
+ break;
+ default:
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java
new file mode 100644
index 000000000..7aa04d24c
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/MenuItemUtils.java
@@ -0,0 +1,31 @@
+package de.danoeh.antennapod.util.menuhandler;
+
+import android.support.v4.view.MenuItemCompat;
+import android.support.v7.widget.SearchView;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import de.danoeh.antennapod.R;
+
+/**
+ * Utilities for menu items
+ */
+public class MenuItemUtils {
+
+ public static MenuItem addSearchItem(Menu menu, SearchView searchView) {
+ MenuItem item = menu.add(Menu.NONE, R.id.search_item, Menu.NONE, R.string.search_label);
+ MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
+ MenuItemCompat.setActionView(item, searchView);
+ return item;
+ }
+
+ /**
+ * Checks if the navigation drawer of the DrawerActivity is opened. This can be useful for Fragments
+ * that hide their menu if the navigation drawer is open.
+ *
+ * @return True if the drawer is open, false otherwise (also if the parameter is null)
+ */
+ public static boolean isActivityDrawerOpen(NavDrawerActivity activity) {
+ return activity != null && activity.isDrawerOpen();
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java
new file mode 100644
index 000000000..9c611a452
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/menuhandler/NavDrawerActivity.java
@@ -0,0 +1,9 @@
+package de.danoeh.antennapod.util.menuhandler;
+
+/**
+ * Defines useful methods for activities that have a navigation drawer
+ */
+public interface NavDrawerActivity {
+
+ public boolean isDrawerOpen();
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java
new file mode 100644
index 000000000..bd49b0d18
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/AudioPlayer.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.Context;
+import android.util.Log;
+import android.view.SurfaceHolder;
+import com.aocate.media.MediaPlayer;
+
+public class AudioPlayer extends MediaPlayer implements IPlayer {
+ private static final String TAG = "AudioPlayer";
+
+ public AudioPlayer(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setScreenOnWhilePlaying(boolean screenOn) {
+ Log.e(TAG, "Setting screen on while playing not supported in Audio Player");
+ throw new UnsupportedOperationException("Setting screen on while playing not supported in Audio Player");
+
+ }
+
+ @Override
+ public void setDisplay(SurfaceHolder sh) {
+ if (sh != null) {
+ Log.e(TAG, "Setting display not supported in Audio Player");
+ throw new UnsupportedOperationException("Setting display not supported in Audio Player");
+ }
+ }
+
+ @Override
+ public void setVideoScalingMode(int mode) {
+ throw new UnsupportedOperationException("Setting scaling mode is not supported in Audio Player");
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java b/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java
new file mode 100644
index 000000000..3f6e6ae0a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/ExternalMedia.java
@@ -0,0 +1,237 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.util.ChapterUtils;
+
+import java.io.File;
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+/** Represents a media file that is stored on the local storage device. */
+public class ExternalMedia implements Playable {
+
+ public static final int PLAYABLE_TYPE_EXTERNAL_MEDIA = 2;
+ public static final String PREF_SOURCE_URL = "ExternalMedia.PrefSourceUrl";
+ public static final String PREF_POSITION = "ExternalMedia.PrefPosition";
+ public static final String PREF_MEDIA_TYPE = "ExternalMedia.PrefMediaType";
+
+ private String source;
+
+ private String episodeTitle;
+ private String feedTitle;
+ private MediaType mediaType = MediaType.AUDIO;
+ private List<Chapter> chapters;
+ private int duration;
+ private int position;
+
+ public ExternalMedia(String source, MediaType mediaType) {
+ super();
+ this.source = source;
+ this.mediaType = mediaType;
+ }
+
+ public ExternalMedia(String source, MediaType mediaType, int position) {
+ this(source, mediaType);
+ this.position = position;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(source);
+ dest.writeString(mediaType.toString());
+ dest.writeInt(position);
+ }
+
+ @Override
+ public void writeToPreferences(Editor prefEditor) {
+ prefEditor.putString(PREF_SOURCE_URL, source);
+ prefEditor.putString(PREF_MEDIA_TYPE, mediaType.toString());
+ prefEditor.putInt(PREF_POSITION, position);
+ }
+
+ @Override
+ public void loadMetadata() throws PlayableException {
+ MediaMetadataRetriever mmr = new MediaMetadataRetriever();
+ try {
+ mmr.setDataSource(source);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ throw new PlayableException(
+ "IllegalArgumentException when setting up MediaMetadataReceiver");
+ } catch (RuntimeException e) {
+ // http://code.google.com/p/android/issues/detail?id=39770
+ e.printStackTrace();
+ throw new PlayableException(
+ "RuntimeException when setting up MediaMetadataRetriever");
+ }
+ episodeTitle = mmr
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
+ feedTitle = mmr
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM);
+ try {
+ duration = Integer.parseInt(mmr
+ .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ throw new PlayableException("NumberFormatException when reading duration of media file");
+ }
+ ChapterUtils.loadChaptersFromFileUrl(this);
+ }
+
+ @Override
+ public void loadChapterMarks() {
+
+ }
+
+ @Override
+ public String getEpisodeTitle() {
+ return episodeTitle;
+ }
+
+ @Override
+ public Callable<String> loadShownotes() {
+ return new Callable<String>() {
+ @Override
+ public String call() throws Exception {
+ return "";
+ }
+ };
+ }
+
+ @Override
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+ @Override
+ public String getWebsiteLink() {
+ return null;
+ }
+
+ @Override
+ public String getPaymentLink() {
+ return null;
+ }
+
+ @Override
+ public String getFeedTitle() {
+ return feedTitle;
+ }
+
+ @Override
+ public Object getIdentifier() {
+ return source;
+ }
+
+ @Override
+ public int getDuration() {
+ return duration;
+ }
+
+ @Override
+ public int getPosition() {
+ return position;
+ }
+
+ @Override
+ public MediaType getMediaType() {
+ return mediaType;
+ }
+
+ @Override
+ public String getLocalMediaUrl() {
+ return source;
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return null;
+ }
+
+ @Override
+ public boolean localFileAvailable() {
+ return true;
+ }
+
+ @Override
+ public boolean streamAvailable() {
+ return false;
+ }
+
+ @Override
+ public void saveCurrentPosition(SharedPreferences pref, int newPosition) {
+ SharedPreferences.Editor editor = pref.edit();
+ editor.putInt(PREF_POSITION, newPosition);
+ position = newPosition;
+ editor.commit();
+ }
+
+ @Override
+ public void setPosition(int newPosition) {
+ position = newPosition;
+ }
+
+ @Override
+ public void setDuration(int newDuration) {
+ duration = newDuration;
+ }
+
+ @Override
+ public void onPlaybackStart() {
+
+ }
+
+ @Override
+ public void onPlaybackCompleted() {
+
+ }
+
+ @Override
+ public int getPlayableType() {
+ return PLAYABLE_TYPE_EXTERNAL_MEDIA;
+ }
+
+ @Override
+ public void setChapters(List<Chapter> chapters) {
+ this.chapters = chapters;
+ }
+
+ public static final Parcelable.Creator<ExternalMedia> CREATOR = new Parcelable.Creator<ExternalMedia>() {
+ public ExternalMedia createFromParcel(Parcel in) {
+ String source = in.readString();
+ MediaType type = MediaType.valueOf(in.readString());
+ int position = 0;
+ if (in.dataAvail() > 0) {
+ position = in.readInt();
+ }
+ ExternalMedia extMedia = new ExternalMedia(source, type, position);
+ return extMedia;
+ }
+
+ public ExternalMedia[] newArray(int size) {
+ return new ExternalMedia[size];
+ }
+ };
+
+ @Override
+ public Uri getImageUri() {
+ if (localFileAvailable()) {
+ return new Uri.Builder().scheme(SCHEME_MEDIA).encodedPath(getLocalMediaUrl()).build();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java
new file mode 100644
index 000000000..2d4551b13
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/IPlayer.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.Context;
+import android.view.SurfaceHolder;
+
+import java.io.IOException;
+
+public interface IPlayer {
+ boolean canSetPitch();
+
+ boolean canSetSpeed();
+
+ float getCurrentPitchStepsAdjustment();
+
+ int getCurrentPosition();
+
+ float getCurrentSpeedMultiplier();
+
+ int getDuration();
+
+ float getMaxSpeedMultiplier();
+
+ float getMinSpeedMultiplier();
+
+ boolean isLooping();
+
+ boolean isPlaying();
+
+ void pause();
+
+ void prepare() throws IllegalStateException, IOException;
+
+ void prepareAsync();
+
+ void release();
+
+ void reset();
+
+ void seekTo(int msec);
+
+ void setAudioStreamType(int streamtype);
+
+ void setScreenOnWhilePlaying(boolean screenOn);
+
+ void setDataSource(String path) throws IllegalStateException, IOException,
+ IllegalArgumentException, SecurityException;
+
+ void setDisplay(SurfaceHolder sh);
+
+ void setEnableSpeedAdjustment(boolean enableSpeedAdjustment);
+
+ void setLooping(boolean looping);
+
+ void setPitchStepsAdjustment(float pitchSteps);
+
+ void setPlaybackPitch(float f);
+
+ void setPlaybackSpeed(float f);
+
+ void setVolume(float left, float right);
+
+ void start();
+
+ void stop();
+
+ public void setVideoScalingMode(int mode);
+
+ public void setWakeMode(Context context, int mode);
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java b/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java
new file mode 100644
index 000000000..23ead581f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/MediaPlayerError.java
@@ -0,0 +1,23 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.Context;
+import android.media.MediaPlayer;
+import de.danoeh.antennapod.R;
+
+/** Utility class for MediaPlayer errors. */
+public class MediaPlayerError {
+
+ /** Get a human-readable string for a specific error code. */
+ public static String getErrorString(Context context, int code) {
+ int resId;
+ switch(code) {
+ case MediaPlayer.MEDIA_ERROR_SERVER_DIED:
+ resId = R.string.playback_error_server_died;
+ break;
+ default:
+ resId = R.string.playback_error_unknown;
+ break;
+ }
+ return context.getString(resId);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java b/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java
new file mode 100644
index 000000000..004ae56bb
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/Playable.java
@@ -0,0 +1,207 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Parcelable;
+import android.util.Log;
+
+import java.util.List;
+
+import de.danoeh.antennapod.asynctask.PicassoImageResource;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.storage.DBReader;
+import de.danoeh.antennapod.util.ShownotesProvider;
+
+/**
+ * Interface for objects that can be played by the PlaybackService.
+ */
+public interface Playable extends Parcelable,
+ ShownotesProvider, PicassoImageResource {
+
+ /**
+ * Save information about the playable in a preference so that it can be
+ * restored later via PlayableUtils.createInstanceFromPreferences.
+ * Implementations must NOT call commit() after they have written the values
+ * to the preferences file.
+ */
+ public void writeToPreferences(SharedPreferences.Editor prefEditor);
+
+ /**
+ * This method is called from a separate thread by the PlaybackService.
+ * Playable objects should load their metadata in this method. This method
+ * should execute as quickly as possible and NOT load chapter marks if no
+ * local file is available.
+ */
+ public void loadMetadata() throws PlayableException;
+
+ /**
+ * This method is called from a separate thread by the PlaybackService.
+ * Playable objects should load their chapter marks in this method if no
+ * local file was available when loadMetadata() was called.
+ */
+ public void loadChapterMarks();
+
+ /**
+ * Returns the title of the episode that this playable represents
+ */
+ public String getEpisodeTitle();
+
+ /**
+ * Returns a list of chapter marks or null if this Playable has no chapters.
+ */
+ public List<Chapter> getChapters();
+
+ /**
+ * Returns a link to a website that is meant to be shown in a browser
+ */
+ public String getWebsiteLink();
+
+ public String getPaymentLink();
+
+ /**
+ * Returns the title of the feed this Playable belongs to.
+ */
+ public String getFeedTitle();
+
+ /**
+ * Returns a unique identifier, for example a file url or an ID from a
+ * database.
+ */
+ public Object getIdentifier();
+
+ /**
+ * Return duration of object or 0 if duration is unknown.
+ */
+ public int getDuration();
+
+ /**
+ * Return position of object or 0 if position is unknown.
+ */
+ public int getPosition();
+
+ /**
+ * Returns the type of media. This method should return the correct value
+ * BEFORE loadMetadata() is called.
+ */
+ public MediaType getMediaType();
+
+ /**
+ * Returns an url to a local file that can be played or null if this file
+ * does not exist.
+ */
+ public String getLocalMediaUrl();
+
+ /**
+ * Returns an url to a file that can be streamed by the player or null if
+ * this url is not known.
+ */
+ public String getStreamUrl();
+
+ /**
+ * Returns true if a local file that can be played is available. getFileUrl
+ * MUST return a non-null string if this method returns true.
+ */
+ public boolean localFileAvailable();
+
+ /**
+ * Returns true if a streamable file is available. getStreamUrl MUST return
+ * a non-null string if this method returns true.
+ */
+ public boolean streamAvailable();
+
+ /**
+ * Saves the current position of this object. Implementations can use the
+ * provided SharedPreference to save this information and retrieve it later
+ * via PlayableUtils.createInstanceFromPreferences.
+ */
+ public void saveCurrentPosition(SharedPreferences pref, int newPosition);
+
+ public void setPosition(int newPosition);
+
+ public void setDuration(int newDuration);
+
+ /**
+ * Is called by the PlaybackService when playback starts.
+ */
+ public void onPlaybackStart();
+
+ /**
+ * Is called by the PlaybackService when playback is completed.
+ */
+ public void onPlaybackCompleted();
+
+ /**
+ * Returns an integer that must be unique among all Playable classes. The
+ * return value is later used by PlayableUtils to determine the type of the
+ * Playable object that is restored.
+ */
+ public int getPlayableType();
+
+ public void setChapters(List<Chapter> chapters);
+
+ /**
+ * Provides utility methods for Playable objects.
+ */
+ public static class PlayableUtils {
+ private static final String TAG = "PlayableUtils";
+
+ /**
+ * Restores a playable object from a sharedPreferences file. This method might load data from the database,
+ * depending on the type of playable that was restored.
+ *
+ * @param type An integer that represents the type of the Playable object
+ * that is restored.
+ * @param pref The SharedPreferences file from which the Playable object
+ * is restored
+ * @return The restored Playable object
+ */
+ public static Playable createInstanceFromPreferences(Context context, int type,
+ SharedPreferences pref) {
+ // ADD new Playable types here:
+ switch (type) {
+ case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA:
+ long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1);
+ if (mediaId != -1) {
+ return DBReader.getFeedMedia(context, mediaId);
+ }
+ break;
+ case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA:
+ String source = pref.getString(ExternalMedia.PREF_SOURCE_URL,
+ null);
+ String mediaType = pref.getString(
+ ExternalMedia.PREF_MEDIA_TYPE, null);
+ if (source != null && mediaType != null) {
+ int position = pref.getInt(ExternalMedia.PREF_POSITION, 0);
+ return new ExternalMedia(source,
+ MediaType.valueOf(mediaType), position);
+ }
+ break;
+ }
+ Log.e(TAG, "Could not restore Playable object from preferences");
+ return null;
+ }
+ }
+
+ public static class PlayableException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public PlayableException() {
+ super();
+ }
+
+ public PlayableException(String detailMessage, Throwable throwable) {
+ super(detailMessage, throwable);
+ }
+
+ public PlayableException(String detailMessage) {
+ super(detailMessage);
+ }
+
+ public PlayableException(Throwable throwable) {
+ super(throwable);
+ }
+
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java b/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java
new file mode 100644
index 000000000..64dbf4868
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/PlaybackController.java
@@ -0,0 +1,784 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.app.Activity;
+import android.content.*;
+import android.content.res.TypedArray;
+import android.media.MediaPlayer;
+import android.os.AsyncTask;
+import android.os.IBinder;
+import android.preference.PreferenceManager;
+import android.util.Log;
+import android.util.Pair;
+import android.view.SurfaceHolder;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.ImageButton;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.FeedMedia;
+import de.danoeh.antennapod.feed.MediaType;
+import de.danoeh.antennapod.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.preferences.UserPreferences;
+import de.danoeh.antennapod.service.playback.PlaybackService;
+import de.danoeh.antennapod.service.playback.PlaybackServiceMediaPlayer;
+import de.danoeh.antennapod.service.playback.PlayerStatus;
+import de.danoeh.antennapod.storage.DBTasks;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.playback.Playable.PlayableUtils;
+
+import java.util.concurrent.*;
+
+/**
+ * Communicates with the playback service. GUI classes should use this class to
+ * control playback instead of communicating with the PlaybackService directly.
+ */
+public abstract class PlaybackController {
+ private static final String TAG = "PlaybackController";
+
+ public static final int INVALID_TIME = -1;
+
+ private final Activity activity;
+
+ private PlaybackService playbackService;
+ private Playable media;
+ private PlayerStatus status;
+
+ private ScheduledThreadPoolExecutor schedExecutor;
+ private static final int SCHED_EX_POOLSIZE = 1;
+
+ protected MediaPositionObserver positionObserver;
+ protected ScheduledFuture positionObserverFuture;
+
+ private boolean mediaInfoLoaded = false;
+ private boolean released = false;
+
+ /**
+ * True if controller should reinit playback service if 'pause' button is
+ * pressed.
+ */
+ private boolean reinitOnPause;
+
+ public PlaybackController(Activity activity, boolean reinitOnPause) {
+ Validate.notNull(activity);
+
+ this.activity = activity;
+ this.reinitOnPause = reinitOnPause;
+ schedExecutor = new ScheduledThreadPoolExecutor(SCHED_EX_POOLSIZE,
+ new ThreadFactory() {
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ }
+ }, new RejectedExecutionHandler() {
+
+ @Override
+ public void rejectedExecution(Runnable r,
+ ThreadPoolExecutor executor) {
+ Log.w(TAG,
+ "Rejected execution of runnable in schedExecutor");
+ }
+ }
+ );
+ }
+
+ /**
+ * Creates a new connection to the playbackService. Should be called in the
+ * activity's onResume() method.
+ */
+ public void init() {
+ activity.registerReceiver(statusUpdate, new IntentFilter(
+ PlaybackService.ACTION_PLAYER_STATUS_CHANGED));
+
+ activity.registerReceiver(notificationReceiver, new IntentFilter(
+ PlaybackService.ACTION_PLAYER_NOTIFICATION));
+
+ activity.registerReceiver(shutdownReceiver, new IntentFilter(
+ PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
+
+ if (!released) {
+ bindToService();
+ } else {
+ throw new IllegalStateException(
+ "Can't call init() after release() has been called");
+ }
+ }
+
+ /**
+ * Should be called if the PlaybackController is no longer needed, for
+ * example in the activity's onStop() method.
+ */
+ public void release() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Releasing PlaybackController");
+
+ try {
+ activity.unregisterReceiver(statusUpdate);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+
+ try {
+ activity.unregisterReceiver(notificationReceiver);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+
+ try {
+ activity.unbindService(mConnection);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+
+ try {
+ activity.unregisterReceiver(shutdownReceiver);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+ cancelPositionObserver();
+ schedExecutor.shutdownNow();
+ media = null;
+ released = true;
+
+ }
+
+ /**
+ * Should be called in the activity's onPause() method.
+ */
+ public void pause() {
+ mediaInfoLoaded = false;
+ }
+
+ /**
+ * Tries to establish a connection to the PlaybackService. If it isn't
+ * running, the PlaybackService will be started with the last played media
+ * as the arguments of the launch intent.
+ */
+ private void bindToService() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Trying to connect to service");
+ AsyncTask<Void, Void, Intent> intentLoader = new AsyncTask<Void, Void, Intent>() {
+ @Override
+ protected Intent doInBackground(Void... voids) {
+ return getPlayLastPlayedMediaIntent();
+ }
+
+ @Override
+ protected void onPostExecute(Intent serviceIntent) {
+ boolean bound = false;
+ if (!PlaybackService.started) {
+ if (serviceIntent != null) {
+ if (BuildConfig.DEBUG) Log.d(TAG, "Calling start service");
+ activity.startService(serviceIntent);
+ bound = activity.bindService(serviceIntent, mConnection, 0);
+ } else {
+ status = PlayerStatus.STOPPED;
+ setupGUI();
+ handleStatus();
+ }
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG,
+ "PlaybackService is running, trying to connect without start command.");
+ bound = activity.bindService(new Intent(activity,
+ PlaybackService.class), mConnection, 0);
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Result for service binding: " + bound);
+ }
+ };
+ intentLoader.execute();
+ }
+
+ /**
+ * Returns an intent that starts the PlaybackService and plays the last
+ * played media or null if no last played media could be found.
+ */
+ private Intent getPlayLastPlayedMediaIntent() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Trying to restore last played media");
+ SharedPreferences prefs = PreferenceManager
+ .getDefaultSharedPreferences(activity.getApplicationContext());
+ long currentlyPlayingMedia = PlaybackPreferences
+ .getCurrentlyPlayingMedia();
+ if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) {
+ Playable media = PlayableUtils.createInstanceFromPreferences(activity,
+ (int) currentlyPlayingMedia, prefs);
+ if (media != null) {
+ Intent serviceIntent = new Intent(activity,
+ PlaybackService.class);
+ serviceIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media);
+ serviceIntent.putExtra(
+ PlaybackService.EXTRA_START_WHEN_PREPARED, false);
+ serviceIntent.putExtra(
+ PlaybackService.EXTRA_PREPARE_IMMEDIATELY, false);
+ boolean fileExists = media.localFileAvailable();
+ boolean lastIsStream = PlaybackPreferences
+ .getCurrentEpisodeIsStream();
+ if (!fileExists && !lastIsStream && media instanceof FeedMedia) {
+ DBTasks.notifyMissingFeedMediaFile(
+ activity, (FeedMedia) media);
+ }
+ serviceIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM,
+ lastIsStream || !fileExists);
+ return serviceIntent;
+ }
+ }
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "No last played media found");
+ return null;
+ }
+
+ public abstract void setupGUI();
+
+ private void setupPositionObserver() {
+ if ((positionObserverFuture != null && positionObserverFuture
+ .isCancelled())
+ || (positionObserverFuture != null && positionObserverFuture
+ .isDone()) || positionObserverFuture == null) {
+
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Setting up position observer");
+ positionObserver = new MediaPositionObserver();
+ positionObserverFuture = schedExecutor.scheduleWithFixedDelay(
+ positionObserver, MediaPositionObserver.WAITING_INTERVALL,
+ MediaPositionObserver.WAITING_INTERVALL,
+ TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void cancelPositionObserver() {
+ if (positionObserverFuture != null) {
+ boolean result = positionObserverFuture.cancel(true);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "PositionObserver cancelled. Result: " + result);
+ }
+ }
+
+ public abstract void onPositionObserverUpdate();
+
+ private ServiceConnection mConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder service) {
+ playbackService = ((PlaybackService.LocalBinder) service)
+ .getService();
+ if (!released) {
+ queryService();
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Connection to Service established");
+ } else {
+ Log.i(TAG, "Connection to playback service has been established, but controller has already been released");
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ playbackService = null;
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Disconnected from Service");
+
+ }
+ };
+
+ protected BroadcastReceiver statusUpdate = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Received statusUpdate Intent.");
+ if (isConnectedToPlaybackService()) {
+ PlaybackServiceMediaPlayer.PSMPInfo info = playbackService.getPSMPInfo();
+ status = info.playerStatus;
+ media = info.playable;
+ handleStatus();
+ } else {
+ Log.w(TAG,
+ "Couldn't receive status update: playbackService was null");
+ bindToService();
+ }
+ }
+ };
+
+ protected BroadcastReceiver notificationReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (isConnectedToPlaybackService()) {
+ int type = intent.getIntExtra(
+ PlaybackService.EXTRA_NOTIFICATION_TYPE, -1);
+ int code = intent.getIntExtra(
+ PlaybackService.EXTRA_NOTIFICATION_CODE, -1);
+ if (code != -1 && type != -1) {
+ switch (type) {
+ case PlaybackService.NOTIFICATION_TYPE_ERROR:
+ handleError(code);
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_BUFFER_UPDATE:
+ float progress = ((float) code) / 100;
+ onBufferUpdate(progress);
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_RELOAD:
+ cancelPositionObserver();
+ mediaInfoLoaded = false;
+ queryService();
+ onReloadNotification(intent.getIntExtra(
+ PlaybackService.EXTRA_NOTIFICATION_CODE, -1));
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_SLEEPTIMER_UPDATE:
+ onSleepTimerUpdate();
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_BUFFER_START:
+ onBufferStart();
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_BUFFER_END:
+ onBufferEnd();
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END:
+ onPlaybackEnd();
+ break;
+ case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE:
+ onPlaybackSpeedChange();
+ break;
+ }
+
+ } else {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Bad arguments. Won't handle intent");
+ }
+ } else {
+ bindToService();
+ }
+ }
+
+ };
+
+ private BroadcastReceiver shutdownReceiver = new BroadcastReceiver() {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (isConnectedToPlaybackService()) {
+ if (StringUtils.equals(intent.getAction(),
+ PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)) {
+ release();
+ onShutdownNotification();
+ }
+ }
+ }
+ };
+
+ public abstract void onPlaybackSpeedChange();
+
+ public abstract void onShutdownNotification();
+
+ /**
+ * Called when the currently displayed information should be refreshed.
+ */
+ public abstract void onReloadNotification(int code);
+
+ public abstract void onBufferStart();
+
+ public abstract void onBufferEnd();
+
+ public abstract void onBufferUpdate(float progress);
+
+ public abstract void onSleepTimerUpdate();
+
+ public abstract void handleError(int code);
+
+ public abstract void onPlaybackEnd();
+
+ /**
+ * Is called whenever the PlaybackService changes it's status. This method
+ * should be used to update the GUI or start/cancel background threads.
+ */
+ private void handleStatus() {
+ final int playResource;
+ final int pauseResource;
+ final CharSequence playText = activity.getString(R.string.play_label);
+ final CharSequence pauseText = activity.getString(R.string.pause_label);
+
+ if (PlaybackService.getCurrentMediaType() == MediaType.AUDIO) {
+ TypedArray res = activity.obtainStyledAttributes(new int[]{
+ R.attr.av_play, R.attr.av_pause});
+ playResource = res.getResourceId(0, R.drawable.av_play);
+ pauseResource = res.getResourceId(1, R.drawable.av_pause);
+ res.recycle();
+ } else {
+ playResource = R.drawable.ic_action_play_over_video;
+ pauseResource = R.drawable.ic_action_pause_over_video;
+ }
+
+ switch (status) {
+
+ case ERROR:
+ postStatusMsg(R.string.player_error_msg);
+ handleError(MediaPlayer.MEDIA_ERROR_UNKNOWN);
+ break;
+ case PAUSED:
+ clearStatusMsg();
+ checkMediaInfoLoaded();
+ cancelPositionObserver();
+ updatePlayButtonAppearance(playResource, playText);
+ if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) {
+ setScreenOn(false);
+ }
+ break;
+ case PLAYING:
+ clearStatusMsg();
+ checkMediaInfoLoaded();
+ if (PlaybackService.getCurrentMediaType() == MediaType.VIDEO) {
+ onAwaitingVideoSurface();
+ setScreenOn(true);
+ }
+ setupPositionObserver();
+ updatePlayButtonAppearance(pauseResource, pauseText);
+ break;
+ case PREPARING:
+ postStatusMsg(R.string.player_preparing_msg);
+ checkMediaInfoLoaded();
+ if (playbackService != null) {
+ if (playbackService.isStartWhenPrepared()) {
+ updatePlayButtonAppearance(pauseResource, pauseText);
+ } else {
+ updatePlayButtonAppearance(playResource, playText);
+ }
+ }
+ break;
+ case STOPPED:
+ postStatusMsg(R.string.player_stopped_msg);
+ break;
+ case PREPARED:
+ checkMediaInfoLoaded();
+ postStatusMsg(R.string.player_ready_msg);
+ updatePlayButtonAppearance(playResource, playText);
+ break;
+ case SEEKING:
+ postStatusMsg(R.string.player_seeking_msg);
+ break;
+ case INITIALIZED:
+ checkMediaInfoLoaded();
+ clearStatusMsg();
+ updatePlayButtonAppearance(playResource, playText);
+ break;
+ }
+ }
+
+ private void checkMediaInfoLoaded() {
+ mediaInfoLoaded = (mediaInfoLoaded || loadMediaInfo());
+ }
+
+ private void updatePlayButtonAppearance(int resource, CharSequence contentDescription) {
+ ImageButton butPlay = getPlayButton();
+ butPlay.setImageResource(resource);
+ butPlay.setContentDescription(contentDescription);
+ }
+
+ public abstract ImageButton getPlayButton();
+
+ public abstract void postStatusMsg(int msg);
+
+ public abstract void clearStatusMsg();
+
+ public abstract boolean loadMediaInfo();
+
+ public abstract void onAwaitingVideoSurface();
+
+ /**
+ * Called when connection to playback service has been established or
+ * information has to be refreshed
+ */
+ void queryService() {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Querying service info");
+ if (playbackService != null) {
+ status = playbackService.getStatus();
+ media = playbackService.getPlayable();
+ /*
+ if (media == null) {
+ Log.w(TAG,
+ "PlaybackService has no media object. Trying to restore last played media.");
+ Intent serviceIntent = getPlayLastPlayedMediaIntent();
+ if (serviceIntent != null) {
+ activity.startService(serviceIntent);
+ }
+ }
+ */
+ onServiceQueried();
+
+ setupGUI();
+ handleStatus();
+ // make sure that new media is loaded if it's available
+ mediaInfoLoaded = false;
+
+ } else {
+ Log.e(TAG,
+ "queryService() was called without an existing connection to playbackservice");
+ }
+ }
+
+ public abstract void onServiceQueried();
+
+ /**
+ * Should be used by classes which implement the OnSeekBarChanged interface.
+ */
+ public float onSeekBarProgressChanged(SeekBar seekBar, int progress,
+ boolean fromUser, TextView txtvPosition) {
+ if (fromUser && playbackService != null && media != null) {
+ float prog = progress / ((float) seekBar.getMax());
+ int duration = media.getDuration();
+ txtvPosition.setText(Converter
+ .getDurationStringLong((int) (prog * duration)));
+ return prog;
+ }
+ return 0;
+
+ }
+
+ /**
+ * Should be used by classes which implement the OnSeekBarChanged interface.
+ */
+ public void onSeekBarStartTrackingTouch(SeekBar seekBar) {
+ // interrupt position Observer, restart later
+ cancelPositionObserver();
+ }
+
+ /**
+ * Should be used by classes which implement the OnSeekBarChanged interface.
+ */
+ public void onSeekBarStopTrackingTouch(SeekBar seekBar, float prog) {
+ if (playbackService != null) {
+ playbackService.seekTo((int) (prog * media.getDuration()));
+ setupPositionObserver();
+ }
+ }
+
+ /**
+ * Should be implemented by classes that show a video. The default implementation
+ * does nothing
+ *
+ * @param enable True if the screen should be kept on, false otherwise
+ */
+ protected void setScreenOn(boolean enable) {
+
+ }
+
+ public OnClickListener newOnPlayButtonClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (playbackService != null) {
+ switch (status) {
+ case PLAYING:
+ playbackService.pause(true, reinitOnPause);
+ break;
+ case PAUSED:
+ case PREPARED:
+ playbackService.resume();
+ break;
+ case PREPARING:
+ playbackService.setStartWhenPrepared(!playbackService
+ .isStartWhenPrepared());
+ if (reinitOnPause
+ && playbackService.isStartWhenPrepared() == false) {
+ playbackService.reinit();
+ }
+ break;
+ case INITIALIZED:
+ playbackService.setStartWhenPrepared(true);
+ playbackService.prepare();
+ break;
+ }
+ } else {
+ Log.w(TAG,
+ "Play/Pause button was pressed, but playbackservice was null!");
+ }
+ }
+
+ };
+ }
+
+ public OnClickListener newOnRevButtonClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (status == PlayerStatus.PLAYING) {
+ playbackService.seekDelta(-UserPreferences.getSeekDeltaMs());
+ }
+ }
+ };
+ }
+
+ public OnClickListener newOnFFButtonClickListener() {
+ return new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (status == PlayerStatus.PLAYING) {
+ playbackService.seekDelta(UserPreferences.getSeekDeltaMs());
+ }
+ }
+ };
+ }
+
+ public boolean serviceAvailable() {
+ return playbackService != null;
+ }
+
+ public int getPosition() {
+ if (playbackService != null) {
+ return playbackService.getCurrentPosition();
+ } else {
+ return PlaybackService.INVALID_TIME;
+ }
+ }
+
+ public int getDuration() {
+ if (playbackService != null) {
+ return playbackService.getDuration();
+ } else {
+ return PlaybackService.INVALID_TIME;
+ }
+ }
+
+ public Playable getMedia() {
+ return media;
+ }
+
+ public boolean sleepTimerActive() {
+ return playbackService != null && playbackService.sleepTimerActive();
+ }
+
+ public boolean sleepTimerNotActive() {
+ return playbackService != null && !playbackService.sleepTimerActive();
+ }
+
+ public void disableSleepTimer() {
+ if (playbackService != null) {
+ playbackService.disableSleepTimer();
+ }
+ }
+
+ public long getSleepTimerTimeLeft() {
+ if (playbackService != null) {
+ return playbackService.getSleepTimerTimeLeft();
+ } else {
+ return INVALID_TIME;
+ }
+ }
+
+ public void setSleepTimer(long time) {
+ if (playbackService != null) {
+ playbackService.setSleepTimer(time);
+ }
+ }
+
+ public void seekToChapter(Chapter chapter) {
+ if (playbackService != null) {
+ playbackService.seekToChapter(chapter);
+ }
+ }
+
+ public void seekTo(int time) {
+ if (playbackService != null) {
+ playbackService.seekTo(time);
+ }
+ }
+
+ public void setVideoSurface(SurfaceHolder holder) {
+ if (playbackService != null) {
+ playbackService.setVideoSurface(holder);
+ }
+ }
+
+ public PlayerStatus getStatus() {
+ return status;
+ }
+
+ public boolean canSetPlaybackSpeed() {
+ return playbackService != null && playbackService.canSetSpeed();
+ }
+
+ public void setPlaybackSpeed(float speed) {
+ if (playbackService != null) {
+ playbackService.setSpeed(speed);
+ }
+ }
+
+ public float getCurrentPlaybackSpeedMultiplier() {
+ if (canSetPlaybackSpeed()) {
+ return playbackService.getCurrentPlaybackSpeed();
+ } else {
+ return -1;
+ }
+ }
+
+ public boolean isPlayingVideo() {
+ if (playbackService != null) {
+ return PlaybackService.getCurrentMediaType() == MediaType.VIDEO;
+ }
+ return false;
+ }
+
+ public Pair<Integer, Integer> getVideoSize() {
+ if (playbackService != null) {
+ return playbackService.getVideoSize();
+ } else {
+ return null;
+ }
+ }
+
+
+ /**
+ * Returns true if PlaybackController can communicate with the playback
+ * service.
+ */
+ public boolean isConnectedToPlaybackService() {
+ return playbackService != null;
+ }
+
+ public void notifyVideoSurfaceAbandoned() {
+ if (playbackService != null) {
+ playbackService.notifyVideoSurfaceAbandoned();
+ }
+ }
+
+ /**
+ * Move service into INITIALIZED state if it's paused to save bandwidth
+ */
+ public void reinitServiceIfPaused() {
+ if (playbackService != null
+ && playbackService.isStreaming()
+ && (playbackService.getStatus() == PlayerStatus.PAUSED || (playbackService
+ .getStatus() == PlayerStatus.PREPARING && playbackService
+ .isStartWhenPrepared() == false))) {
+ playbackService.reinit();
+ }
+ }
+
+ /**
+ * Refreshes the current position of the media file that is playing.
+ */
+ public class MediaPositionObserver implements Runnable {
+
+ public static final int WAITING_INTERVALL = 1000;
+
+ @Override
+ public void run() {
+ if (playbackService != null && playbackService.getStatus() == PlayerStatus.PLAYING) {
+ activity.runOnUiThread(new Runnable() {
+
+ @Override
+ public void run() {
+ onPositionObserverUpdate();
+ }
+ });
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java b/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java
new file mode 100644
index 000000000..ceed68183
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/Timeline.java
@@ -0,0 +1,161 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.Log;
+import android.util.TypedValue;
+
+import org.apache.commons.lang3.Validate;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.util.Converter;
+import de.danoeh.antennapod.util.ShownotesProvider;
+
+/**
+ * Connects chapter information and shownotes of a shownotesProvider, for example by making it possible to use the
+ * shownotes to navigate to another position in the podcast or by highlighting certain parts of the shownotesProvider's
+ * shownotes.
+ * <p/>
+ * A timeline object needs a shownotesProvider from which the chapter information is retrieved and shownotes are generated.
+ */
+public class Timeline {
+ private static final String TAG = "Timeline";
+
+ private static final String WEBVIEW_STYLE = "@font-face { font-family: 'Roboto-Light'; src: url('file:///android_asset/Roboto-Light.ttf'); } * { color: %s; font-family: roboto-Light; font-size: 11pt; } a { font-style: normal; text-decoration: none; font-weight: normal; color: #00A8DF; } a.timecode { color: #669900; } img { display: block; margin: 10 auto; max-width: %s; height: auto; } body { margin: %dpx %dpx %dpx %dpx; }";
+
+
+ private ShownotesProvider shownotesProvider;
+
+
+ private final String colorString;
+ private final int pageMargin;
+
+ public Timeline(Context context, ShownotesProvider shownotesProvider) {
+ if (shownotesProvider == null) throw new IllegalArgumentException("shownotesProvider = null");
+ this.shownotesProvider = shownotesProvider;
+
+ TypedArray res = context
+ .getTheme()
+ .obtainStyledAttributes(
+ new int[]{android.R.attr.textColorPrimary});
+ int colorResource = res.getColor(0, 0);
+ colorString = String.format("#%06X",
+ 0xFFFFFF & colorResource);
+ res.recycle();
+
+ pageMargin = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 8, context.getResources()
+ .getDisplayMetrics()
+ );
+ }
+
+ private static final Pattern TIMECODE_LINK_REGEX = Pattern.compile("antennapod://timecode/((\\d+))");
+ private static final String TIMECODE_LINK = "<a class=\"timecode\" href=\"antennapod://timecode/%d\">%s</a>";
+ private static final Pattern TIMECODE_REGEX = Pattern.compile("\\b(?:(?:(([0-9][0-9])):))?(([0-9][0-9])):(([0-9][0-9]))\\b");
+
+ /**
+ * Applies an app-specific CSS stylesheet and adds timecode links (optional).
+ * <p/>
+ * This method does NOT change the original shownotes string of the shownotesProvider object and it should
+ * also not be changed by the caller.
+ *
+ * @param addTimecodes True if this method should add timecode links
+ * @return The processed HTML string.
+ */
+ public String processShownotes(final boolean addTimecodes) {
+ final Playable playable = (shownotesProvider instanceof Playable) ? (Playable) shownotesProvider : null;
+
+ // load shownotes
+
+ String shownotes;
+ try {
+ shownotes = shownotesProvider.loadShownotes().call();
+ } catch (Exception e) {
+ e.printStackTrace();
+ return null;
+ }
+ if (shownotes == null) {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "shownotesProvider contained no shownotes. Returning empty string");
+ return "";
+ }
+
+ Document document = Jsoup.parse(shownotes);
+
+ // apply style
+ String styleStr = String.format(WEBVIEW_STYLE, colorString, "100%", pageMargin,
+ pageMargin, pageMargin, pageMargin);
+ document.head().appendElement("style").attr("type", "text/css").text(styleStr);
+
+ // apply timecode links
+ if (addTimecodes) {
+ Elements elementsWithTimeCodes = document.body().getElementsMatchingOwnText(TIMECODE_REGEX);
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Recognized " + elementsWithTimeCodes.size() + " timecodes");
+ for (Element element : elementsWithTimeCodes) {
+ Matcher matcherLong = TIMECODE_REGEX.matcher(element.text());
+ StringBuffer buffer = new StringBuffer();
+ while (matcherLong.find()) {
+ String h = matcherLong.group(1);
+ String group = matcherLong.group(0);
+ int time = (h != null) ? Converter.durationStringLongToMs(group) :
+ Converter.durationStringShortToMs(group);
+
+ String rep;
+ if (playable == null || playable.getDuration() > time) {
+ rep = String.format(TIMECODE_LINK, time, group);
+ } else {
+ rep = group;
+ }
+ matcherLong.appendReplacement(buffer, rep);
+ }
+ matcherLong.appendTail(buffer);
+
+ element.html(buffer.toString());
+ }
+ }
+
+ Log.i(TAG, "Out: " + document.toString());
+ return document.toString();
+ }
+
+
+ /**
+ * Returns true if the given link is a timecode link.
+ */
+ public static boolean isTimecodeLink(String link) {
+ return link != null && link.matches(TIMECODE_LINK_REGEX.pattern());
+ }
+
+ /**
+ * Returns the time in milliseconds that is attached to this link or -1
+ * if the link is no valid timecode link.
+ */
+ public static int getTimecodeLinkTime(String link) {
+ if (isTimecodeLink(link)) {
+ Matcher m = TIMECODE_LINK_REGEX.matcher(link);
+
+ try {
+ if (m.find()) {
+ return Integer.valueOf(m.group(1));
+ }
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ }
+ }
+ return -1;
+ }
+
+
+ public void setShownotesProvider(ShownotesProvider shownotesProvider) {
+ Validate.notNull(shownotesProvider);
+ this.shownotesProvider = shownotesProvider;
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java b/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java
new file mode 100644
index 000000000..ea9c692ab
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/playback/VideoPlayer.java
@@ -0,0 +1,67 @@
+package de.danoeh.antennapod.util.playback;
+
+import android.media.MediaPlayer;
+import android.util.Log;
+
+public class VideoPlayer extends MediaPlayer implements IPlayer {
+ private static final String TAG = "VideoPlayer";
+
+ @Override
+ public boolean canSetPitch() {
+ return false;
+ }
+
+ @Override
+ public boolean canSetSpeed() {
+ return false;
+ }
+
+ @Override
+ public float getCurrentPitchStepsAdjustment() {
+ return 1;
+ }
+
+ @Override
+ public float getCurrentSpeedMultiplier() {
+ return 1;
+ }
+
+ @Override
+ public float getMaxSpeedMultiplier() {
+ return 1;
+ }
+
+ @Override
+ public float getMinSpeedMultiplier() {
+ return 1;
+ }
+
+ @Override
+ public void setEnableSpeedAdjustment(boolean enableSpeedAdjustment) throws UnsupportedOperationException {
+ Log.e(TAG, "Setting enable speed adjustment unsupported in video player");
+ throw new UnsupportedOperationException("Setting enable speed adjustment unsupported in video player");
+ }
+
+ @Override
+ public void setPitchStepsAdjustment(float pitchSteps) {
+ Log.e(TAG, "Setting pitch steps adjustment unsupported in video player");
+ throw new UnsupportedOperationException("Setting pitch steps adjustment unsupported in video player");
+ }
+
+ @Override
+ public void setPlaybackPitch(float f) {
+ Log.e(TAG, "Setting playback pitch unsupported in video player");
+ throw new UnsupportedOperationException("Setting playback pitch unsupported in video player");
+ }
+
+ @Override
+ public void setPlaybackSpeed(float f) {
+ Log.e(TAG, "Setting playback speed unsupported in video player");
+ throw new UnsupportedOperationException("Setting playback speed unsupported in video player");
+ }
+
+ @Override
+ public void setVideoScalingMode(int mode) {
+ super.setVideoScalingMode(mode);
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java b/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java
new file mode 100644
index 000000000..ac38ec876
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/syndication/FeedDiscoverer.java
@@ -0,0 +1,78 @@
+package de.danoeh.antennapod.util.syndication;
+
+import android.net.Uri;
+import org.apache.commons.lang3.StringUtils;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Finds RSS/Atom URLs in a HTML document using the auto-discovery techniques described here:
+ * <p/>
+ * http://www.rssboard.org/rss-autodiscovery
+ * <p/>
+ * http://blog.whatwg.org/feed-autodiscovery
+ */
+public class FeedDiscoverer {
+
+ private static final String MIME_RSS = "application/rss+xml";
+ private static final String MIME_ATOM = "application/atom+xml";
+
+ /**
+ * Discovers links to RSS and Atom feeds in the given File which must be a HTML document.
+ *
+ * @return A map which contains the feed URLs as keys and titles as values (the feed URL is also used as a title if
+ * a title cannot be found).
+ */
+ public Map<String, String> findLinks(File in, String baseUrl) throws IOException {
+ return findLinks(Jsoup.parse(in, null), baseUrl);
+ }
+
+ /**
+ * Discovers links to RSS and Atom feeds in the given File which must be a HTML document.
+ *
+ * @return A map which contains the feed URLs as keys and titles as values (the feed URL is also used as a title if
+ * a title cannot be found).
+ */
+ public Map<String, String> findLinks(String in, String baseUrl) throws IOException {
+ return findLinks(Jsoup.parse(in), baseUrl);
+ }
+
+ private Map<String, String> findLinks(Document document, String baseUrl) {
+ Map<String, String> res = new LinkedHashMap<String, String>();
+ Elements links = document.head().getElementsByTag("link");
+ for (Element link : links) {
+ String rel = link.attr("rel");
+ String href = link.attr("href");
+ if (!StringUtils.isEmpty(href) &&
+ (rel.equals("alternate") || rel.equals("feed"))) {
+ String type = link.attr("type");
+ if (type.equals(MIME_RSS) || type.equals(MIME_ATOM)) {
+ String title = link.attr("title");
+ String processedUrl = processURL(baseUrl, href);
+ if (processedUrl != null) {
+ res.put(processedUrl,
+ (StringUtils.isEmpty(title)) ? href : title);
+ }
+ }
+ }
+ }
+ return res;
+ }
+
+ private String processURL(String baseUrl, String strUrl) {
+ Uri uri = Uri.parse(strUrl);
+ if (uri.isRelative()) {
+ Uri res = Uri.parse(baseUrl).buildUpon().path(strUrl).build();
+ return (res != null) ? res.toString() : null;
+ } else {
+ return strUrl;
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java
new file mode 100644
index 000000000..767034ed2
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/OggInputStream.java
@@ -0,0 +1,81 @@
+package de.danoeh.antennapod.util.vorbiscommentreader;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+public class OggInputStream extends InputStream {
+ private InputStream input;
+
+ /** True if OggInputStream is currently inside an Ogg page. */
+ private boolean isInPage;
+ private long bytesLeft;
+
+ public OggInputStream(InputStream input) {
+ super();
+ isInPage = false;
+ this.input = input;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (!isInPage) {
+ readOggPage();
+ }
+
+ if (isInPage && bytesLeft > 0) {
+ int result = input.read();
+ bytesLeft -= 1;
+ if (bytesLeft == 0) {
+ isInPage = false;
+ }
+ return result;
+ }
+ return -1;
+ }
+
+ private void readOggPage() throws IOException {
+ // find OggS
+ int[] buffer = new int[4];
+ int c = 0;
+ boolean isInOggS = false;
+ while ((c = input.read()) != -1) {
+ switch (c) {
+ case 'O':
+ isInOggS = true;
+ buffer[0] = c;
+ break;
+ case 'g':
+ if (buffer[1] != c) {
+ buffer[1] = c;
+ } else {
+ buffer[2] = c;
+ }
+ break;
+ case 'S':
+ buffer[3] = c;
+ break;
+ default:
+ if (isInOggS) {
+ Arrays.fill(buffer, 0);
+ isInOggS = false;
+ }
+ }
+ if (buffer[0] == 'O' && buffer[1] == 'g' && buffer[2] == 'g'
+ && buffer[3] == 'S') {
+ break;
+ }
+ }
+ // read segments
+ IOUtils.skipFully(input, 22);
+ bytesLeft = 0;
+ int numSegments = input.read();
+ for (int i = 0; i < numSegments; i++) {
+ bytesLeft += input.read();
+ }
+ isInPage = true;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java
new file mode 100644
index 000000000..b2f149ddd
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentChapterReader.java
@@ -0,0 +1,101 @@
+package de.danoeh.antennapod.util.vorbiscommentreader;
+
+import android.util.Log;
+import de.danoeh.antennapod.BuildConfig;
+import de.danoeh.antennapod.feed.Chapter;
+import de.danoeh.antennapod.feed.VorbisCommentChapter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class VorbisCommentChapterReader extends VorbisCommentReader {
+ private static final String TAG = "VorbisCommentChapterReader";
+
+ private static final String CHAPTER_KEY = "chapter\\d\\d\\d.*";
+ private static final String CHAPTER_ATTRIBUTE_TITLE = "name";
+ private static final String CHAPTER_ATTRIBUTE_LINK = "url";
+
+ private List<Chapter> chapters;
+
+ public VorbisCommentChapterReader() {
+ }
+
+ @Override
+ public void onVorbisCommentFound() {
+ System.out.println("Vorbis comment found");
+ }
+
+ @Override
+ public void onVorbisCommentHeaderFound(VorbisCommentHeader header) {
+ chapters = new ArrayList<Chapter>();
+ System.out.println(header.toString());
+ }
+
+ @Override
+ public boolean onContentVectorKey(String content) {
+ return content.matches(CHAPTER_KEY);
+ }
+
+ @Override
+ public void onContentVectorValue(String key, String value)
+ throws VorbisCommentReaderException {
+ if (BuildConfig.DEBUG)
+ Log.d(TAG, "Key: " + key + ", value: " + value);
+ String attribute = VorbisCommentChapter.getAttributeTypeFromKey(key);
+ int id = VorbisCommentChapter.getIDFromKey(key);
+ Chapter chapter = getChapterById(id);
+ if (attribute == null) {
+ if (getChapterById(id) == null) {
+ // new chapter
+ long start = VorbisCommentChapter.getStartTimeFromValue(value);
+ chapter = new VorbisCommentChapter(id);
+ chapter.setStart(start);
+ chapters.add(chapter);
+ } else {
+ throw new VorbisCommentReaderException(
+ "Found chapter with duplicate ID (" + key + ", "
+ + value + ")");
+ }
+ } else if (attribute.equals(CHAPTER_ATTRIBUTE_TITLE)) {
+ if (chapter != null) {
+ chapter.setTitle(value);
+ }
+ } else if (attribute.equals(CHAPTER_ATTRIBUTE_LINK)) {
+ if (chapter != null) {
+ chapter.setLink(value);
+ }
+ }
+ }
+
+ @Override
+ public void onNoVorbisCommentFound() {
+ System.out.println("No vorbis comment found");
+ }
+
+ @Override
+ public void onEndOfComment() {
+ System.out.println("End of comment");
+ for (Chapter c : chapters) {
+ System.out.println(c.toString());
+ }
+ }
+
+ @Override
+ public void onError(VorbisCommentReaderException exception) {
+ exception.printStackTrace();
+ }
+
+ private Chapter getChapterById(long id) {
+ for (Chapter c : chapters) {
+ if (((VorbisCommentChapter) c).getVorbisCommentId() == id) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java
new file mode 100644
index 000000000..8c47393c9
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentHeader.java
@@ -0,0 +1,26 @@
+package de.danoeh.antennapod.util.vorbiscommentreader;
+public class VorbisCommentHeader {
+ private String vendorString;
+ private long userCommentLength;
+
+ public VorbisCommentHeader(String vendorString, long userCommentLength) {
+ super();
+ this.vendorString = vendorString;
+ this.userCommentLength = userCommentLength;
+ }
+
+ @Override
+ public String toString() {
+ return "VorbisCommentHeader [vendorString=" + vendorString
+ + ", userCommentLength=" + userCommentLength + "]";
+ }
+
+ public String getVendorString() {
+ return vendorString;
+ }
+
+ public long getUserCommentLength() {
+ return userCommentLength;
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java
new file mode 100644
index 000000000..718a4f30f
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReader.java
@@ -0,0 +1,194 @@
+package de.danoeh.antennapod.util.vorbiscommentreader;
+
+import org.apache.commons.io.EndianUtils;
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+
+
+public abstract class VorbisCommentReader {
+ /** Length of first page in an ogg file in bytes. */
+ private static final int FIRST_PAGE_LENGTH = 58;
+ private static final int SECOND_PAGE_MAX_LENGTH = 64 * 1024 * 1024;
+ private static final int PACKET_TYPE_IDENTIFICATION = 1;
+ private static final int PACKET_TYPE_COMMENT = 3;
+
+ /** Called when Reader finds identification header. */
+ public abstract void onVorbisCommentFound();
+
+ public abstract void onVorbisCommentHeaderFound(VorbisCommentHeader header);
+
+ /**
+ * Is called every time the Reader finds a content vector. The handler
+ * should return true if it wants to handle the content vector.
+ */
+ public abstract boolean onContentVectorKey(String content);
+
+ /**
+ * Is called if onContentVectorKey returned true for the key.
+ *
+ * @throws VorbisCommentReaderException
+ */
+ public abstract void onContentVectorValue(String key, String value)
+ throws VorbisCommentReaderException;
+
+ public abstract void onNoVorbisCommentFound();
+
+ public abstract void onEndOfComment();
+
+ public abstract void onError(VorbisCommentReaderException exception);
+
+ public void readInputStream(InputStream input)
+ throws VorbisCommentReaderException {
+ try {
+ // look for identification header
+ if (findIdentificationHeader(input)) {
+
+ onVorbisCommentFound();
+ input = new OggInputStream(input);
+ if (findCommentHeader(input)) {
+ VorbisCommentHeader commentHeader = readCommentHeader(input);
+ if (commentHeader != null) {
+ onVorbisCommentHeaderFound(commentHeader);
+ for (int i = 0; i < commentHeader
+ .getUserCommentLength(); i++) {
+ try {
+ long vectorLength = EndianUtils
+ .readSwappedUnsignedInteger(input);
+ String key = readContentVectorKey(input,
+ vectorLength).toLowerCase();
+ boolean readValue = onContentVectorKey(key);
+ if (readValue) {
+ String value = readUTF8String(
+ input,
+ (int) (vectorLength - key.length() - 1));
+ onContentVectorValue(key, value);
+ } else {
+ IOUtils.skipFully(input,
+ vectorLength - key.length() - 1);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ onEndOfComment();
+ }
+
+ } else {
+ onError(new VorbisCommentReaderException(
+ "No comment header found"));
+ }
+ } else {
+ onNoVorbisCommentFound();
+ }
+ } catch (IOException e) {
+ onError(new VorbisCommentReaderException(e));
+ }
+ }
+
+ private String readUTF8String(InputStream input, long length)
+ throws IOException {
+ byte[] buffer = new byte[(int) length];
+
+ IOUtils.readFully(input, buffer);
+ Charset charset = Charset.forName("UTF-8");
+ return charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString();
+ }
+
+ /**
+ * Looks for an identification header in the first page of the file. If an
+ * identification header is found, it will be skipped completely and the
+ * method will return true, otherwise false.
+ *
+ * @throws IOException
+ */
+ private boolean findIdentificationHeader(InputStream input)
+ throws IOException {
+ byte[] buffer = new byte[FIRST_PAGE_LENGTH];
+ IOUtils.readFully(input, buffer);
+ int i;
+ for (i = 6; i < buffer.length; i++) {
+ if (buffer[i - 5] == 'v' && buffer[i - 4] == 'o'
+ && buffer[i - 3] == 'r' && buffer[i - 2] == 'b'
+ && buffer[i - 1] == 'i' && buffer[i] == 's'
+ && buffer[i - 6] == PACKET_TYPE_IDENTIFICATION) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean findCommentHeader(InputStream input) throws IOException {
+ char[] buffer = new char["vorbis".length() + 1];
+ for (int bytesRead = 0; bytesRead < SECOND_PAGE_MAX_LENGTH; bytesRead++) {
+ char c = (char) input.read();
+ int dest = -1;
+ switch (c) {
+ case PACKET_TYPE_COMMENT:
+ dest = 0;
+ break;
+ case 'v':
+ dest = 1;
+ break;
+ case 'o':
+ dest = 2;
+ break;
+ case 'r':
+ dest = 3;
+ break;
+ case 'b':
+ dest = 4;
+ break;
+ case 'i':
+ dest = 5;
+ break;
+ case 's':
+ dest = 6;
+ break;
+ }
+ if (dest >= 0) {
+ buffer[dest] = c;
+ if (buffer[1] == 'v' && buffer[2] == 'o' && buffer[3] == 'r'
+ && buffer[4] == 'b' && buffer[5] == 'i'
+ && buffer[6] == 's' && buffer[0] == PACKET_TYPE_COMMENT) {
+ return true;
+ }
+ } else {
+ Arrays.fill(buffer, (char) 0);
+ }
+ }
+ return false;
+ }
+
+ private VorbisCommentHeader readCommentHeader(InputStream input)
+ throws IOException, VorbisCommentReaderException {
+ try {
+ long vendorLength = EndianUtils.readSwappedUnsignedInteger(input);
+ String vendorName = readUTF8String(input, vendorLength);
+ long userCommentLength = EndianUtils
+ .readSwappedUnsignedInteger(input);
+ return new VorbisCommentHeader(vendorName, userCommentLength);
+ } catch (UnsupportedEncodingException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ }
+
+ private String readContentVectorKey(InputStream input, long vectorLength)
+ throws IOException {
+ StringBuffer buffer = new StringBuffer();
+ for (int i = 0; i < vectorLength; i++) {
+ char c = (char) input.read();
+ if (c == '=') {
+ return buffer.toString();
+ } else {
+ buffer.append(c);
+ }
+ }
+ return null; // no key found
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java
new file mode 100644
index 000000000..574373241
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/util/vorbiscommentreader/VorbisCommentReaderException.java
@@ -0,0 +1,24 @@
+package de.danoeh.antennapod.util.vorbiscommentreader;
+public class VorbisCommentReaderException extends Exception {
+
+ public VorbisCommentReaderException() {
+ super();
+ // TODO Auto-generated constructor stub
+ }
+
+ public VorbisCommentReaderException(String arg0, Throwable arg1) {
+ super(arg0, arg1);
+ // TODO Auto-generated constructor stub
+ }
+
+ public VorbisCommentReaderException(String arg0) {
+ super(arg0);
+ // TODO Auto-generated constructor stub
+ }
+
+ public VorbisCommentReaderException(Throwable arg0) {
+ super(arg0);
+ // TODO Auto-generated constructor stub
+ }
+
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java b/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java
new file mode 100644
index 000000000..f930c912a
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/view/AspectRatioVideoView.java
@@ -0,0 +1,97 @@
+package de.danoeh.antennapod.view;
+
+/*
+ * Copyright (C) Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.VideoView;
+
+public class AspectRatioVideoView extends VideoView {
+
+
+ private int mVideoWidth;
+ private int mVideoHeight;
+
+ public AspectRatioVideoView(Context context) {
+ this(context, null);
+ }
+
+ public AspectRatioVideoView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public AspectRatioVideoView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+
+ mVideoWidth = 0;
+ mVideoHeight = 0;
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ if (mVideoWidth <= 0 || mVideoHeight <= 0) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ return;
+ }
+
+ float heightRatio = (float) mVideoHeight / (float) getHeight();
+ float widthRatio = (float) mVideoWidth / (float) getWidth();
+
+ int scaledHeight;
+ int scaledWidth;
+
+ if (heightRatio > widthRatio) {
+ scaledHeight = (int) Math.ceil((float) mVideoHeight
+ / heightRatio);
+ scaledWidth = (int) Math.ceil((float) mVideoWidth
+ / heightRatio);
+ } else {
+ scaledHeight = (int) Math.ceil((float) mVideoHeight
+ / widthRatio);
+ scaledWidth = (int) Math.ceil((float) mVideoWidth
+ / widthRatio);
+ }
+
+ setMeasuredDimension(scaledWidth, scaledHeight);
+ }
+
+ /**
+ * Source code originally from:
+ * http://clseto.mysinablog.com/index.php?op=ViewArticle&articleId=2992625
+ *
+ * @param videoWidth
+ * @param videoHeight
+ */
+ public void setVideoSize(int videoWidth, int videoHeight) {
+ // Set the new video size
+ mVideoWidth = videoWidth;
+ mVideoHeight = videoHeight;
+
+ /**
+ * If this isn't set the video is stretched across the
+ * SurfaceHolders display surface (i.e. the SurfaceHolder
+ * as the same size and the video is drawn to fit this
+ * display area). We want the size to be the video size
+ * and allow the aspectratio to handle how the surface is shown
+ */
+ getHolder().setFixedSize(videoWidth, videoHeight);
+
+ requestLayout();
+ invalidate();
+ }
+
+}