From 658559699f5cd482bb19ade298db43a65d750664 Mon Sep 17 00:00:00 2001 From: daniel oeh Date: Sat, 11 Oct 2014 17:43:07 +0200 Subject: Moved core classes into subproject --- .../java/com/aocate/media/AndroidMediaPlayer.java | 470 +++++++ .../main/java/com/aocate/media/MediaPlayer.java | 1278 +++++++++++++++++++ .../java/com/aocate/media/MediaPlayerImpl.java | 118 ++ .../com/aocate/media/ServiceBackedMediaPlayer.java | 1170 +++++++++++++++++ .../com/aocate/media/SpeedAdjustmentAlgorithm.java | 31 + .../antennapod/core/ApplicationCallbacks.java | 22 + .../de/danoeh/antennapod/core/ClientConfig.java | 25 + .../antennapod/core/DownloadServiceCallbacks.java | 44 + .../de/danoeh/antennapod/core/FlattrCallbacks.java | 36 + .../danoeh/antennapod/core/GpodnetCallbacks.java | 27 + .../antennapod/core/PlaybackServiceCallbacks.java | 21 + .../danoeh/antennapod/core/StorageCallbacks.java | 27 + .../core/asynctask/DownloadObserver.java | 177 +++ .../antennapod/core/asynctask/FeedRemover.java | 74 ++ .../core/asynctask/FlattrClickWorker.java | 237 ++++ .../core/asynctask/FlattrStatusFetcher.java | 47 + .../core/asynctask/FlattrTokenFetcher.java | 92 ++ .../core/asynctask/PicassoImageResource.java | 37 + .../antennapod/core/asynctask/PicassoProvider.java | 152 +++ .../antennapod/core/backup/OpmlBackupAgent.java | 211 ++++ .../antennapod/core/dialog/ConfirmationDialog.java | 64 + .../dialog/DownloadRequestErrorDialogCreator.java | 30 + .../de/danoeh/antennapod/core/feed/Chapter.java | 55 + .../antennapod/core/feed/EventDistributor.java | 140 +++ .../java/de/danoeh/antennapod/core/feed/Feed.java | 445 +++++++ .../danoeh/antennapod/core/feed/FeedComponent.java | 66 + .../de/danoeh/antennapod/core/feed/FeedFile.java | 105 ++ .../de/danoeh/antennapod/core/feed/FeedImage.java | 71 ++ .../de/danoeh/antennapod/core/feed/FeedItem.java | 332 +++++ .../de/danoeh/antennapod/core/feed/FeedMedia.java | 410 ++++++ .../antennapod/core/feed/FeedPreferences.java | 89 ++ .../de/danoeh/antennapod/core/feed/ID3Chapter.java | 36 + .../de/danoeh/antennapod/core/feed/MediaType.java | 5 + .../danoeh/antennapod/core/feed/SearchResult.java | 34 + .../danoeh/antennapod/core/feed/SimpleChapter.java | 25 + .../antennapod/core/feed/VorbisCommentChapter.java | 109 ++ .../antennapod/core/gpoddernet/GpodnetService.java | 718 +++++++++++ .../GpodnetServiceAuthenticationException.java | 21 + .../GpodnetServiceBadStatusCodeException.java | 12 + .../core/gpoddernet/GpodnetServiceException.java | 19 + .../core/gpoddernet/model/GpodnetDevice.java | 72 ++ .../core/gpoddernet/model/GpodnetPodcast.java | 65 + .../model/GpodnetSubscriptionChange.java | 41 + .../core/gpoddernet/model/GpodnetTag.java | 46 + .../model/GpodnetUploadChangesResponse.java | 56 + .../danoeh/antennapod/core/opml/OpmlElement.java | 46 + .../de/danoeh/antennapod/core/opml/OpmlReader.java | 87 ++ .../danoeh/antennapod/core/opml/OpmlSymbols.java | 21 + .../de/danoeh/antennapod/core/opml/OpmlWriter.java | 65 + .../core/preferences/GpodnetPreferences.java | 247 ++++ .../core/preferences/PlaybackPreferences.java | 146 +++ .../core/preferences/UserPreferences.java | 577 +++++++++ .../core/receiver/AlarmUpdateReceiver.java | 33 + .../core/receiver/ConnectivityActionReceiver.java | 46 + .../core/receiver/FeedUpdateReceiver.java | 46 + .../core/receiver/MediaButtonReceiver.java | 32 + .../core/service/GpodnetSyncService.java | 251 ++++ .../core/service/download/APRedirectHandler.java | 54 + .../service/download/AntennapodHttpClient.java | 98 ++ .../core/service/download/DownloadRequest.java | 209 ++++ .../core/service/download/DownloadService.java | 1200 ++++++++++++++++++ .../core/service/download/DownloadStatus.java | 181 +++ .../core/service/download/Downloader.java | 73 ++ .../core/service/download/DownloaderCallback.java | 10 + .../core/service/download/HttpDownloader.java | 252 ++++ .../core/service/playback/PlaybackService.java | 1072 ++++++++++++++++ .../playback/PlaybackServiceMediaPlayer.java | 979 +++++++++++++++ .../playback/PlaybackServiceTaskManager.java | 384 ++++++ .../core/service/playback/PlayerStatus.java | 14 + .../danoeh/antennapod/core/storage/DBReader.java | 908 ++++++++++++++ .../de/danoeh/antennapod/core/storage/DBTasks.java | 895 +++++++++++++ .../danoeh/antennapod/core/storage/DBWriter.java | 974 +++++++++++++++ .../core/storage/DownloadRequestException.java | 25 + .../antennapod/core/storage/DownloadRequester.java | 366 ++++++ .../core/storage/FeedItemStatistics.java | 70 ++ .../antennapod/core/storage/FeedSearcher.java | 57 + .../antennapod/core/storage/PodDBAdapter.java | 1310 ++++++++++++++++++++ .../core/syndication/handler/FeedHandler.java | 34 + .../syndication/handler/FeedHandlerResult.java | 19 + .../core/syndication/handler/HandlerState.java | 98 ++ .../core/syndication/handler/SyndHandler.java | 126 ++ .../core/syndication/handler/TypeGetter.java | 111 ++ .../handler/UnsupportedFeedtypeException.java | 38 + .../core/syndication/namespace/NSContent.java | 25 + .../core/syndication/namespace/NSITunes.java | 51 + .../core/syndication/namespace/NSMedia.java | 68 + .../core/syndication/namespace/NSRSS20.java | 141 +++ .../syndication/namespace/NSSimpleChapters.java | 42 + .../core/syndication/namespace/Namespace.java | 21 + .../core/syndication/namespace/SyndElement.java | 22 + .../core/syndication/namespace/atom/AtomText.java | 46 + .../core/syndication/namespace/atom/NSAtom.java | 194 +++ .../core/syndication/util/SyndDateUtils.java | 153 +++ .../core/syndication/util/SyndTypeUtils.java | 42 + .../danoeh/antennapod/core/util/ChapterUtils.java | 261 ++++ .../de/danoeh/antennapod/core/util/Converter.java | 103 ++ .../danoeh/antennapod/core/util/DownloadError.java | 52 + .../de/danoeh/antennapod/core/util/DuckType.java | 117 ++ .../danoeh/antennapod/core/util/EpisodeFilter.java | 49 + .../antennapod/core/util/FeedtitleComparator.java | 15 + .../antennapod/core/util/FileNameGenerator.java | 36 + .../antennapod/core/util/InvalidFeedException.java | 21 + .../de/danoeh/antennapod/core/util/LangUtils.java | 120 ++ .../danoeh/antennapod/core/util/NetworkUtils.java | 69 ++ .../danoeh/antennapod/core/util/QueueAccess.java | 93 ++ .../de/danoeh/antennapod/core/util/ShareUtils.java | 34 + .../antennapod/core/util/ShownotesProvider.java | 16 + .../danoeh/antennapod/core/util/StorageUtils.java | 67 + .../de/danoeh/antennapod/core/util/ThemeUtils.java | 23 + .../de/danoeh/antennapod/core/util/URIUtil.java | 35 + .../de/danoeh/antennapod/core/util/URLChecker.java | 51 + .../antennapod/core/util/UndoBarController.java | 137 ++ .../comparator/ChapterStartTimeComparator.java | 20 + .../util/comparator/DownloadStatusComparator.java | 15 + .../util/comparator/FeedItemPubdateComparator.java | 19 + .../PlaybackCompletionDateComparator.java | 19 + .../comparator/SearchResultValueComparator.java | 14 + .../util/exception/MediaFileNotFoundException.java | 23 + .../core/util/flattr/FlattrServiceCreator.java | 25 + .../antennapod/core/util/flattr/FlattrStatus.java | 68 + .../antennapod/core/util/flattr/FlattrThing.java | 7 + .../antennapod/core/util/flattr/FlattrUtils.java | 304 +++++ .../core/util/flattr/SimpleFlattrThing.java | 30 + .../core/util/gui/FeedItemUndoToken.java | 55 + .../core/util/id3reader/ChapterReader.java | 118 ++ .../antennapod/core/util/id3reader/ID3Reader.java | 250 ++++ .../core/util/id3reader/ID3ReaderException.java | 20 + .../core/util/id3reader/model/FrameHeader.java | 17 + .../core/util/id3reader/model/Header.java | 29 + .../core/util/id3reader/model/TagHeader.java | 26 + .../antennapod/core/util/playback/AudioPlayer.java | 34 + .../core/util/playback/ExternalMedia.java | 235 ++++ .../antennapod/core/util/playback/IPlayer.java | 69 ++ .../core/util/playback/MediaPlayerError.java | 23 + .../antennapod/core/util/playback/Playable.java | 207 ++++ .../core/util/playback/PlaybackController.java | 784 ++++++++++++ .../antennapod/core/util/playback/Timeline.java | 161 +++ .../antennapod/core/util/playback/VideoPlayer.java | 67 + .../core/util/syndication/FeedDiscoverer.java | 78 ++ .../util/vorbiscommentreader/OggInputStream.java | 81 ++ .../VorbisCommentChapterReader.java | 101 ++ .../vorbiscommentreader/VorbisCommentHeader.java | 26 + .../vorbiscommentreader/VorbisCommentReader.java | 194 +++ .../VorbisCommentReaderException.java | 24 + 144 files changed, 23763 insertions(+) create mode 100644 core/src/main/java/com/aocate/media/AndroidMediaPlayer.java create mode 100644 core/src/main/java/com/aocate/media/MediaPlayer.java create mode 100644 core/src/main/java/com/aocate/media/MediaPlayerImpl.java create mode 100644 core/src/main/java/com/aocate/media/ServiceBackedMediaPlayer.java create mode 100644 core/src/main/java/com/aocate/media/SpeedAdjustmentAlgorithm.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/ApplicationCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/FlattrCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/GpodnetCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/StorageCallbacks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/DownloadObserver.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/FeedRemover.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrStatusFetcher.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrTokenFetcher.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoImageResource.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/dialog/ConfirmationDialog.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/dialog/DownloadRequestErrorDialogCreator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/Chapter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/EventDistributor.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedComponent.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedFile.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedImage.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/ID3Chapter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/SearchResult.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/SimpleChapter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/feed/VorbisCommentChapter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetService.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceAuthenticationException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceBadStatusCodeException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetDevice.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetPodcast.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetSubscriptionChange.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetTag.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetUploadChangesResponse.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/opml/OpmlElement.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/opml/OpmlReader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/opml/OpmlSymbols.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/opml/OpmlWriter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/receiver/AlarmUpdateReceiver.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/receiver/ConnectivityActionReceiver.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/APRedirectHandler.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/AntennapodHttpClient.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadRequest.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadStatus.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/Downloader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/DownloaderCallback.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequestException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/FeedItemStatistics.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandler.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandlerResult.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/HandlerState.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/SyndHandler.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/TypeGetter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSSimpleChapters.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/Namespace.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/SyndElement.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomText.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndDateUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndTypeUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/Converter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/DuckType.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/EpisodeFilter.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/FeedtitleComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/InvalidFeedException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/QueueAccess.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/URIUtil.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/UndoBarController.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/comparator/ChapterStartTimeComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/comparator/DownloadStatusComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/comparator/FeedItemPubdateComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/comparator/PlaybackCompletionDateComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/comparator/SearchResultValueComparator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/exception/MediaFileNotFoundException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrServiceCreator.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrStatus.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrThing.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrUtils.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/flattr/SimpleFlattrThing.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/gui/FeedItemUndoToken.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3ReaderException.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/Header.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/syndication/FeedDiscoverer.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/OggInputStream.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentHeader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReader.java create mode 100644 core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReaderException.java (limited to 'core/src/main/java') diff --git a/core/src/main/java/com/aocate/media/AndroidMediaPlayer.java b/core/src/main/java/com/aocate/media/AndroidMediaPlayer.java new file mode 100644 index 000000000..17ee74a13 --- /dev/null +++ b/core/src/main/java/com/aocate/media/AndroidMediaPlayer.java @@ -0,0 +1,470 @@ +// Copyright 2011, Aocate, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.aocate.media; + +import java.io.IOException; + +import android.content.Context; +import android.media.MediaPlayer; +import android.net.Uri; +import android.util.Log; + +public class AndroidMediaPlayer extends MediaPlayerImpl { + private final static String AMP_TAG = "AocateAndroidMediaPlayer"; + + // private static final long TIMEOUT_DURATION_MS = 500; + + android.media.MediaPlayer mp = null; + + private android.media.MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener = new android.media.MediaPlayer.OnBufferingUpdateListener() { + public void onBufferingUpdate(android.media.MediaPlayer mp, int percent) { + if (owningMediaPlayer != null) { + owningMediaPlayer.lock.lock(); + try { + if ((owningMediaPlayer.onBufferingUpdateListener != null) + && (owningMediaPlayer.mpi == AndroidMediaPlayer.this)) { + owningMediaPlayer.onBufferingUpdateListener.onBufferingUpdate(owningMediaPlayer, percent); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + } + }; + + private android.media.MediaPlayer.OnCompletionListener onCompletionListener = new android.media.MediaPlayer.OnCompletionListener() { + public void onCompletion(android.media.MediaPlayer mp) { + Log.d(AMP_TAG, "onCompletionListener being called"); + if (owningMediaPlayer != null) { + owningMediaPlayer.lock.lock(); + try { + if (owningMediaPlayer.onCompletionListener != null) { + owningMediaPlayer.onCompletionListener.onCompletion(owningMediaPlayer); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + } + }; + + private android.media.MediaPlayer.OnErrorListener onErrorListener = new android.media.MediaPlayer.OnErrorListener() { + public boolean onError(android.media.MediaPlayer mp, int what, int extra) { + // Once we're in errored state, any received messages are going to be junked + if (owningMediaPlayer != null) { + owningMediaPlayer.lock.lock(); + try { + if (owningMediaPlayer.onErrorListener != null) { + return owningMediaPlayer.onErrorListener.onError(owningMediaPlayer, what, extra); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + return false; + } + }; + + private android.media.MediaPlayer.OnInfoListener onInfoListener = new android.media.MediaPlayer.OnInfoListener() { + public boolean onInfo(android.media.MediaPlayer mp, int what, int extra) { + if (owningMediaPlayer != null) { + owningMediaPlayer.lock.lock(); + try { + if ((owningMediaPlayer.onInfoListener != null) + && (owningMediaPlayer.mpi == AndroidMediaPlayer.this)) { + return owningMediaPlayer.onInfoListener.onInfo(owningMediaPlayer, what, extra); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + return false; + } + }; + + // We have to assign this.onPreparedListener because the + // onPreparedListener in owningMediaPlayer sets the state + // to PREPARED. Due to prepareAsync, that's the only + // reasonable place to do it + // The others it just didn't make sense to have a setOnXListener that didn't use the parameter + private android.media.MediaPlayer.OnPreparedListener onPreparedListener = new android.media.MediaPlayer.OnPreparedListener() { + public void onPrepared(android.media.MediaPlayer mp) { + Log.d(AMP_TAG, "Calling onPreparedListener.onPrepared()"); + if (AndroidMediaPlayer.this.owningMediaPlayer != null) { + AndroidMediaPlayer.this.lockMuteOnPreparedCount.lock(); + try { + if (AndroidMediaPlayer.this.muteOnPreparedCount > 0) { + AndroidMediaPlayer.this.muteOnPreparedCount--; + } + else { + AndroidMediaPlayer.this.muteOnPreparedCount = 0; + if (AndroidMediaPlayer.this.owningMediaPlayer.onPreparedListener != null) { + Log.d(AMP_TAG, "Invoking AndroidMediaPlayer.this.owningMediaPlayer.onPreparedListener.onPrepared"); + AndroidMediaPlayer.this.owningMediaPlayer.onPreparedListener.onPrepared(AndroidMediaPlayer.this.owningMediaPlayer); + } + } + } + finally { + AndroidMediaPlayer.this.lockMuteOnPreparedCount.unlock(); + } + if (owningMediaPlayer.mpi != AndroidMediaPlayer.this) { + Log.d(AMP_TAG, "owningMediaPlayer has changed implementation"); + } + } + } + }; + + private android.media.MediaPlayer.OnSeekCompleteListener onSeekCompleteListener = new android.media.MediaPlayer.OnSeekCompleteListener() { + public void onSeekComplete(android.media.MediaPlayer mp) { + if (owningMediaPlayer != null) { + owningMediaPlayer.lock.lock(); + try { + lockMuteOnSeekCount.lock(); + try { + if (AndroidMediaPlayer.this.muteOnSeekCount > 0) { + AndroidMediaPlayer.this.muteOnSeekCount--; + } + else { + AndroidMediaPlayer.this.muteOnSeekCount = 0; + if (AndroidMediaPlayer.this.owningMediaPlayer.onSeekCompleteListener != null) { + owningMediaPlayer.onSeekCompleteListener.onSeekComplete(owningMediaPlayer); + } + } + } + finally { + lockMuteOnSeekCount.unlock(); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + } + }; + + public AndroidMediaPlayer(com.aocate.media.MediaPlayer owningMediaPlayer, Context context) { + super(owningMediaPlayer, context); + + mp = new MediaPlayer(); + +// final ReentrantLock lock = new ReentrantLock(); +// Handler handler = new Handler(Looper.getMainLooper()) { +// @Override +// public void handleMessage(Message msg) { +// Log.d(AMP_TAG, "Instantiating new AndroidMediaPlayer from Handler"); +// lock.lock(); +// if (mp == null) { +// mp = new MediaPlayer(); +// } +// lock.unlock(); +// } +// }; +// +// long endTime = System.currentTimeMillis() + TIMEOUT_DURATION_MS; +// +// while (true) { +// // Retry messages until mp isn't null or it's time to give up +// handler.sendMessage(handler.obtainMessage()); +// if ((mp != null) +// || (endTime < System.currentTimeMillis())) { +// break; +// } +// try { +// Thread.sleep(50); +// } catch (InterruptedException e) { +// // TODO Auto-generated catch block +// e.printStackTrace(); +// } +// } + + if (mp == null) { + throw new IllegalStateException("Did not instantiate android.media.MediaPlayer successfully"); + } + + mp.setOnBufferingUpdateListener(this.onBufferingUpdateListener); + mp.setOnCompletionListener(this.onCompletionListener); + mp.setOnErrorListener(this.onErrorListener); + mp.setOnInfoListener(this.onInfoListener); + Log.d(AMP_TAG, " ++++++++++++++++++++++++++++++++ Setting prepared listener to this.onPreparedListener"); + mp.setOnPreparedListener(this.onPreparedListener); + mp.setOnSeekCompleteListener(this.onSeekCompleteListener); + } + + @Override + public boolean canSetPitch() { + return false; + } + + @Override + public boolean canSetSpeed() { + return false; + } + + @Override + public float getCurrentPitchStepsAdjustment() { + return 0; + } + + @Override + public int getCurrentPosition() { + owningMediaPlayer.lock.lock(); + try { + return mp.getCurrentPosition(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public float getCurrentSpeedMultiplier() { + return 1f; + } + + @Override + public int getDuration() { + owningMediaPlayer.lock.lock(); + try { + return mp.getDuration(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public float getMaxSpeedMultiplier() { + return 1f; + } + + @Override + public float getMinSpeedMultiplier() { + return 1f; + } + + @Override + public boolean isLooping() { + owningMediaPlayer.lock.lock(); + try { + return mp.isLooping(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public boolean isPlaying() { + owningMediaPlayer.lock.lock(); + try { + return mp.isPlaying(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void pause() { + owningMediaPlayer.lock.lock(); + try { + mp.pause(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void prepare() throws IllegalStateException, IOException { + owningMediaPlayer.lock.lock(); + Log.d(AMP_TAG, "prepare()"); + try { + mp.prepare(); + Log.d(AMP_TAG, "Finish prepare()"); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void prepareAsync() { + mp.prepareAsync(); + } + + @Override + public void release() { + owningMediaPlayer.lock.lock(); + try { + if (mp != null) { + Log.d(AMP_TAG, "mp.release()"); + mp.release(); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void reset() { + owningMediaPlayer.lock.lock(); + try { + mp.reset(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void seekTo(int msec) throws IllegalStateException { + owningMediaPlayer.lock.lock(); + try { + mp.setOnSeekCompleteListener(this.onSeekCompleteListener); + mp.seekTo(msec); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setAudioStreamType(int streamtype) { + owningMediaPlayer.lock.lock(); + try { + mp.setAudioStreamType(streamtype); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setDataSource(Context context, Uri uri) + throws IllegalArgumentException, IllegalStateException, IOException { + owningMediaPlayer.lock.lock(); + try { + Log.d(AMP_TAG, "setDataSource(context, " + uri.toString() + ")"); + mp.setDataSource(context, uri); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setDataSource(String path) throws IllegalArgumentException, + IllegalStateException, IOException { + owningMediaPlayer.lock.lock(); + try { + Log.d(AMP_TAG, "setDataSource(" + path + ")"); + mp.setDataSource(path); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setEnableSpeedAdjustment(boolean enableSpeedAdjustment) { + // Can't! + } + + @Override + public void setLooping(boolean loop) { + owningMediaPlayer.lock.lock(); + try { + mp.setLooping(loop); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setPitchStepsAdjustment(float pitchSteps) { + // Can't! + } + + @Override + public void setPlaybackPitch(float f) { + // Can't! + } + + @Override + public void setPlaybackSpeed(float f) { + // Can't! + Log.d(AMP_TAG, "setPlaybackSpeed(" + f + ")"); + } + + @Override + public void setSpeedAdjustmentAlgorithm(int algorithm) { + // Can't! + Log.d(AMP_TAG, "setSpeedAdjustmentAlgorithm(" + algorithm + ")"); + } + + @Override + public void setVolume(float leftVolume, float rightVolume) { + owningMediaPlayer.lock.lock(); + try { + mp.setVolume(leftVolume, rightVolume); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void setWakeMode(Context context, int mode) { + owningMediaPlayer.lock.lock(); + try { + if (mode != 0) { + mp.setWakeMode(context, mode); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void start() { + owningMediaPlayer.lock.lock(); + try { + mp.start(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + @Override + public void stop() { + owningMediaPlayer.lock.lock(); + try { + mp.stop(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } +} diff --git a/core/src/main/java/com/aocate/media/MediaPlayer.java b/core/src/main/java/com/aocate/media/MediaPlayer.java new file mode 100644 index 000000000..c73c5219e --- /dev/null +++ b/core/src/main/java/com/aocate/media/MediaPlayer.java @@ -0,0 +1,1278 @@ +// Copyright 2011, Aocate, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.aocate.media; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.IBinder; +import android.os.Message; +import android.util.Log; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import de.danoeh.antennapod.core.BuildConfig; + +public class MediaPlayer { + public interface OnBufferingUpdateListener { + public abstract void onBufferingUpdate(MediaPlayer arg0, int percent); + } + + public interface OnCompletionListener { + public abstract void onCompletion(MediaPlayer arg0); + } + + public interface OnErrorListener { + public abstract boolean onError(MediaPlayer arg0, int what, int extra); + } + + public interface OnInfoListener { + public abstract boolean onInfo(MediaPlayer arg0, int what, int extra); + } + + public interface OnPitchAdjustmentAvailableChangedListener { + /** + * @param arg0 The owning media player + * @param pitchAdjustmentAvailable True if pitch adjustment is available, false if not + */ + public abstract void onPitchAdjustmentAvailableChanged( + MediaPlayer arg0, boolean pitchAdjustmentAvailable); + } + + public interface OnPreparedListener { + public abstract void onPrepared(MediaPlayer arg0); + } + + public interface OnSeekCompleteListener { + public abstract void onSeekComplete(MediaPlayer arg0); + } + + public interface OnSpeedAdjustmentAvailableChangedListener { + /** + * @param arg0 The owning media player + * @param speedAdjustmentAvailable True if speed adjustment is available, false if not + */ + public abstract void onSpeedAdjustmentAvailableChanged( + MediaPlayer arg0, boolean speedAdjustmentAvailable); + } + + public enum State { + IDLE, INITIALIZED, PREPARED, STARTED, PAUSED, STOPPED, PREPARING, PLAYBACK_COMPLETED, END, ERROR + } + + private static Uri SPEED_ADJUSTMENT_MARKET_URI = Uri + .parse("market://details?id=com.aocate.presto"); + + private static Intent prestoMarketIntent = null; + + public static final int MEDIA_ERROR_SERVER_DIED = android.media.MediaPlayer.MEDIA_ERROR_SERVER_DIED; + public static final int MEDIA_ERROR_UNKNOWN = android.media.MediaPlayer.MEDIA_ERROR_UNKNOWN; + public static final int MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK = android.media.MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK; + + /** + * Indicates whether the specified action can be used as an intent. This + * method queries the package manager for installed packages that can + * respond to an intent with the specified action. If no suitable package is + * found, this method returns false. + * + * @param context The application's environment. + * @param action The Intent action to check for availability. + * @return True if an Intent with the specified action can be sent and + * responded to, false otherwise. + */ + public static boolean isIntentAvailable(Context context, String action) { + final PackageManager packageManager = context.getPackageManager(); + final Intent intent = new Intent(action); + List list = packageManager.queryIntentServices(intent, + PackageManager.MATCH_DEFAULT_ONLY); + return list.size() > 0; + } + + /** + * Indicates whether the Presto library is installed + * + * @param context The context to use to query the package manager. + * @return True if the Presto library is installed, false if not. + */ + public static boolean isPrestoLibraryInstalled(Context context) { + return isIntentAvailable(context, ServiceBackedMediaPlayer.INTENT_NAME); + } + + /** + * Return an Intent that opens the Android Market page for the speed + * alteration library + * + * @return The Intent for the Presto library on the Android Market + */ + public static Intent getPrestoMarketIntent() { + if (prestoMarketIntent == null) { + prestoMarketIntent = new Intent(Intent.ACTION_VIEW, + SPEED_ADJUSTMENT_MARKET_URI); + } + return prestoMarketIntent; + } + + /** + * Open the Android Market page for the Presto library + * + * @param context The context from which to open the Android Market page + */ + public static void openPrestoMarketIntent(Context context) { + context.startActivity(getPrestoMarketIntent()); + } + + private static final String MP_TAG = "AocateReplacementMediaPlayer"; + + private static final double PITCH_STEP_CONSTANT = 1.0594630943593; + + private AndroidMediaPlayer amp = null; + // This is whether speed adjustment should be enabled (by the Service) + // To avoid the Service entirely, set useService to false + protected boolean enableSpeedAdjustment = true; + private int lastKnownPosition = 0; + // In some cases, we're going to have to replace the + // android.media.MediaPlayer on the fly, and we don't want to touch the + // wrong media player, so lock it way too much. + ReentrantLock lock = new ReentrantLock(); + private int mAudioStreamType = AudioManager.STREAM_MUSIC; + private Context mContext; + private boolean mIsLooping = false; + private float mLeftVolume = 1f; + private float mPitchStepsAdjustment = 0f; + private float mRightVolume = 1f; + private float mSpeedMultiplier = 1f; + private int mWakeMode = 0; + MediaPlayerImpl mpi = null; + protected boolean pitchAdjustmentAvailable = false; + private ServiceBackedMediaPlayer sbmp = null; + protected boolean speedAdjustmentAvailable = false; + + private Handler mServiceDisconnectedHandler = null; + + // Some parts of state cannot be found by calling MediaPlayerImpl functions, + // so store our own state. This also helps copy state when changing + // implementations + State state = State.INITIALIZED; + String stringDataSource = null; + Uri uriDataSource = null; + private boolean useService = false; + + // Naming Convention for Listeners + // Most listeners can both be set by clients and called by MediaPlayImpls + // There are a few that have to do things in this class as well as calling + // the function. In all cases, onX is what is called by MediaPlayerImpl + // If there is work to be done in this class, then the listener that is + // set by setX is X (with the first letter lowercase). + OnBufferingUpdateListener onBufferingUpdateListener = null; + OnCompletionListener onCompletionListener = null; + OnErrorListener onErrorListener = null; + OnInfoListener onInfoListener = null; + + // Special case. Pitch adjustment ceases to be available when we switch + // to the android.media.MediaPlayer (though it is not guaranteed to be + // available when using the ServiceBackedMediaPlayer) + OnPitchAdjustmentAvailableChangedListener onPitchAdjustmentAvailableChangedListener = new OnPitchAdjustmentAvailableChangedListener() { + public void onPitchAdjustmentAvailableChanged(MediaPlayer arg0, + boolean pitchAdjustmentAvailable) { + lock.lock(); + try { + Log + .d( + MP_TAG, + "onPitchAdjustmentAvailableChangedListener.onPitchAdjustmentAvailableChanged being called"); + if (MediaPlayer.this.pitchAdjustmentAvailable != pitchAdjustmentAvailable) { + Log.d(MP_TAG, "Pitch adjustment state has changed from " + + MediaPlayer.this.pitchAdjustmentAvailable + + " to " + pitchAdjustmentAvailable); + MediaPlayer.this.pitchAdjustmentAvailable = pitchAdjustmentAvailable; + if (MediaPlayer.this.pitchAdjustmentAvailableChangedListener != null) { + MediaPlayer.this.pitchAdjustmentAvailableChangedListener + .onPitchAdjustmentAvailableChanged(arg0, + pitchAdjustmentAvailable); + } + } + } finally { + lock.unlock(); + } + } + }; + OnPitchAdjustmentAvailableChangedListener pitchAdjustmentAvailableChangedListener = null; + + MediaPlayer.OnPreparedListener onPreparedListener = new MediaPlayer.OnPreparedListener() { + public void onPrepared(MediaPlayer arg0) { + Log.d(MP_TAG, "onPreparedListener 242 setting state to PREPARED"); + MediaPlayer.this.state = State.PREPARED; + if (MediaPlayer.this.preparedListener != null) { + Log.d(MP_TAG, "Calling preparedListener"); + MediaPlayer.this.preparedListener.onPrepared(arg0); + } + Log.d(MP_TAG, "Wrap up onPreparedListener"); + } + }; + + OnPreparedListener preparedListener = null; + OnSeekCompleteListener onSeekCompleteListener = null; + + // Special case. Speed adjustment ceases to be available when we switch + // to the android.media.MediaPlayer (though it is not guaranteed to be + // available when using the ServiceBackedMediaPlayer) + OnSpeedAdjustmentAvailableChangedListener onSpeedAdjustmentAvailableChangedListener = new OnSpeedAdjustmentAvailableChangedListener() { + public void onSpeedAdjustmentAvailableChanged(MediaPlayer arg0, + boolean speedAdjustmentAvailable) { + lock.lock(); + try { + Log + .d( + MP_TAG, + "onSpeedAdjustmentAvailableChangedListener.onSpeedAdjustmentAvailableChanged being called"); + if (MediaPlayer.this.speedAdjustmentAvailable != speedAdjustmentAvailable) { + Log.d(MP_TAG, "Speed adjustment state has changed from " + + MediaPlayer.this.speedAdjustmentAvailable + + " to " + speedAdjustmentAvailable); + MediaPlayer.this.speedAdjustmentAvailable = speedAdjustmentAvailable; + if (MediaPlayer.this.speedAdjustmentAvailableChangedListener != null) { + MediaPlayer.this.speedAdjustmentAvailableChangedListener + .onSpeedAdjustmentAvailableChanged(arg0, + speedAdjustmentAvailable); + } + } + } finally { + lock.unlock(); + } + } + }; + OnSpeedAdjustmentAvailableChangedListener speedAdjustmentAvailableChangedListener = null; + + private int speedAdjustmentAlgorithm = SpeedAdjustmentAlgorithm.SONIC; + + public MediaPlayer(final Context context) { + this(context, true); + } + + public MediaPlayer(final Context context, boolean useService) { + this.mContext = context; + this.useService = useService; + + // So here's the major problem + // Sometimes the service won't exist or won't be connected, + // so start with an android.media.MediaPlayer, and when + // the service is connected, use that from then on + this.mpi = this.amp = new AndroidMediaPlayer(this, context); + + // setupMpi will go get the Service, if it can, then bring that + // implementation into sync + Log.d(MP_TAG, "setupMpi"); + setupMpi(context); + } + + private boolean invalidServiceConnectionConfiguration() { + if (!(this.mpi instanceof ServiceBackedMediaPlayer)) { + if (this.useService && isPrestoLibraryInstalled()) { + // In this case, the Presto library has been installed + // or something while playing sound + // We could be using the service, but we're not + Log.d(MP_TAG, "We could be using the service, but we're not 316"); + return true; + } + // If useService is false, then we shouldn't be using the SBMP + // If the Presto library isn't installed, ditto + Log.d(MP_TAG, "this.mpi is not a ServiceBackedMediaPlayer, but we couldn't use it anyway 321"); + return false; + } else { + if (BuildConfig.DEBUG && !(this.mpi instanceof ServiceBackedMediaPlayer)) + throw new AssertionError(); + if (this.useService && isPrestoLibraryInstalled()) { + // We should be using the service, and we are. Great! + Log.d(MP_TAG, "We could be using a ServiceBackedMediaPlayer and we are 327"); + return false; + } + // We're trying to use the service when we shouldn't, + // that's an invalid configuration + Log.d(MP_TAG, "We're trying to use a ServiceBackedMediaPlayer but we shouldn't be 332"); + return true; + } + } + + private void setupMpi(final Context context) { + lock.lock(); + try { + Log.d(MP_TAG, "setupMpi 336"); + // Check if the client wants to use the service at all, + // then if we're already using the right kind of media player + if (this.useService && isPrestoLibraryInstalled()) { + if ((this.mpi != null) + && (this.mpi instanceof ServiceBackedMediaPlayer)) { + Log.d(MP_TAG, "Already using ServiceBackedMediaPlayer"); + return; + } + if (this.sbmp == null) { + Log.d(MP_TAG, "Instantiating new ServiceBackedMediaPlayer 346"); + this.sbmp = new ServiceBackedMediaPlayer(this, context, + new ServiceConnection() { + public void onServiceConnected( + ComponentName className, + final IBinder service) { + Thread t = new Thread(new Runnable() { + public void run() { + // This lock probably isn't granular + // enough + MediaPlayer.this.lock.lock(); + Log.d(MP_TAG, + "onServiceConnected 257"); + try { + MediaPlayer.this + .switchMediaPlayerImpl( + MediaPlayer.this.amp, + MediaPlayer.this.sbmp); + Log.d(MP_TAG, "End onServiceConnected 362"); + } finally { + MediaPlayer.this.lock.unlock(); + } + } + }); + t.start(); + } + + public void onServiceDisconnected( + ComponentName className) { + MediaPlayer.this.lock.lock(); + try { + // Can't get any more useful information + // out of sbmp + if (MediaPlayer.this.sbmp != null) { + MediaPlayer.this.sbmp.release(); + } + // Unlike most other cases, sbmp gets set + // to null since there's nothing useful + // backing it now + MediaPlayer.this.sbmp = null; + + if (mServiceDisconnectedHandler == null) { + mServiceDisconnectedHandler = new Handler(new Callback() { + public boolean handleMessage(Message msg) { + // switchMediaPlayerImpl won't try to + // clone anything from null + lock.lock(); + try { + if (MediaPlayer.this.amp == null) { + // This should never be in this state + MediaPlayer.this.amp = new AndroidMediaPlayer( + MediaPlayer.this, + MediaPlayer.this.mContext); + } + // Use sbmp instead of null in case by some miracle it's + // been restored in the meantime + MediaPlayer.this.switchMediaPlayerImpl( + MediaPlayer.this.sbmp, + MediaPlayer.this.amp); + return true; + } finally { + lock.unlock(); + } + } + }); + } + + // This code needs to execute on the + // original thread to instantiate + // the new object in the right place + mServiceDisconnectedHandler + .sendMessage( + mServiceDisconnectedHandler + .obtainMessage()); + // Note that we do NOT want to set + // useService. useService is about + // what the user wants, not what they + // get + } finally { + MediaPlayer.this.lock.unlock(); + } + } + } + ); + } + switchMediaPlayerImpl(this.amp, this.sbmp); + } else { + if ((this.mpi != null) + && (this.mpi instanceof AndroidMediaPlayer)) { + Log.d(MP_TAG, "Already using AndroidMediaPlayer"); + return; + } + if (this.amp == null) { + Log.d(MP_TAG, "Instantiating new AndroidMediaPlayer (this should be impossible)"); + this.amp = new AndroidMediaPlayer(this, context); + } + switchMediaPlayerImpl(this.sbmp, this.amp); + } + } finally { + lock.unlock(); + } + } + + private void switchMediaPlayerImpl(MediaPlayerImpl from, MediaPlayerImpl to) { + lock.lock(); + try { + Log.d(MP_TAG, "switchMediaPlayerImpl"); + if ((from == to) + // Same object, nothing to synchronize + || (to == null) + // Nothing to copy to (maybe this should throw an error?) + || ((to instanceof ServiceBackedMediaPlayer) && !((ServiceBackedMediaPlayer) to).isConnected()) + // ServiceBackedMediaPlayer hasn't yet connected, onServiceConnected will take care of the transition + || (MediaPlayer.this.state == State.END)) { + // State.END is after a release(), no further functions should + // be called on this class and from is likely to have problems + // retrieving state that won't be used anyway + return; + } + // Extract all that we can from the existing implementation + // and copy it to the new implementation + + Log.d(MP_TAG, "switchMediaPlayerImpl(), current state is " + + this.state.toString()); + + to.reset(); + + // Do this first so we don't have to prepare the same + // data file twice + to.setEnableSpeedAdjustment(MediaPlayer.this.enableSpeedAdjustment); + + // This is a reasonable place to set all of these, + // none of them require prepare() or the like first + to.setAudioStreamType(this.mAudioStreamType); + to.setSpeedAdjustmentAlgorithm(this.speedAdjustmentAlgorithm); + to.setLooping(this.mIsLooping); + to.setPitchStepsAdjustment(this.mPitchStepsAdjustment); + Log.d(MP_TAG, "Setting playback speed to " + this.mSpeedMultiplier); + to.setPlaybackSpeed(this.mSpeedMultiplier); + to.setVolume(MediaPlayer.this.mLeftVolume, + MediaPlayer.this.mRightVolume); + to.setWakeMode(this.mContext, this.mWakeMode); + + Log.d(MP_TAG, "asserting at least one data source is null"); + assert ((MediaPlayer.this.stringDataSource == null) || (MediaPlayer.this.uriDataSource == null)); + + if (uriDataSource != null) { + Log.d(MP_TAG, "switchMediaPlayerImpl(): uriDataSource != null"); + try { + to.setDataSource(this.mContext, uriDataSource); + } catch (IllegalArgumentException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IllegalStateException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + if (stringDataSource != null) { + Log.d(MP_TAG, + "switchMediaPlayerImpl(): stringDataSource != null"); + try { + to.setDataSource(stringDataSource); + } catch (IllegalArgumentException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IllegalStateException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + if ((this.state == State.PREPARED) + || (this.state == State.PREPARING) + || (this.state == State.PAUSED) + || (this.state == State.STOPPED) + || (this.state == State.STARTED) + || (this.state == State.PLAYBACK_COMPLETED)) { + Log.d(MP_TAG, "switchMediaPlayerImpl(): prepare and seek"); + // Use prepare here instead of prepareAsync so that + // we wait for it to be ready before we try to use it + try { + to.muteNextOnPrepare(); + to.prepare(); + } catch (IllegalStateException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + int seekPos = 0; + if (from != null) { + seekPos = from.getCurrentPosition(); + } else if (this.lastKnownPosition < to.getDuration()) { + // This can happen if the Service unexpectedly + // disconnected. Because it would result in too much + // information being passed around, we don't constantly + // poll for the lastKnownPosition, but we'll save it + // when getCurrentPosition is called + seekPos = this.lastKnownPosition; + } + to.muteNextSeek(); + to.seekTo(seekPos); + } + if ((from != null) + && from.isPlaying()) { + from.pause(); + } + if ((this.state == State.STARTED) + || (this.state == State.PAUSED) + || (this.state == State.STOPPED)) { + Log.d(MP_TAG, "switchMediaPlayerImpl(): start"); + if (to != null) { + to.start(); + } + } + + if (this.state == State.PAUSED) { + Log.d(MP_TAG, "switchMediaPlayerImpl(): paused"); + if (to != null) { + to.pause(); + } + } else if (this.state == State.STOPPED) { + Log.d(MP_TAG, "switchMediaPlayerImpl(): stopped"); + if (to != null) { + to.stop(); + } + } + + this.mpi = to; + + // Cheating here by relying on the side effect in + // on(Pitch|Speed)AdjustmentAvailableChanged + if ((to.canSetPitch() != this.pitchAdjustmentAvailable) + && (this.onPitchAdjustmentAvailableChangedListener != null)) { + this.onPitchAdjustmentAvailableChangedListener + .onPitchAdjustmentAvailableChanged(this, to + .canSetPitch()); + } + if ((to.canSetSpeed() != this.speedAdjustmentAvailable) + && (this.onSpeedAdjustmentAvailableChangedListener != null)) { + this.onSpeedAdjustmentAvailableChangedListener + .onSpeedAdjustmentAvailableChanged(this, to + .canSetSpeed()); + } + Log.d(MP_TAG, "switchMediaPlayerImpl() 625 " + this.state.toString()); + } finally { + lock.unlock(); + } + } + + /** + * Returns true if pitch can be changed at this moment + * + * @return True if pitch can be changed + */ + public boolean canSetPitch() { + lock.lock(); + try { + return this.mpi.canSetPitch(); + } finally { + lock.unlock(); + } + } + + /** + * Returns true if speed can be changed at this moment + * + * @return True if speed can be changed + */ + public boolean canSetSpeed() { + lock.lock(); + try { + return this.mpi.canSetSpeed(); + } finally { + lock.unlock(); + } + } + + protected void finalize() throws Throwable { + lock.lock(); + try { + Log.d(MP_TAG, "finalize() 626"); + this.release(); + } finally { + lock.unlock(); + } + } + + /** + * Returns the number of steps (in a musical scale) by which playback is + * currently shifted. When greater than zero, pitch is shifted up. When less + * than zero, pitch is shifted down. + * + * @return The number of steps pitch is currently shifted by + */ + public float getCurrentPitchStepsAdjustment() { + lock.lock(); + try { + return this.mpi.getCurrentPitchStepsAdjustment(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.getCurrentPosition() + * Accurate only to frame size of encoded data (26 ms for MP3s) + * + * @return Current position (in milliseconds) + */ + public int getCurrentPosition() { + lock.lock(); + try { + return (this.lastKnownPosition = this.mpi.getCurrentPosition()); + } finally { + lock.unlock(); + } + } + + /** + * Returns the current speed multiplier. Defaults to 1.0 (normal speed) + * + * @return The current speed multiplier + */ + public float getCurrentSpeedMultiplier() { + lock.lock(); + try { + return this.mpi.getCurrentSpeedMultiplier(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.getDuration() + * + * @return Length of the track (in milliseconds) + */ + public int getDuration() { + lock.lock(); + try { + return this.mpi.getDuration(); + } finally { + lock.unlock(); + } + } + + /** + * Get the maximum value that can be passed to setPlaybackSpeed + * + * @return The maximum speed multiplier + */ + public float getMaxSpeedMultiplier() { + lock.lock(); + try { + return this.mpi.getMaxSpeedMultiplier(); + } finally { + lock.unlock(); + } + } + + /** + * Get the minimum value that can be passed to setPlaybackSpeed + * + * @return The minimum speed multiplier + */ + public float getMinSpeedMultiplier() { + lock.lock(); + try { + return this.mpi.getMinSpeedMultiplier(); + } finally { + lock.unlock(); + } + } + + /** + * Gets the version code of the backing service + * + * @return -1 if ServiceBackedMediaPlayer is not used, 0 if the service is not + * connected, otherwise the version code retrieved from the service + */ + public int getServiceVersionCode() { + lock.lock(); + try { + if (this.mpi instanceof ServiceBackedMediaPlayer) { + return ((ServiceBackedMediaPlayer) this.mpi).getServiceVersionCode(); + } else { + return -1; + } + } finally { + lock.unlock(); + } + } + + /** + * Gets the version name of the backing service + * + * @return null if ServiceBackedMediaPlayer is not used, empty string if + * the service is not connected, otherwise the version name retrieved from + * the service + */ + public String getServiceVersionName() { + lock.lock(); + try { + if (this.mpi instanceof ServiceBackedMediaPlayer) { + return ((ServiceBackedMediaPlayer) this.mpi).getServiceVersionName(); + } else { + return null; + } + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.isLooping() + * + * @return True if the track is looping + */ + public boolean isLooping() { + lock.lock(); + try { + return this.mpi.isLooping(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.isPlaying() + * + * @return True if the track is playing + */ + public boolean isPlaying() { + lock.lock(); + try { + return this.mpi.isPlaying(); + } finally { + lock.unlock(); + } + } + + /** + * Returns true if this MediaPlayer has access to the Presto + * library + * + * @return True if the Presto library is installed + */ + public boolean isPrestoLibraryInstalled() { + if ((this.mpi == null) || (this.mpi.mContext == null)) { + return false; + } + return isPrestoLibraryInstalled(this.mpi.mContext); + } + + /** + * Open the Android Market page in the same context as this MediaPlayer + */ + public void openPrestoMarketIntent() { + if ((this.mpi != null) && (this.mpi.mContext != null)) { + openPrestoMarketIntent(this.mpi.mContext); + } + } + + /** + * Functions identically to android.media.MediaPlayer.pause() Pauses the + * track + */ + public void pause() { + lock.lock(); + try { + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.PAUSED; + this.mpi.pause(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.prepare() Prepares the + * track. This or prepareAsync must be called before start() + */ + public void prepare() throws IllegalStateException, IOException { + lock.lock(); + try { + Log.d(MP_TAG, "prepare() 746 using " + ((this.mpi == null) ? "null (this shouldn't happen)" : this.mpi.getClass().toString()) + " state " + this.state.toString()); + Log.d(MP_TAG, "onPreparedListener is: " + ((this.onPreparedListener == null) ? "null" : "non-null")); + Log.d(MP_TAG, "preparedListener is: " + ((this.preparedListener == null) ? "null" : "non-null")); + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.mpi.prepare(); + this.state = State.PREPARED; + Log.d(MP_TAG, "prepare() finished 778"); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.prepareAsync() + * Prepares the track. This or prepare must be called before start() + */ + public void prepareAsync() { + lock.lock(); + try { + Log.d(MP_TAG, "prepareAsync() 779"); + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.PREPARING; + this.mpi.prepareAsync(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.release() Releases the + * underlying resources used by the media player. + */ + public void release() { + lock.lock(); + try { + Log.d(MP_TAG, "Releasing MediaPlayer 791"); + + this.state = State.END; + if (this.amp != null) { + this.amp.release(); + } + if (this.sbmp != null) { + this.sbmp.release(); + } + + this.onBufferingUpdateListener = null; + this.onCompletionListener = null; + this.onErrorListener = null; + this.onInfoListener = null; + this.preparedListener = null; + this.onPitchAdjustmentAvailableChangedListener = null; + this.pitchAdjustmentAvailableChangedListener = null; + Log.d(MP_TAG, "Setting onSeekCompleteListener to null 871"); + this.onSeekCompleteListener = null; + this.onSpeedAdjustmentAvailableChangedListener = null; + this.speedAdjustmentAvailableChangedListener = null; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.reset() Resets the + * track to idle state + */ + public void reset() { + lock.lock(); + try { + this.state = State.IDLE; + this.stringDataSource = null; + this.uriDataSource = null; + this.mpi.reset(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.seekTo(int msec) Seeks + * to msec in the track + */ + public void seekTo(int msec) throws IllegalStateException { + lock.lock(); + try { + this.mpi.seekTo(msec); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setAudioStreamType(int + * streamtype) Sets the audio stream type. + */ + public void setAudioStreamType(int streamtype) { + lock.lock(); + try { + this.mAudioStreamType = streamtype; + this.mpi.setAudioStreamType(streamtype); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setDataSource(Context + * context, Uri uri) Sets uri as data source in the context given + */ + public void setDataSource(Context context, Uri uri) + throws IllegalArgumentException, IllegalStateException, IOException { + lock.lock(); + try { + Log.d(MP_TAG, "In setDataSource(context, " + uri.toString() + "), using " + this.mpi.getClass().toString()); + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.INITIALIZED; + this.stringDataSource = null; + this.uriDataSource = uri; + this.mpi.setDataSource(context, uri); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setDataSource(String + * path) Sets the data source of the track to a file given. + */ + public void setDataSource(String path) throws IllegalArgumentException, + IllegalStateException, IOException { + lock.lock(); + try { + Log.d(MP_TAG, "In setDataSource(context, " + path + ")"); + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.INITIALIZED; + this.stringDataSource = path; + this.uriDataSource = null; + this.mpi.setDataSource(path); + } finally { + lock.unlock(); + } + } + + /** + * Sets whether to use speed adjustment or not. Speed adjustment on is more + * computation-intensive than with it off. + * + * @param enableSpeedAdjustment Whether speed adjustment should be supported. + */ + public void setEnableSpeedAdjustment(boolean enableSpeedAdjustment) { + lock.lock(); + try { + this.enableSpeedAdjustment = enableSpeedAdjustment; + this.mpi.setEnableSpeedAdjustment(enableSpeedAdjustment); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setLooping(boolean + * loop) Sets the track to loop infinitely if loop is true, play once if + * loop is false + */ + public void setLooping(boolean loop) { + lock.lock(); + try { + this.mIsLooping = loop; + this.mpi.setLooping(loop); + } finally { + lock.unlock(); + } + } + + /** + * Sets the number of steps (in a musical scale) by which playback is + * currently shifted. When greater than zero, pitch is shifted up. When less + * than zero, pitch is shifted down. + * + * @param pitchSteps The number of steps by which to shift playback + */ + public void setPitchStepsAdjustment(float pitchSteps) { + lock.lock(); + try { + this.mPitchStepsAdjustment = pitchSteps; + this.mpi.setPitchStepsAdjustment(pitchSteps); + } finally { + lock.unlock(); + } + } + + /** + * Set the algorithm to use for changing the speed and pitch of audio + * See SpeedAdjustmentAlgorithm constants for more details + * + * @param algorithm The algorithm to use. + */ + public void setSpeedAdjustmentAlgorithm(int algorithm) { + lock.lock(); + try { + this.speedAdjustmentAlgorithm = algorithm; + if (this.mpi != null) { + this.mpi.setSpeedAdjustmentAlgorithm(algorithm); + } + } finally { + lock.unlock(); + } + } + + private static float getPitchStepsAdjustment(float pitch) { + return (float) (Math.log(pitch) / (2 * Math.log(PITCH_STEP_CONSTANT))); + } + + /** + * Sets the percentage by which pitch is currently shifted. When greater + * than zero, pitch is shifted up. When less than zero, pitch is shifted + * down + * + * @param f The percentage to shift pitch + */ + public void setPlaybackPitch(float pitch) { + lock.lock(); + try { + this.mPitchStepsAdjustment = getPitchStepsAdjustment(pitch); + this.mpi.setPlaybackPitch(pitch); + } finally { + lock.unlock(); + } + } + + /** + * Set playback speed. 1.0 is normal speed, 2.0 is double speed, and so on. + * Speed should never be set to 0 or below. + * + * @param f The speed multiplier to use for further playback + */ + public void setPlaybackSpeed(float f) { + lock.lock(); + try { + this.mSpeedMultiplier = f; + this.mpi.setPlaybackSpeed(f); + } finally { + lock.unlock(); + } + } + + /** + * Sets whether to use speed adjustment or not. Speed adjustment on is more + * computation-intensive than with it off. + * + * @param enableSpeedAdjustment Whether speed adjustment should be supported. + */ + public void setUseService(boolean useService) { + lock.lock(); + try { + this.useService = useService; + setupMpi(this.mpi.mContext); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setVolume(float + * leftVolume, float rightVolume) Sets the stereo volume + */ + public void setVolume(float leftVolume, float rightVolume) { + lock.lock(); + try { + this.mLeftVolume = leftVolume; + this.mRightVolume = rightVolume; + this.mpi.setVolume(leftVolume, rightVolume); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setWakeMode(Context + * context, int mode) Acquires a wake lock in the context given. You must + * request the appropriate permissions in your AndroidManifest.xml file. + */ + public void setWakeMode(Context context, int mode) { + lock.lock(); + try { + this.mWakeMode = mode; + this.mpi.setWakeMode(context, mode); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnCompletionListener(OnCompletionListener + * listener) Sets a listener to be used when a track completes playing. + */ + public void setOnBufferingUpdateListener(OnBufferingUpdateListener listener) { + lock.lock(); + try { + this.onBufferingUpdateListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnCompletionListener(OnCompletionListener + * listener) Sets a listener to be used when a track completes playing. + */ + public void setOnCompletionListener(OnCompletionListener listener) { + lock.lock(); + try { + this.onCompletionListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnErrorListener(OnErrorListener listener) + * Sets a listener to be used when a track encounters an error. + */ + public void setOnErrorListener(OnErrorListener listener) { + lock.lock(); + try { + this.onErrorListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnInfoListener(OnInfoListener listener) Sets + * a listener to be used when a track has info. + */ + public void setOnInfoListener(OnInfoListener listener) { + lock.lock(); + try { + this.onInfoListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Sets a listener that will fire when pitch adjustment becomes available or + * stops being available + */ + public void setOnPitchAdjustmentAvailableChangedListener( + OnPitchAdjustmentAvailableChangedListener listener) { + lock.lock(); + try { + this.pitchAdjustmentAvailableChangedListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnPreparedListener(OnPreparedListener + * listener) Sets a listener to be used when a track finishes preparing. + */ + public void setOnPreparedListener(OnPreparedListener listener) { + lock.lock(); + Log.d(MP_TAG, " ++++++++++++++++++++++++++++++++++++++++++++ setOnPreparedListener"); + try { + this.preparedListener = listener; + // For this one, we do not explicitly set the MediaPlayer or the + // Service listener. This is because in addition to calling the + // listener provided by the client, it's necessary to change + // state to PREPARED. See prepareAsync for implementation details + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to + * android.media.MediaPlayer.setOnSeekCompleteListener + * (OnSeekCompleteListener listener) Sets a listener to be used when a track + * finishes seeking. + */ + public void setOnSeekCompleteListener(OnSeekCompleteListener listener) { + lock.lock(); + try { + this.onSeekCompleteListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Sets a listener that will fire when speed adjustment becomes available or + * stops being available + */ + public void setOnSpeedAdjustmentAvailableChangedListener( + OnSpeedAdjustmentAvailableChangedListener listener) { + lock.lock(); + try { + this.speedAdjustmentAvailableChangedListener = listener; + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.start() Starts a track + * playing + */ + public void start() { + lock.lock(); + try { + Log.d(MP_TAG, "start() 1149"); + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.STARTED; + Log.d(MP_TAG, "start() 1154"); + this.mpi.start(); + } finally { + lock.unlock(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.stop() Stops a track + * playing and resets its position to the start. + */ + public void stop() { + lock.lock(); + try { + if (invalidServiceConnectionConfiguration()) { + setupMpi(this.mpi.mContext); + } + this.state = State.STOPPED; + this.mpi.stop(); + } finally { + lock.unlock(); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/aocate/media/MediaPlayerImpl.java b/core/src/main/java/com/aocate/media/MediaPlayerImpl.java new file mode 100644 index 000000000..856ab47ce --- /dev/null +++ b/core/src/main/java/com/aocate/media/MediaPlayerImpl.java @@ -0,0 +1,118 @@ +// Copyright 2011, Aocate, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.aocate.media; + +import java.io.IOException; +import java.util.concurrent.locks.ReentrantLock; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +public abstract class MediaPlayerImpl { + private static final String MPI_TAG = "AocateMediaPlayerImpl"; + protected final MediaPlayer owningMediaPlayer; + protected final Context mContext; + protected int muteOnPreparedCount = 0; + protected int muteOnSeekCount = 0; + + public MediaPlayerImpl(MediaPlayer owningMediaPlayer, Context context) { + this.owningMediaPlayer = owningMediaPlayer; + + this.mContext = context; + } + + public abstract boolean canSetPitch(); + + public abstract boolean canSetSpeed(); + + public abstract float getCurrentPitchStepsAdjustment(); + + public abstract int getCurrentPosition(); + + public abstract float getCurrentSpeedMultiplier(); + + public abstract int getDuration(); + + public abstract float getMaxSpeedMultiplier(); + + public abstract float getMinSpeedMultiplier(); + + public abstract boolean isLooping(); + + public abstract boolean isPlaying(); + + public abstract void pause(); + + public abstract void prepare() throws IllegalStateException, IOException; + + public abstract void prepareAsync(); + + public abstract void release(); + + public abstract void reset(); + + public abstract void seekTo(int msec) throws IllegalStateException; + + public abstract void setAudioStreamType(int streamtype); + + public abstract void setDataSource(Context context, Uri uri) throws IllegalArgumentException, IllegalStateException, IOException; + + public abstract void setDataSource(String path) throws IllegalArgumentException, IllegalStateException, IOException; + + public abstract void setEnableSpeedAdjustment(boolean enableSpeedAdjustment); + + public abstract void setLooping(boolean loop); + + public abstract void setPitchStepsAdjustment(float pitchSteps); + + public abstract void setPlaybackPitch(float f); + + public abstract void setPlaybackSpeed(float f); + + public abstract void setSpeedAdjustmentAlgorithm(int algorithm); + + public abstract void setVolume(float leftVolume, float rightVolume); + + public abstract void setWakeMode(Context context, int mode); + + public abstract void start(); + + public abstract void stop(); + + protected ReentrantLock lockMuteOnPreparedCount = new ReentrantLock(); + public void muteNextOnPrepare() { + lockMuteOnPreparedCount.lock(); + Log.d(MPI_TAG, "muteNextOnPrepare()"); + try { + this.muteOnPreparedCount++; + } + finally { + lockMuteOnPreparedCount.unlock(); + } + } + + protected ReentrantLock lockMuteOnSeekCount = new ReentrantLock(); + public void muteNextSeek() { + lockMuteOnSeekCount.lock(); + Log.d(MPI_TAG, "muteNextOnSeek()"); + try { + this.muteOnSeekCount++; + } + finally { + lockMuteOnSeekCount.unlock(); + } + } +} diff --git a/core/src/main/java/com/aocate/media/ServiceBackedMediaPlayer.java b/core/src/main/java/com/aocate/media/ServiceBackedMediaPlayer.java new file mode 100644 index 000000000..ef4572d33 --- /dev/null +++ b/core/src/main/java/com/aocate/media/ServiceBackedMediaPlayer.java @@ -0,0 +1,1170 @@ +// Copyright 2011, Aocate, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.aocate.media; + +import java.io.IOException; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.AudioManager; +import android.net.Uri; +import android.os.IBinder; +import android.os.PowerManager; +import android.os.RemoteException; +import android.os.PowerManager.WakeLock; +import android.util.Log; + +import com.aocate.media.MediaPlayer.State; +import com.aocate.presto.service.IDeathCallback_0_8; +import com.aocate.presto.service.IOnBufferingUpdateListenerCallback_0_8; +import com.aocate.presto.service.IOnCompletionListenerCallback_0_8; +import com.aocate.presto.service.IOnErrorListenerCallback_0_8; +import com.aocate.presto.service.IOnInfoListenerCallback_0_8; +import com.aocate.presto.service.IOnPitchAdjustmentAvailableChangedListenerCallback_0_8; +import com.aocate.presto.service.IOnPreparedListenerCallback_0_8; +import com.aocate.presto.service.IOnSeekCompleteListenerCallback_0_8; +import com.aocate.presto.service.IOnSpeedAdjustmentAvailableChangedListenerCallback_0_8; +import com.aocate.presto.service.IPlayMedia_0_8; + +/** + * Class for connecting to remote speed-altering, media playing Service + * Note that there is unusually high coupling between MediaPlayer and this + * class. This is an unfortunate compromise, since the alternative was to + * track state in two different places in this code (plus the internal state + * of the remote media player). + * @author aocate + * + */ +public class ServiceBackedMediaPlayer extends MediaPlayerImpl { + static final String INTENT_NAME = "com.aocate.intent.PLAY_AUDIO_ADJUST_SPEED_0_8"; + + private static final String SBMP_TAG = "AocateServiceBackedMediaPlayer"; + + private ServiceConnection mPlayMediaServiceConnection = null; + protected IPlayMedia_0_8 pmInterface = null; + private Intent playMediaServiceIntent = null; + // In some cases, we're going to have to replace the + // android.media.MediaPlayer on the fly, and we don't want to touch the + // wrong media player. + + private long sessionId = 0; + private boolean isErroring = false; + private int mAudioStreamType = AudioManager.STREAM_MUSIC; + + private WakeLock mWakeLock = null; + + // So here's the major problem + // Sometimes the service won't exist or won't be connected, + // so start with an android.media.MediaPlayer, and when + // the service is connected, use that from then on + public ServiceBackedMediaPlayer(MediaPlayer owningMediaPlayer, final Context context, final ServiceConnection serviceConnection) { + super(owningMediaPlayer, context); + Log.d(SBMP_TAG, "Instantiating ServiceBackedMediaPlayer 87"); + this.playMediaServiceIntent = + new Intent(INTENT_NAME); + this.mPlayMediaServiceConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName name, IBinder service) { + IPlayMedia_0_8 tmpPlayMediaInterface = IPlayMedia_0_8.Stub.asInterface((IBinder) service); + + Log.d(SBMP_TAG, "Setting up pmInterface 94"); + if (ServiceBackedMediaPlayer.this.sessionId == 0) { + try { + // The IDeathCallback isn't a conventional callback. + // It exists so that if the client ceases to exist, + // the Service becomes aware of that and can shut + // down whatever it needs to shut down + ServiceBackedMediaPlayer.this.sessionId = tmpPlayMediaInterface.startSession(new IDeathCallback_0_8.Stub() { + }); + // This is really bad if this fails + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + Log.d(SBMP_TAG, "Assigning pmInterface"); + + ServiceBackedMediaPlayer.this.setOnBufferingUpdateCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnCompletionCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnErrorCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnInfoCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnPitchAdjustmentAvailableChangedListener(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnPreparedCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnSeekCompleteCallback(tmpPlayMediaInterface); + ServiceBackedMediaPlayer.this.setOnSpeedAdjustmentAvailableChangedCallback(tmpPlayMediaInterface); + + // In order to avoid race conditions from the sessionId or listener not being assigned + pmInterface = tmpPlayMediaInterface; + + Log.d(SBMP_TAG, "Invoking onServiceConnected"); + serviceConnection.onServiceConnected(name, service); + } + + public void onServiceDisconnected(ComponentName name) { + Log.d(SBMP_TAG, "onServiceDisconnected 114"); + + pmInterface = null; + + sessionId = 0; + + serviceConnection.onServiceDisconnected(name); + } + }; + + Log.d(SBMP_TAG, "Connecting PlayMediaService 124"); + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private boolean ConnectPlayMediaService() { + Log.d(SBMP_TAG, "ConnectPlayMediaService()"); + + if (MediaPlayer.isIntentAvailable(mContext, INTENT_NAME)) { + Log.d(SBMP_TAG, INTENT_NAME + " is available"); + if (pmInterface == null) { + try { + Log.d(SBMP_TAG, "Binding service"); + return mContext.bindService(playMediaServiceIntent, mPlayMediaServiceConnection, Context.BIND_AUTO_CREATE); + } catch (Exception e) { + return false; + } + } else { + Log.d(SBMP_TAG, "Service already bound"); + return true; + } + } + else { + Log.d(SBMP_TAG, INTENT_NAME + " is not available"); + return false; + } + } + + /** + * Returns true if pitch can be changed at this moment + * @return True if pitch can be changed + */ + @Override + public boolean canSetPitch() { + Log.d(SBMP_TAG, "canSetPitch() 155"); + + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set pitch if the service isn't connected + try { + return pmInterface.canSetPitch(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return false; + } + + /** + * Returns true if speed can be changed at this moment + * @return True if speed can be changed + */ + @Override + public boolean canSetSpeed() { + Log.d(SBMP_TAG, "canSetSpeed() 180"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the service isn't connected + try { + return pmInterface.canSetSpeed(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return false; + } + + void error(int what, int extra) { + owningMediaPlayer.lock.lock(); + Log.e(SBMP_TAG, "error(" + what + ", " + extra + ")"); + try { + if (!this.isErroring) { + this.isErroring = true; + owningMediaPlayer.state = State.ERROR; + if (owningMediaPlayer.onErrorListener != null) { + if (owningMediaPlayer.onErrorListener.onError(owningMediaPlayer, what, extra)) { + return; + } + } + if (owningMediaPlayer.onCompletionListener != null) { + owningMediaPlayer.onCompletionListener.onCompletion(owningMediaPlayer); + } + } + } + finally { + this.isErroring = false; + owningMediaPlayer.lock.unlock(); + } + } + + protected void finalize() throws Throwable { + owningMediaPlayer.lock.lock(); + try { + Log.d(SBMP_TAG, "finalize() 224"); + this.release(); + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + /** + * Returns the number of steps (in a musical scale) by which playback is + * currently shifted. When greater than zero, pitch is shifted up. + * When less than zero, pitch is shifted down. + * @return The number of steps pitch is currently shifted by + */ + @Override + public float getCurrentPitchStepsAdjustment() { + Log.d(SBMP_TAG, "getCurrentPitchStepsAdjustment() 240"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set pitch if the service isn't connected + try { + return pmInterface.getCurrentPitchStepsAdjustment( + ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return 0f; + } + + /** + * Functions identically to android.media.MediaPlayer.getCurrentPosition() + * @return Current position (in milliseconds) + */ + @Override + public int getCurrentPosition() { + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + return pmInterface.getCurrentPosition( + ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + return 0; + } + + /** + * Returns the current speed multiplier. Defaults to 1.0 (normal speed) + * @return The current speed multiplier + */ + @Override + public float getCurrentSpeedMultiplier() { + Log.d(SBMP_TAG, "getCurrentSpeedMultiplier() 286"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the service isn't connected + try { + return pmInterface.getCurrentSpeedMultiplier( + ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return 1; + } + + /** + * Functions identically to android.media.MediaPlayer.getDuration() + * @return Length of the track (in milliseconds) + */ + @Override + public int getDuration() { + Log.d(SBMP_TAG, "getDuration() 311"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + return pmInterface.getDuration(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + return 0; + } + + /** + * Get the maximum value that can be passed to setPlaybackSpeed + * @return The maximum speed multiplier + */ + @Override + public float getMaxSpeedMultiplier() { + Log.d(SBMP_TAG, "getMaxSpeedMultiplier() 332"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + return pmInterface.getMaxSpeedMultiplier( + ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return 1f; + } + + /** + * Get the minimum value that can be passed to setPlaybackSpeed + * @return The minimum speed multiplier + */ + @Override + public float getMinSpeedMultiplier() { + Log.d(SBMP_TAG, "getMinSpeedMultiplier() 357"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + return pmInterface.getMinSpeedMultiplier( + ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return 1f; + } + + public int getServiceVersionCode() { + Log.d(SBMP_TAG, "getVersionCode"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + return pmInterface.getVersionCode(); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + return 0; + } + + public String getServiceVersionName() { + Log.d(SBMP_TAG, "getVersionName"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + return pmInterface.getVersionName(); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + return ""; + } + + public boolean isConnected() { + return (pmInterface != null); + } + + /** + * Functions identically to android.media.MediaPlayer.isLooping() + * @return True if the track is looping + */ + @Override + public boolean isLooping() { + Log.d(SBMP_TAG, "isLooping() 382"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + return pmInterface.isLooping(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + return false; + } + + /** + * Functions identically to android.media.MediaPlayer.isPlaying() + * @return True if the track is playing + */ + @Override + public boolean isPlaying() { + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + try { + return pmInterface.isPlaying(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + return false; + } + + /** + * Functions identically to android.media.MediaPlayer.pause() + * Pauses the track + */ + @Override + public void pause() { + Log.d(SBMP_TAG, "pause() 424"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.pause(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.prepare() + * Prepares the track. This or prepareAsync must be called before start() + */ + @Override + public void prepare() throws IllegalStateException, IOException { + Log.d(SBMP_TAG, "prepare() 444"); + Log.d(SBMP_TAG, "onPreparedCallback is: " + ((this.mOnPreparedCallback == null) ? "null" : "non-null")); + if (pmInterface == null) { + Log.d(SBMP_TAG, "prepare: pmInterface is null"); + if (!ConnectPlayMediaService()) { + Log.d(SBMP_TAG, "prepare: Failed to connect play media service"); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + Log.d(SBMP_TAG, "prepare: pmInterface isn't null"); + try { + Log.d(SBMP_TAG, "prepare: Remote invoke pmInterface.prepare(" + ServiceBackedMediaPlayer.this.sessionId + ")"); + pmInterface.prepare(ServiceBackedMediaPlayer.this.sessionId); + Log.d(SBMP_TAG, "prepare: prepared"); + } catch (RemoteException e) { + Log.d(SBMP_TAG, "prepare: RemoteException"); + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + Log.d(SBMP_TAG, "Done with prepare()"); + } + + /** + * Functions identically to android.media.MediaPlayer.prepareAsync() + * Prepares the track. This or prepare must be called before start() + */ + @Override + public void prepareAsync() { + Log.d(SBMP_TAG, "prepareAsync() 469"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.prepareAsync(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.release() + * Releases the underlying resources used by the media player. + */ + @Override + public void release() { + Log.d(SBMP_TAG, "release() 492"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + Log.d(SBMP_TAG, "release() 500"); + try { + pmInterface.release(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + mContext.unbindService(this.mPlayMediaServiceConnection); + // Don't try to keep awake (if we were) + this.setWakeMode(mContext, 0); + pmInterface = null; + this.sessionId = 0; + } + + if ((this.mWakeLock != null) && this.mWakeLock.isHeld()) { + Log.d(SBMP_TAG, "Releasing wakelock"); + this.mWakeLock.release(); + } + } + + /** + * Functions identically to android.media.MediaPlayer.reset() + * Resets the track to idle state + */ + @Override + public void reset() { + Log.d(SBMP_TAG, "reset() 523"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.reset(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.seekTo(int msec) + * Seeks to msec in the track + */ + @Override + public void seekTo(int msec) throws IllegalStateException { + Log.d(SBMP_TAG, "seekTo(" + msec + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.seekTo(ServiceBackedMediaPlayer.this.sessionId, msec); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setAudioStreamType(int streamtype) + * Sets the audio stream type. + */ + @Override + public void setAudioStreamType(int streamtype) { + Log.d(SBMP_TAG, "setAudioStreamType(" + streamtype + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.setAudioStreamType( + ServiceBackedMediaPlayer.this.sessionId, + this.mAudioStreamType); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + + /** + * Functions identically to android.media.MediaPlayer.setDataSource(Context context, Uri uri) + * Sets uri as data source in the context given + */ + @Override + public void setDataSource(Context context, Uri uri) throws IllegalArgumentException, IllegalStateException, IOException { + Log.d(SBMP_TAG, "setDataSource(context, uri)"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.setDataSourceUri( + ServiceBackedMediaPlayer.this.sessionId, + uri); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setDataSource(String path) + * Sets the data source of the track to a file given. + */ + @Override + public void setDataSource(String path) throws IllegalArgumentException, IllegalStateException, IOException { + Log.d(SBMP_TAG, "setDataSource(path)"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface == null) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + else { + try { + pmInterface.setDataSourceString( + ServiceBackedMediaPlayer.this.sessionId, + path); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + } + + /** + * Sets whether to use speed adjustment or not. Speed adjustment on is + * more computation-intensive than with it off. + * @param enableSpeedAdjustment Whether speed adjustment should be supported. + */ + @Override + public void setEnableSpeedAdjustment(boolean enableSpeedAdjustment) { + // TODO: This has no business being here, I think + owningMediaPlayer.lock.lock(); + Log.d(SBMP_TAG, "setEnableSpeedAdjustment(enableSpeedAdjustment)"); + try { + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + pmInterface.setEnableSpeedAdjustment( + ServiceBackedMediaPlayer.this.sessionId, + enableSpeedAdjustment); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + + + /** + * Functions identically to android.media.MediaPlayer.setLooping(boolean loop) + * Sets the track to loop infinitely if loop is true, play once if loop is false + */ + @Override + public void setLooping(boolean loop) { + Log.d(SBMP_TAG, "setLooping(" + loop + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.setLooping(ServiceBackedMediaPlayer.this.sessionId, loop); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Sets the number of steps (in a musical scale) by which playback is + * currently shifted. When greater than zero, pitch is shifted up. + * When less than zero, pitch is shifted down. + * + * @param pitchSteps The number of steps by which to shift playback + */ + @Override + public void setPitchStepsAdjustment(float pitchSteps) { + Log.d(SBMP_TAG, "setPitchStepsAdjustment(" + pitchSteps + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + pmInterface.setPitchStepsAdjustment( + ServiceBackedMediaPlayer.this.sessionId, + pitchSteps); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + } + + /** + * Sets the percentage by which pitch is currently shifted. When + * greater than zero, pitch is shifted up. When less than zero, pitch + * is shifted down + * @param f The percentage to shift pitch + */ + @Override + public void setPlaybackPitch(float f) { + Log.d(SBMP_TAG, "setPlaybackPitch(" + f + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + pmInterface.setPlaybackPitch( + ServiceBackedMediaPlayer.this.sessionId, + f); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + } + + /** + * Set playback speed. 1.0 is normal speed, 2.0 is double speed, and so + * on. Speed should never be set to 0 or below. + * @param f The speed multiplier to use for further playback + */ + @Override + public void setPlaybackSpeed(float f) { + Log.d(SBMP_TAG, "setPlaybackSpeed(" + f + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + if (pmInterface != null) { + // Can't set speed if the Service isn't connected + try { + pmInterface.setPlaybackSpeed( + ServiceBackedMediaPlayer.this.sessionId, + f); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + } + + @Override + public void setSpeedAdjustmentAlgorithm(int algorithm) { + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.setSpeedAdjustmentAlgorithm( + ServiceBackedMediaPlayer.this.sessionId, + algorithm); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setVolume(float leftVolume, float rightVolume) + * Sets the stereo volume + */ + @Override + public void setVolume(float leftVolume, float rightVolume) { + Log.d(SBMP_TAG, "setVolume(" + leftVolume + ", " + rightVolume + ")"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.setVolume( + ServiceBackedMediaPlayer.this.sessionId, + leftVolume, + rightVolume); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.setWakeMode(Context context, int mode) + * Acquires a wake lock in the context given. You must request the appropriate permissions + * in your AndroidManifest.xml file. + */ + @Override + // This does not just call .setWakeMode() in the Service because doing so + // would add a permission requirement to the Service. Do it here, and it's + // the client app's responsibility to request that permission + public void setWakeMode(Context context, int mode) { + Log.d(SBMP_TAG, "setWakeMode(context, " + mode + ")"); + if ((this.mWakeLock != null) + && (this.mWakeLock.isHeld())) { + this.mWakeLock.release(); + } + if (mode != 0) { + if (this.mWakeLock == null) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + // Since mode can't be changed on the fly, we have to allocate a new one + this.mWakeLock = pm.newWakeLock(mode, this.getClass().getName()); + } + + this.mWakeLock.acquire(); + } + } + + private IOnBufferingUpdateListenerCallback_0_8.Stub mOnBufferingUpdateCallback = null; + private void setOnBufferingUpdateCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnBufferingUpdateCallback == null) { + mOnBufferingUpdateCallback = new IOnBufferingUpdateListenerCallback_0_8.Stub() { + public void onBufferingUpdate(int percent) + throws RemoteException { + owningMediaPlayer.lock.lock(); + try { + if ((owningMediaPlayer.onBufferingUpdateListener != null) + && (owningMediaPlayer.mpi == ServiceBackedMediaPlayer.this)) { + owningMediaPlayer.onBufferingUpdateListener.onBufferingUpdate(owningMediaPlayer, percent); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnBufferingUpdateCallback( + ServiceBackedMediaPlayer.this.sessionId, + mOnBufferingUpdateCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnCompletionListenerCallback_0_8.Stub mOnCompletionCallback = null; + private void setOnCompletionCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnCompletionCallback == null) { + this.mOnCompletionCallback = new IOnCompletionListenerCallback_0_8.Stub() { + public void onCompletion() throws RemoteException { + owningMediaPlayer.lock.lock(); + Log.d(SBMP_TAG, "onCompletionListener being called"); + try { + if (owningMediaPlayer.onCompletionListener != null) { + owningMediaPlayer.onCompletionListener.onCompletion(owningMediaPlayer); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnCompletionCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnCompletionCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnErrorListenerCallback_0_8.Stub mOnErrorCallback = null; + private void setOnErrorCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnErrorCallback == null) { + this.mOnErrorCallback = new IOnErrorListenerCallback_0_8.Stub() { + public boolean onError(int what, int extra) throws RemoteException { + owningMediaPlayer.lock.lock(); + try { + if (owningMediaPlayer.onErrorListener != null) { + return owningMediaPlayer.onErrorListener.onError(owningMediaPlayer, what, extra); + } + return false; + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnErrorCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnErrorCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnInfoListenerCallback_0_8.Stub mOnInfoCallback = null; + private void setOnInfoCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnInfoCallback == null) { + this.mOnInfoCallback = new IOnInfoListenerCallback_0_8.Stub() { + public boolean onInfo(int what, int extra) throws RemoteException { + owningMediaPlayer.lock.lock(); + try { + if ((owningMediaPlayer.onInfoListener != null) + && (owningMediaPlayer.mpi == ServiceBackedMediaPlayer.this)) { + return owningMediaPlayer.onInfoListener.onInfo(owningMediaPlayer, what, extra); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + return false; + } + }; + } + iface.registerOnInfoCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnInfoCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnPitchAdjustmentAvailableChangedListenerCallback_0_8.Stub mOnPitchAdjustmentAvailableChangedCallback = null; + private void setOnPitchAdjustmentAvailableChangedListener(IPlayMedia_0_8 iface) { + try { + if (this.mOnPitchAdjustmentAvailableChangedCallback == null) { + this.mOnPitchAdjustmentAvailableChangedCallback = new IOnPitchAdjustmentAvailableChangedListenerCallback_0_8.Stub() { + public void onPitchAdjustmentAvailableChanged( + boolean pitchAdjustmentAvailable) + throws RemoteException { + owningMediaPlayer.lock.lock(); + try { + if (owningMediaPlayer.onPitchAdjustmentAvailableChangedListener != null) { + owningMediaPlayer.onPitchAdjustmentAvailableChangedListener.onPitchAdjustmentAvailableChanged(owningMediaPlayer, pitchAdjustmentAvailable); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnPitchAdjustmentAvailableChangedCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnPitchAdjustmentAvailableChangedCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnPreparedListenerCallback_0_8.Stub mOnPreparedCallback = null; + private void setOnPreparedCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnPreparedCallback == null) { + this.mOnPreparedCallback = new IOnPreparedListenerCallback_0_8.Stub() { + public void onPrepared() throws RemoteException { + owningMediaPlayer.lock.lock(); + Log.d(SBMP_TAG, "setOnPreparedCallback.mOnPreparedCallback.onPrepared 1050"); + try { + Log.d(SBMP_TAG, "owningMediaPlayer.onPreparedListener is " + ((owningMediaPlayer.onPreparedListener == null) ? "null" : "non-null")); + Log.d(SBMP_TAG, "owningMediaPlayer.mpi is " + ((owningMediaPlayer.mpi == ServiceBackedMediaPlayer.this) ? "this" : "not this")); + ServiceBackedMediaPlayer.this.lockMuteOnPreparedCount.lock(); + try { + if (ServiceBackedMediaPlayer.this.muteOnPreparedCount > 0) { + ServiceBackedMediaPlayer.this.muteOnPreparedCount--; + } + else { + ServiceBackedMediaPlayer.this.muteOnPreparedCount = 0; + if (ServiceBackedMediaPlayer.this.owningMediaPlayer.onPreparedListener != null) { + owningMediaPlayer.onPreparedListener.onPrepared(owningMediaPlayer); + } + } + } + finally { + ServiceBackedMediaPlayer.this.lockMuteOnPreparedCount.unlock(); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnPreparedCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnPreparedCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnSeekCompleteListenerCallback_0_8.Stub mOnSeekCompleteCallback = null; + private void setOnSeekCompleteCallback(IPlayMedia_0_8 iface) { + try { + if (this.mOnSeekCompleteCallback == null) { + this.mOnSeekCompleteCallback = new IOnSeekCompleteListenerCallback_0_8.Stub() { + public void onSeekComplete() throws RemoteException { + Log.d(SBMP_TAG, "onSeekComplete() 941"); + owningMediaPlayer.lock.lock(); + try { + if (ServiceBackedMediaPlayer.this.muteOnSeekCount > 0) { + Log.d(SBMP_TAG, "The next " + ServiceBackedMediaPlayer.this.muteOnSeekCount + " seek events are muted (counting this one)"); + ServiceBackedMediaPlayer.this.muteOnSeekCount--; + } + else { + ServiceBackedMediaPlayer.this.muteOnSeekCount = 0; + Log.d(SBMP_TAG, "Attempting to invoke next seek event"); + if (ServiceBackedMediaPlayer.this.owningMediaPlayer.onSeekCompleteListener != null) { + Log.d(SBMP_TAG, "Invoking onSeekComplete"); + owningMediaPlayer.onSeekCompleteListener.onSeekComplete(owningMediaPlayer); + } + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnSeekCompleteCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnSeekCompleteCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + private IOnSpeedAdjustmentAvailableChangedListenerCallback_0_8.Stub mOnSpeedAdjustmentAvailableChangedCallback = null; + private void setOnSpeedAdjustmentAvailableChangedCallback(IPlayMedia_0_8 iface) { + try { + Log.d(SBMP_TAG, "Setting the service of on speed adjustment available changed"); + if (this.mOnSpeedAdjustmentAvailableChangedCallback == null) { + this.mOnSpeedAdjustmentAvailableChangedCallback = new IOnSpeedAdjustmentAvailableChangedListenerCallback_0_8.Stub() { + public void onSpeedAdjustmentAvailableChanged( + boolean speedAdjustmentAvailable) + throws RemoteException { + owningMediaPlayer.lock.lock(); + try { + if (owningMediaPlayer.onSpeedAdjustmentAvailableChangedListener != null) { + owningMediaPlayer.onSpeedAdjustmentAvailableChangedListener.onSpeedAdjustmentAvailableChanged(owningMediaPlayer, speedAdjustmentAvailable); + } + } + finally { + owningMediaPlayer.lock.unlock(); + } + } + }; + } + iface.registerOnSpeedAdjustmentAvailableChangedCallback( + ServiceBackedMediaPlayer.this.sessionId, + this.mOnSpeedAdjustmentAvailableChangedCallback); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.start() + * Starts a track playing + */ + @Override + public void start() { + Log.d(SBMP_TAG, "start()"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.start(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + + /** + * Functions identically to android.media.MediaPlayer.stop() + * Stops a track playing and resets its position to the start. + */ + @Override + public void stop() { + Log.d(SBMP_TAG, "stop()"); + if (pmInterface == null) { + if (!ConnectPlayMediaService()) { + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } + try { + pmInterface.stop(ServiceBackedMediaPlayer.this.sessionId); + } catch (RemoteException e) { + e.printStackTrace(); + ServiceBackedMediaPlayer.this.error(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/aocate/media/SpeedAdjustmentAlgorithm.java b/core/src/main/java/com/aocate/media/SpeedAdjustmentAlgorithm.java new file mode 100644 index 000000000..d337a0452 --- /dev/null +++ b/core/src/main/java/com/aocate/media/SpeedAdjustmentAlgorithm.java @@ -0,0 +1,31 @@ +// Copyright 2011, Aocate, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.aocate.media; + +public class SpeedAdjustmentAlgorithm { + /** + * Use this to use the user-specified algorithm + */ + public static int DEFAULT = 0; + + /** + * Better for voice audio + */ + public static int SONIC = 1; + /** + * Better for music audio + */ + public static int WSOLA = 2; +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ApplicationCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/ApplicationCallbacks.java new file mode 100644 index 000000000..69a959ba8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/ApplicationCallbacks.java @@ -0,0 +1,22 @@ +package de.danoeh.antennapod.core; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; + +/** + * Callbacks related to the application in general + */ +public interface ApplicationCallbacks { + + /** + * Returns a non-null instance of the application class + */ + public Application getApplicationInstance(); + + /** + * Returns a non-null intent that starts the storage error + * activity. + */ + public Intent getStorageErrorActivity(Context context); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java new file mode 100644 index 000000000..e5e609f5f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.core; + +/** + * Stores callbacks for core classes like Services, DB classes etc. and other configuration variables. + * Apps using the core module of AntennaPod should register implementations of all interfaces here. + */ +public class ClientConfig { + + /** + * Should be used when setting User-Agent header for HTTP-requests. + */ + public static String USER_AGENT; + + public static ApplicationCallbacks applicationCallbacks; + + public static DownloadServiceCallbacks downloadServiceCallbacks; + + public static PlaybackServiceCallbacks playbackServiceCallbacks; + + public static GpodnetCallbacks gpodnetCallbacks; + + public static FlattrCallbacks flattrCallbacks; + + public static StorageCallbacks storageCallbacks; +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java new file mode 100644 index 000000000..55b69fdec --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java @@ -0,0 +1,44 @@ +package de.danoeh.antennapod.core; + +import android.app.PendingIntent; +import android.content.Context; + +import de.danoeh.antennapod.core.service.download.DownloadRequest; + +/** + * Callbacks for the DownloadService of the core module + */ +public interface DownloadServiceCallbacks { + + /** + * Returns a PendingIntent for a notification the main notification of the DownloadService. + *

+ * The PendingIntent takes the users to a screen where they can observe all currently running + * downloads. + * + * @return A non-null PendingIntent for the notification. + */ + public PendingIntent getNotificationContentIntent(Context context); + + /** + * Returns a PendingIntent for a notification that tells the user to enter a username + * or a password for a requested download. + *

+ * The PendingIntent takes users to an Activity that lets the user enter their username + * and password to retry the download. + * + * @return A non-null PendingIntent for the notification. + */ + public PendingIntent getAuthentificationNotificationContentIntent(Context context, DownloadRequest request); + + /** + * Returns a PendingIntent for notification that notifies the user about the completion of downloads + * along with information about failed and successful downloads. + *

+ * The PendingIntent takes users to an activity where they can look at all successful and failed downloads. + * + * @return A non-null PendingIntent for the notification. + */ + public PendingIntent getReportNotificationContentIntent(Context context); +} + diff --git a/core/src/main/java/de/danoeh/antennapod/core/FlattrCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/FlattrCallbacks.java new file mode 100644 index 000000000..cee1029d8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/FlattrCallbacks.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.core; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import org.shredzone.flattr4j.oauth.AccessToken; + +/** + * Callbacks for the flattr integration of the app. + */ +public interface FlattrCallbacks { + + /** + * Returns if true if the flattr integration should be activated, + * false otherwise. + */ + public boolean flattrEnabled(); + + /** + * Returns an intent that starts the activity that is responsible for + * letting users log into their flattr account. + * + * @return The intent that starts the authentication activity or null + * if flattr integration is disabled (i.e. flattrEnabled() == false). + */ + public Intent getFlattrAuthenticationActivityIntent(Context context); + + public PendingIntent getFlattrFailedNotificationContentIntent(Context context); + + public String getFlattrAppKey(); + + public String getFlattrAppSecret(); + + public void handleFlattrAuthenticationSuccess(AccessToken token); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/GpodnetCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/GpodnetCallbacks.java new file mode 100644 index 000000000..6174bce29 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/GpodnetCallbacks.java @@ -0,0 +1,27 @@ +package de.danoeh.antennapod.core; + +import android.app.PendingIntent; +import android.content.Context; + +/** + * Callbacks related to the gpodder.net integration of the core module + */ +public interface GpodnetCallbacks { + + + /** + * Returns if true if the gpodder.net integration should be activated, + * false otherwise. + */ + public boolean gpodnetEnabled(); + + /** + * Returns a PendingIntent for the error notification of the GpodnetSyncService. + *

+ * What the PendingIntent does may be implementation-specific. + * + * @return A PendingIntent for the notification or null if gpodder.net integration + * has been disabled (i.e. gpodnetEnabled() == false). + */ + public PendingIntent getGpodnetSyncServiceErrorNotificationPendingIntent(Context context); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java new file mode 100644 index 000000000..e37c8fcfd --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core; + +import android.content.Context; +import android.content.Intent; + +import de.danoeh.antennapod.core.feed.MediaType; + +/** + * Callbacks for the PlaybackService of the core module + */ +public interface PlaybackServiceCallbacks { + + /** + * Returns an intent which starts an audio- or videoplayer, depending on the + * type of media that is being played. + * + * @param mediaType The type of media that is being played. + * @return A non-null activity intent. + */ + public Intent getPlayerActivityIntent(Context context, MediaType mediaType); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/StorageCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/StorageCallbacks.java new file mode 100644 index 000000000..5d1a0fffc --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/StorageCallbacks.java @@ -0,0 +1,27 @@ +package de.danoeh.antennapod.core; + +import android.database.sqlite.SQLiteDatabase; + +/** + * Callbacks for the classes in the storage package of the core module. + */ +public interface StorageCallbacks { + + /** + * Returns the current version of the database. + * + * @return The non-negative version number of the database. + */ + public int getDatabaseVersion(); + + /** + * Upgrades the given database from an old version to a newer version. + * + * @param db The database that is supposed to be upgraded. + * @param oldVersion The old version of the database. + * @param newVersion The version that the database is supposed to be upgraded to. + */ + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion); + + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/DownloadObserver.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/DownloadObserver.java new file mode 100644 index 000000000..a13130082 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/DownloadObserver.java @@ -0,0 +1,177 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.service.download.DownloadService; +import de.danoeh.antennapod.core.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 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 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 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/core/src/main/java/de/danoeh/antennapod/core/asynctask/FeedRemover.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FeedRemover.java new file mode 100644 index 000000000..255b95119 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FeedRemover.java @@ -0,0 +1,74 @@ +package de.danoeh.antennapod.core.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.core.R; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.storage.DBWriter; + +import java.util.concurrent.ExecutionException; + +/** Removes a feed in the background. */ +public class FeedRemover extends AsyncTask { + 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/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java new file mode 100644 index 000000000..5d2d5d441 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java @@ -0,0 +1,237 @@ +package de.danoeh.antennapod.core.asynctask; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +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.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.core.util.flattr.FlattrThing; +import de.danoeh.antennapod.core.util.flattr.FlattrUtils; + +/** + * Performs a click action in a background thread. + *

+ * 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. + *

+ * 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 { + 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. + *

+ * 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. + *

+ * 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 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 dbFutures = new LinkedList(); + 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, + ClientConfig.flattrCallbacks.getFlattrAuthenticationActivityIntent(context), 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 = ClientConfig.flattrCallbacks.getFlattrFailedNotificationContentIntent(context); + 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/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrStatusFetcher.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrStatusFetcher.java new file mode 100644 index 000000000..c4aa76ac7 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrStatusFetcher.java @@ -0,0 +1,47 @@ +package de.danoeh.antennapod.core.asynctask; + +import android.content.Context; +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.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 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/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrTokenFetcher.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrTokenFetcher.java new file mode 100644 index 000000000..2513d1abd --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrTokenFetcher.java @@ -0,0 +1,92 @@ +package de.danoeh.antennapod.core.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 org.shredzone.flattr4j.exception.FlattrException; +import org.shredzone.flattr4j.oauth.AccessToken; +import org.shredzone.flattr4j.oauth.AndroidAuthenticator; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.util.flattr.FlattrUtils; + +/** + * Fetches the access token in the background in order to avoid networkOnMainThread exception. + */ + +public class FlattrTokenFetcher extends AsyncTask { + 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) { + ClientConfig.flattrCallbacks.handleFlattrAuthenticationSuccess(result); + } 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/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoImageResource.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoImageResource.java new file mode 100644 index 000000000..c0d8049db --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoImageResource.java @@ -0,0 +1,37 @@ +package de.danoeh.antennapod.core.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. + *

+ * 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. + *

+ * 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). + *

+ * The Uri can also have an optional fallback-URL if loading the default URL + * failed (see PARAM_FALLBACK). + */ + public Uri getImageUri(); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java new file mode 100644 index 000000000..6ace92800 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java @@ -0,0 +1,152 @@ +package de.danoeh.antennapod.core.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. + *

+ * 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/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java b/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java new file mode 100644 index 000000000..1535e2e9a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java @@ -0,0 +1,211 @@ +package de.danoeh.antennapod.core.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.core.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.core.feed.Feed; +import de.danoeh.antennapod.core.opml.OpmlElement; +import de.danoeh.antennapod.core.opml.OpmlReader; +import de.danoeh.antennapod.core.opml.OpmlWriter; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DownloadRequestException; +import de.danoeh.antennapod.core.storage.DownloadRequester; +import de.danoeh.antennapod.core.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 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/core/src/main/java/de/danoeh/antennapod/core/dialog/ConfirmationDialog.java b/core/src/main/java/de/danoeh/antennapod/core/dialog/ConfirmationDialog.java new file mode 100644 index 000000000..ba1add895 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/dialog/ConfirmationDialog.java @@ -0,0 +1,64 @@ +package de.danoeh.antennapod.core.dialog; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/dialog/DownloadRequestErrorDialogCreator.java b/core/src/main/java/de/danoeh/antennapod/core/dialog/DownloadRequestErrorDialogCreator.java new file mode 100644 index 000000000..3d174bd8e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/dialog/DownloadRequestErrorDialogCreator.java @@ -0,0 +1,30 @@ +package de.danoeh.antennapod.core.dialog; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/Chapter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/Chapter.java new file mode 100644 index 000000000..ce3352ed6 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/Chapter.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/EventDistributor.java b/core/src/main/java/de/danoeh/antennapod/core/feed/EventDistributor.java new file mode 100644 index 000000000..f8815dcf0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/EventDistributor.java @@ -0,0 +1,140 @@ +package de.danoeh.antennapod.core.feed; + +import android.os.Handler; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.core.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 events; + + private static EventDistributor instance; + + private EventDistributor() { + this.handler = new Handler(); + events = new ConcurrentLinkedQueue(); + } + + 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/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java new file mode 100644 index 000000000..3f83ab8b6 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java @@ -0,0 +1,445 @@ +package de.danoeh.antennapod.core.feed; + +import android.content.Context; +import android.net.Uri; + +import de.danoeh.antennapod.core.asynctask.PicassoImageResource; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.EpisodeFilter; +import de.danoeh.antennapod.core.util.flattr.FlattrStatus; +import de.danoeh.antennapod.core.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 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(); + } + + /** + * 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(); + 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 getItems() { + return items; + } + + public void setItems(List 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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedComponent.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedComponent.java new file mode 100644 index 000000000..05115c1ea --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedComponent.java @@ -0,0 +1,66 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedFile.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedFile.java new file mode 100644 index 000000000..3dc58654b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedFile.java @@ -0,0 +1,105 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedImage.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedImage.java new file mode 100644 index 000000000..51605691d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedImage.java @@ -0,0 +1,71 @@ +package de.danoeh.antennapod.core.feed; + +import android.net.Uri; + +import de.danoeh.antennapod.core.asynctask.PicassoImageResource; + +import java.io.File; + + +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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java new file mode 100644 index 000000000..8a513de43 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java @@ -0,0 +1,332 @@ +package de.danoeh.antennapod.core.feed; + +import android.net.Uri; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.asynctask.PicassoImageResource; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.util.ShownotesProvider; +import de.danoeh.antennapod.core.util.flattr.FlattrStatus; +import de.danoeh.antennapod.core.util.flattr.FlattrThing; + +/** + * 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 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 getChapters() { + return chapters; + } + + public void setChapters(List 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 loadShownotes() { + return new Callable() { + @Override + public String call() throws Exception { + + if (contentEncoded == null || description == null) { + DBReader.loadExtraInformationOfFeedItem(ClientConfig.applicationCallbacks.getApplicationInstance(), 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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java new file mode 100644 index 000000000..37186ee79 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java @@ -0,0 +1,410 @@ +package de.danoeh.antennapod.core.feed; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.ChapterUtils; +import de.danoeh.antennapod.core.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(ClientConfig.applicationCallbacks.getApplicationInstance(), itemID); + } + } + + @Override + public void loadChapterMarks() { + if (getChapters() == null && !localFileAvailable()) { + ChapterUtils.loadChaptersFromStreamUrl(this); + if (getChapters() != null && item != null) { + DBWriter.setFeedItem(ClientConfig.applicationCallbacks.getApplicationInstance(), + item); + } + } + + } + + @Override + public String getEpisodeTitle() { + if (item == null) { + return null; + } + if (getItem().getTitle() != null) { + return getItem().getTitle(); + } else { + return getItem().getIdentifyingValue(); + } + } + + @Override + public List 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(ClientConfig.applicationCallbacks.getApplicationInstance(), this); + } + + @Override + public void onPlaybackStart() { + } + + @Override + public void onPlaybackCompleted() { + + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_FEEDMEDIA; + } + + @Override + public void setChapters(List chapters) { + getItem().setChapters(chapters); + } + + @Override + public Callable loadShownotes() { + return new Callable() { + @Override + public String call() throws Exception { + if (item == null) { + item = DBReader.getFeedItem( + ClientConfig.applicationCallbacks.getApplicationInstance(), itemID); + } + if (item.getContentEncoded() == null || item.getDescription() == null) { + DBReader.loadExtraInformationOfFeedItem( + ClientConfig.applicationCallbacks.getApplicationInstance(), item); + + } + return (item.getContentEncoded() != null) ? item.getContentEncoded() : item.getDescription(); + } + }; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + 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/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java new file mode 100644 index 000000000..2f0304182 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java @@ -0,0 +1,89 @@ +package de.danoeh.antennapod.core.feed; + +import android.content.Context; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/ID3Chapter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/ID3Chapter.java new file mode 100644 index 000000000..f0ff03a93 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/ID3Chapter.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java b/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java new file mode 100644 index 000000000..7b3cb829d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/MediaType.java @@ -0,0 +1,5 @@ +package de.danoeh.antennapod.core.feed; + +public enum MediaType { + AUDIO, VIDEO, UNKNOWN +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/SearchResult.java b/core/src/main/java/de/danoeh/antennapod/core/feed/SearchResult.java new file mode 100644 index 000000000..9aa8d3170 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/SearchResult.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/SimpleChapter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/SimpleChapter.java new file mode 100644 index 000000000..2dadd3ec8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/SimpleChapter.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/feed/VorbisCommentChapter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/VorbisCommentChapter.java new file mode 100644 index 000000000..5b54a2d59 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/VorbisCommentChapter.java @@ -0,0 +1,109 @@ +package de.danoeh.antennapod.core.feed; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetService.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetService.java new file mode 100644 index 000000000..117cbf96b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetService.java @@ -0,0 +1,718 @@ +package de.danoeh.antennapod.core.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.core.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetSubscriptionChange; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.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 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 tagList = new ArrayList( + 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 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 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. + *

+ * 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 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 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. + *

+ * 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 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 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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.core.gpoddernet.model.GpodnetUploadChangesResponse} + * for details. + * @throws java.lang.IllegalArgumentException if username, deviceId, added or removed is null. + * @throws de.danoeh.antennapod.core.gpoddernet.GpodnetServiceException if added or removed contain duplicates or if there + * is an authentication error. + */ + public GpodnetUploadChangesResponse uploadChanges(String username, String deviceId, Collection added, + Collection 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. + *

+ * 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 readPodcastListFromJSONArray(JSONArray array) + throws JSONException { + Validate.notNull(array); + + List result = new ArrayList( + 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 readDeviceListFromJSONArray(JSONArray array) + throws JSONException { + Validate.notNull(array); + + List result = new ArrayList( + 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 added = new LinkedList(); + JSONArray jsonAdded = object.getJSONArray("add"); + for (int i = 0; i < jsonAdded.length(); i++) { + added.add(jsonAdded.getString(i)); + } + + List removed = new LinkedList(); + 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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceAuthenticationException.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceAuthenticationException.java new file mode 100644 index 000000000..8bd56218c --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceAuthenticationException.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceBadStatusCodeException.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceBadStatusCodeException.java new file mode 100644 index 000000000..16f01f0f4 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceBadStatusCodeException.java @@ -0,0 +1,12 @@ +package de.danoeh.antennapod.core.gpoddernet; + +public class GpodnetServiceBadStatusCodeException extends GpodnetServiceException { + int statusCode; + + public GpodnetServiceBadStatusCodeException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceException.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceException.java new file mode 100644 index 000000000..ce704f7e3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/GpodnetServiceException.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetDevice.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetDevice.java new file mode 100644 index 000000000..4885a243a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetDevice.java @@ -0,0 +1,72 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetPodcast.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetPodcast.java new file mode 100644 index 000000000..afebf66ac --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetPodcast.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetSubscriptionChange.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetSubscriptionChange.java new file mode 100644 index 000000000..a5cb8c0f0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetSubscriptionChange.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.core.gpoddernet.model; + +import org.apache.commons.lang3.Validate; + +import java.util.List; + +public class GpodnetSubscriptionChange { + private List added; + private List removed; + private long timestamp; + + public GpodnetSubscriptionChange(List added, List 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 getAdded() { + return added; + } + + public List getRemoved() { + return removed; + } + + public long getTimestamp() { + return timestamp; + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetTag.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetTag.java new file mode 100644 index 000000000..7178f4be5 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetTag.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.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 { + + @Override + public int compare(GpodnetTag o1, GpodnetTag o2) { + return o1.usage - o2.usage; + } + + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetUploadChangesResponse.java b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetUploadChangesResponse.java new file mode 100644 index 000000000..5a37efa5e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/gpoddernet/model/GpodnetUploadChangesResponse.java @@ -0,0 +1,56 @@ +package de.danoeh.antennapod.core.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.core.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 updatedUrls; + + public GpodnetUploadChangesResponse(long timestamp, Map 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 updatedUrls = new HashMap(); + 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/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlElement.java b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlElement.java new file mode 100644 index 000000000..8d0a4a842 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlElement.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlReader.java b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlReader.java new file mode 100644 index 000000000..775129d09 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlReader.java @@ -0,0 +1,87 @@ +package de.danoeh.antennapod.core.opml; + +import android.util.Log; +import de.danoeh.antennapod.core.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 elementList; + + /** + * Reads an Opml document and returns a list of all OPML elements it can + * find + * + * @throws IOException + * @throws XmlPullParserException + */ + public ArrayList readDocument(Reader reader) + throws XmlPullParserException, IOException { + elementList = new ArrayList(); + 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/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlSymbols.java b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlSymbols.java new file mode 100644 index 000000000..2b831ca2a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlSymbols.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlWriter.java b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlWriter.java new file mode 100644 index 000000000..641190f62 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/opml/OpmlWriter.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.core.opml; + +import android.util.Log; +import android.util.Xml; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.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 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/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java new file mode 100644 index 000000000..af04df017 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java @@ -0,0 +1,247 @@ +package de.danoeh.antennapod.core.preferences; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.gpoddernet.GpodnetService; +import de.danoeh.antennapod.core.service.GpodnetSyncService; + +/** + * 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 addedFeeds; + private static Set 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 ClientConfig.applicationCallbacks.getApplicationInstance().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 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(ClientConfig.applicationCallbacks.getApplicationInstance()); + } + + 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(ClientConfig.applicationCallbacks.getApplicationInstance()); + } + + public static Set getAddedFeedsCopy() { + ensurePreferencesLoaded(); + Set copy = new HashSet(); + feedListLock.lock(); + copy.addAll(addedFeeds); + feedListLock.unlock(); + return copy; + } + + public static void removeAddedFeeds(Collection removed) { + ensurePreferencesLoaded(); + feedListLock.lock(); + addedFeeds.removeAll(removed); + writePreference(PREF_SYNC_ADDED, addedFeeds); + feedListLock.unlock(); + } + + public static Set getRemovedFeedsCopy() { + ensurePreferencesLoaded(); + Set copy = new HashSet(); + feedListLock.lock(); + copy.addAll(removedFeeds); + feedListLock.unlock(); + return copy; + } + + public static void removeRemovedFeeds(Collection 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 readListFromString(String s) { + Set result = new HashSet(); + for (String item : s.split(" ")) { + result.add(item); + } + return result; + } + + private static String writeListToString(Collection 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/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java new file mode 100644 index 000000000..d88543f73 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java @@ -0,0 +1,146 @@ +package de.danoeh.antennapod.core.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.core.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/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java new file mode 100644 index 000000000..5cac4837d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java @@ -0,0 +1,577 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.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 { + public static final String IMPORT_DIR = "import/"; + 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 speedList = new LinkedList(); + 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, + 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/core/src/main/java/de/danoeh/antennapod/core/receiver/AlarmUpdateReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/AlarmUpdateReceiver.java new file mode 100644 index 000000000..0777a7a2e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/AlarmUpdateReceiver.java @@ -0,0 +1,33 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/receiver/ConnectivityActionReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/ConnectivityActionReceiver.java new file mode 100644 index 000000000..6a9a4166a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/ConnectivityActionReceiver.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DownloadRequester; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java new file mode 100644 index 000000000..6ce30763d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java new file mode 100644 index 000000000..a900248d2 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java @@ -0,0 +1,32 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.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.core.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/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java b/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java new file mode 100644 index 000000000..0f2a81dfb --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java @@ -0,0 +1,251 @@ +package de.danoeh.antennapod.core.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 java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.gpoddernet.GpodnetService; +import de.danoeh.antennapod.core.gpoddernet.GpodnetServiceAuthenticationException; +import de.danoeh.antennapod.core.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetSubscriptionChange; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DownloadRequestException; +import de.danoeh.antennapod.core.storage.DownloadRequester; +import de.danoeh.antennapod.core.util.NetworkUtils; + +/** + * 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 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()); + if (BuildConfig.DEBUG) + Log.d(TAG, "Uploading changes response: " + uploadChangesResponse); + GpodnetPreferences.removeAddedFeeds(localSubscriptions); + GpodnetPreferences.removeRemovedFeeds(GpodnetPreferences.getRemovedFeedsCopy()); + GpodnetPreferences.setLastSyncTimestamp(uploadChangesResponse.timestamp); + } else { + Set added = GpodnetPreferences.getAddedFeedsCopy(); + Set 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 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 = ClientConfig.gpodnetCallbacks.getGpodnetSyncServiceErrorNotificationPendingIntent(this); + 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/core/src/main/java/de/danoeh/antennapod/core/service/download/APRedirectHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/APRedirectHandler.java new file mode 100644 index 000000000..3efcf4da8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/APRedirectHandler.java @@ -0,0 +1,54 @@ +package de.danoeh.antennapod.core.service.download; + +import android.util.Log; +import de.danoeh.antennapod.core.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 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 CREATOR = new Parcelable.Creator() { + 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/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java new file mode 100644 index 000000000..9229622ed --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java @@ -0,0 +1,1200 @@ +package de.danoeh.antennapod.core.service.download; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationManager; +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.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.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.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.EventDistributor; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.storage.DownloadRequestException; +import de.danoeh.antennapod.core.storage.DownloadRequester; +import de.danoeh.antennapod.core.syndication.handler.FeedHandler; +import de.danoeh.antennapod.core.syndication.handler.UnsupportedFeedtypeException; +import de.danoeh.antennapod.core.util.ChapterUtils; +import de.danoeh.antennapod.core.util.DownloadError; +import de.danoeh.antennapod.core.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.core.service.cancelDownload"; + + /** + * Cancels all running downloads. + */ + public static final String ACTION_CANCEL_ALL_DOWNLOADS = "action.de.danoeh.antennapod.core.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.core.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 newMediaFiles; + + /** + * Contains all completed downloads that have not been included in the report yet. + */ + private List reportQueue; + + private ExecutorService syncExecutor; + private CompletionService 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 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()); + reportQueue = Collections.synchronizedList(new ArrayList()); + downloads = new ArrayList(); + 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( + 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() { + 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(ClientConfig.downloadServiceCallbacks.getNotificationContentIntent(this)).setLargeIcon(icon) + .setSmallIcon(R.drawable.stat_notify_sync) + ); + } else { + notificationCompatBuilder = new NotificationCompat.Builder(this) + .setOngoing(true).setContentIntent(ClientConfig.downloadServiceCallbacks.getNotificationContentIntent(this)) + .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"); + // create notification object + Notification notification = new NotificationCompat.Builder(this) + .setTicker( + getString(R.string.download_report_title)) + .setContentTitle( + getString(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( + ClientConfig.downloadServiceCallbacks.getReportNotificationContentIntent(this) + ) + .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(); + + 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(ClientConfig.downloadServiceCallbacks.getAuthentificationNotificationContentIntent(DownloadService.this, downloadRequest)); + 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 completedRequests = new LinkedBlockingDeque(); + private CompletionService parserService = new ExecutorCompletionService(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 collectCompletedRequests() { + List results = new LinkedList(); + 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 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 { + + 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. + *

+ * If the file has been partially downloaded, this handler will set the file_url of the FeedFile to the location + * of the downloaded file. + *

+ * 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 getDownloads() { + return downloads; + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadStatus.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadStatus.java new file mode 100644 index 000000000..d05650d10 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadStatus.java @@ -0,0 +1,181 @@ +package de.danoeh.antennapod.core.service.download; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.core.feed.FeedFile; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/service/download/Downloader.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/Downloader.java new file mode 100644 index 000000000..d8042d202 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/Downloader.java @@ -0,0 +1,73 @@ +package de.danoeh.antennapod.core.service.download; + +import android.content.Context; +import android.net.wifi.WifiManager; + +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; + +/** + * Downloads files + */ +public abstract class Downloader implements Callable { + 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) + ClientConfig.applicationCallbacks.getApplicationInstance().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/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloaderCallback.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloaderCallback.java new file mode 100644 index 000000000..2d9347b0a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloaderCallback.java @@ -0,0 +1,10 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java new file mode 100644 index 000000000..32d0d351a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java @@ -0,0 +1,252 @@ +package de.danoeh.antennapod.core.service.download; + +import android.net.http.AndroidHttpClient; +import android.util.Log; + +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.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.util.DownloadError; +import de.danoeh.antennapod.core.util.StorageUtils; +import de.danoeh.antennapod.core.util.URIUtil; + +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(ClientConfig.applicationCallbacks.getApplicationInstance())) { + 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/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java new file mode 100644 index 000000000..5123e40c7 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -0,0 +1,1072 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.asynctask.PicassoProvider; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.QueueAccess; +import de.danoeh.antennapod.core.util.flattr.FlattrUtils; +import de.danoeh.antennapod.core.util.playback.Playable; + +/** + * Controls the MediaPlayer that plays a FeedMedia-file + */ +public class PlaybackService extends Service { + 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"; + /** + * 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.core.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.core.service.startWhenPrepared"; + + public static final String EXTRA_PREPARE_IMMEDIATELY = "extra.de.danoeh.antennapod.core.service.prepareImmediately"; + + public static final String ACTION_PLAYER_STATUS_CHANGED = "action.de.danoeh.antennapod.core.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.core.service.playerNotification"; + public static final String EXTRA_NOTIFICATION_CODE = "extra.de.danoeh.antennapod.core.service.notificationCode"; + public static final String EXTRA_NOTIFICATION_TYPE = "extra.de.danoeh.antennapod.core.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.core.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.core.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) { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType); + } else { + if (PlaybackPreferences.getCurrentEpisodeIsVideo()) { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO); + } else { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO); + } + } + } + + /** + * 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(); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt); + } + + @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 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 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() { + 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(STOP_WIDGET_UPDATE)); + } + + private void updateWidget() { + PlaybackService.this.sendBroadcast(new Intent( + 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 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.core.service.playback.PlaybackServiceMediaPlayer#seekToChapter(de.danoeh.antennapod.core.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 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/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java new file mode 100644 index 000000000..590b67853 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java @@ -0,0 +1,979 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.util.playback.AudioPlayer; +import de.danoeh.antennapod.core.util.playback.IPlayer; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.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 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(), + 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. + *

+ * States: + * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * This method requires the playerLock and is executed on the caller's thread. + * + * @see #playMediaObject(de.danoeh.antennapod.core.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. + *

+ * 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. + *

+ * 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. + *

+ * 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(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. + *

+ * 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 + *

+ * 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. + *

+ * 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 getVideoSize() { + if (!playerLock.tryLock()) { + // use cached value if lock can't be aquired + return videoSize; + } + Pair res; + if (mediaPlayer == null || playerStatus == PlayerStatus.ERROR || mediaType != MediaType.VIDEO) { + res = null; + } else { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + videoSize = new Pair(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). + *

+ * 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/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java new file mode 100644 index 000000000..1865afa6f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java @@ -0,0 +1,384 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.content.Context; +import android.util.Log; + +import org.apache.commons.lang3.Validate; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.EventDistributor; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.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. + *

+ * 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> 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>() { + @Override + public List 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 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 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/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java new file mode 100644 index 000000000..1ad0c25d9 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java @@ -0,0 +1,14 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java new file mode 100644 index 000000000..62edaae29 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java @@ -0,0 +1,908 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.*; +import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.util.DownloadError; +import de.danoeh.antennapod.core.util.comparator.DownloadStatusComparator; +import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; +import de.danoeh.antennapod.core.util.comparator.PlaybackCompletionDateComparator; +import de.danoeh.antennapod.core.util.flattr.FlattrStatus; +import de.danoeh.antennapod.core.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.core.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.core.feed.Feed)}. + */ + public static List getFeedList(final Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting Feedlist"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List result = getFeedList(adapter); + adapter.close(); + return result; + } + + private static List getFeedList(PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting Feedlist"); + + Cursor feedlistCursor = adapter.getAllFeedsCursor(); + List feeds = new ArrayList(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 getFeedListDownloadUrls(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + List result = new ArrayList(); + 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.core.feed.Feed)}. + */ + public static List 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 feeds = new ArrayList(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 items) { + List 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 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 items = extractItemlistFromCursor(adapter, + itemlistCursor); + itemlistCursor.close(); + + Collections.sort(items, new FeedItemPubdateComparator()); + + adapter.close(); + + for (FeedItem item : items) { + item.setFeed(feed); + } + + return items; + } + + static List extractItemlistFromCursor(Context context, Cursor itemlistCursor) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List result = extractItemlistFromCursor(adapter, itemlistCursor); + adapter.close(); + return result; + } + + private static List extractItemlistFromCursor( + PodDBAdapter adapter, Cursor itemlistCursor) { + ArrayList itemIds = new ArrayList(); + List items = new ArrayList( + 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()); + 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 items, ArrayList itemIds) { + + List itemsCopy = new ArrayList(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 items) { + for (FeedItem item : items) { + if (item.getId() == itemId) { + return item; + } + } + return null; + } + + static List getQueue(Context context, PodDBAdapter adapter) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting queue"); + + Cursor itemlistCursor = adapter.getQueueCursor(); + List 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.core.util.QueueAccess} object for easier access to the queue's properties. + */ + public static List getQueueIDList(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + + adapter.open(); + List result = getQueueIDList(adapter); + adapter.close(); + + return result; + } + + static List getQueueIDList(PodDBAdapter adapter) { + adapter.open(); + Cursor queueCursor = adapter.getQueueIDCursor(); + + List queueIds = new ArrayList(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.core.util.QueueAccess} object for easier access to the queue's properties. + */ + public static List getQueue(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting queue"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List 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 getDownloadedItems(Context context) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Extracting downloaded items"); + + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + + Cursor itemlistCursor = adapter.getDownloadedItemsCursor(); + List 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 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 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 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 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 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 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 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 downloadLog = new ArrayList( + 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.core.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 getFeedStatisticsList(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List result = new ArrayList(); + 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 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 getFlattrQueue(Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + List result = new ArrayList(); + + // 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 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 feeds; + public int queueSize; + public int numUnreadItems; + + public NavDrawerData(List feeds, int queueSize, int numUnreadItems) { + this.feeds = feeds; + this.queueSize = queueSize; + this.numUnreadItems = numUnreadItems; + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java new file mode 100644 index 000000000..982959bc2 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java @@ -0,0 +1,895 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.asynctask.FlattrClickWorker; +import de.danoeh.antennapod.core.asynctask.FlattrStatusFetcher; +import de.danoeh.antennapod.core.feed.*; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.GpodnetSyncService; +import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.util.DownloadError; +import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.core.util.QueueAccess; +import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; +import de.danoeh.antennapod.core.util.exception.MediaFileNotFoundException; +import de.danoeh.antennapod.core.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 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 getExpiredFeeds(final Context context) { + long millis = UserPreferences.getUpdateInterval(); + + if (millis > 0) { + + List feedList = DBReader.getExpiredFeedsList(context, + millis); + if (feedList.size() > 0) { + refreshFeeds(context, feedList); + } + return feedList; + } else { + return new ArrayList(); + } + } + + /** + * 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 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 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 queue, final List 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 queue = DBReader.getQueue(context); + final List 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 itemsToDownload = new ArrayList(); + + 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. + *

+ * 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 candidates = new ArrayList(); + List downloadedItems = DBReader.getDownloadedItems(context); + QueueAccess queue = QueueAccess.IDListAccess(DBReader.getQueueIDList(context)); + List 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() { + @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 queue) { + FeedItem result = null; + if (queue == null) { + queue = DBReader.getQueue(context); + } + if (queue != null) { + Iterator 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 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 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. + *

+ * This method can update multiple feeds at once. Submitting a feed twice in the same method call can result in undefined behavior. + *

+ * 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 newFeedsList = new ArrayList(); + List updatedFeedsList = new ArrayList(); + 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> searchFeedItemTitle(final Context context, + final long feedID, final String query) { + return new FutureTask>(new QueryTask>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemTitles(feedID, + query); + List 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> searchFeedItemDescription(final Context context, + final long feedID, final String query) { + return new FutureTask>(new QueryTask>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemDescriptions(feedID, + query); + List 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> searchFeedItemContentEncoded(final Context context, + final long feedID, final String query) { + return new FutureTask>(new QueryTask>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemContentEncoded(feedID, + query); + List 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> searchFeedItemChapters(final Context context, + final long feedID, final String query) { + return new FutureTask>(new QueryTask>(context) { + @Override + public void execute(PodDBAdapter adapter) { + Cursor searchResult = adapter.searchItemChapters(feedID, + query); + List 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 implements Callable { + 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/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java new file mode 100644 index 000000000..eec15acd2 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java @@ -0,0 +1,974 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.asynctask.FlattrClickWorker; +import de.danoeh.antennapod.core.feed.*; +import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.util.QueueAccess; +import de.danoeh.antennapod.core.util.flattr.FlattrStatus; +import de.danoeh.antennapod.core.util.flattr.FlattrThing; +import de.danoeh.antennapod.core.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 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 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 queue = DBReader.getQueue(context, + adapter); + + if (queue != null) { + boolean queueModified = false; + boolean unreadItemsModified = false; + List itemsToSave = new LinkedList(); + 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 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 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 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. + *

+ * 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 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 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 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 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/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequestException.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequestException.java new file mode 100644 index 000000000..c85559e20 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequestException.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java new file mode 100644 index 000000000..2fd653d32 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java @@ -0,0 +1,366 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import android.webkit.URLUtil; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.*; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.download.DownloadRequest; +import de.danoeh.antennapod.core.service.download.DownloadService; +import de.danoeh.antennapod.core.util.FileNameGenerator; +import de.danoeh.antennapod.core.util.URLChecker; +import org.apache.commons.io.FilenameUtils; +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 downloads; + + private DownloadRequester() { + downloads = new ConcurrentHashMap(); + } + + 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/core/src/main/java/de/danoeh/antennapod/core/storage/FeedItemStatistics.java b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedItemStatistics.java new file mode 100644 index 000000000..f6a59836b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedItemStatistics.java @@ -0,0 +1,70 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java new file mode 100644 index 000000000..3a63685ba --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.SearchResult; +import de.danoeh.antennapod.core.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 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 result = new ArrayList(); + + FutureTask>[] 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 items = (List) 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/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java new file mode 100644 index 000000000..1407080dc --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java @@ -0,0 +1,1310 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedComponent; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.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"; + 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, + ClientConfig.storageCallbacks.getDatabaseVersion()); + } + 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 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 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) { + ClientConfig.storageCallbacks.onUpgrade(db, oldVersion, newVersion); + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandler.java new file mode 100644 index 000000000..9efc5888f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandler.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandlerResult.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandlerResult.java new file mode 100644 index 000000000..45d1413bf --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/FeedHandlerResult.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import de.danoeh.antennapod.core.feed.Feed; + +import java.util.Map; + +/** + * Container for results returned by the Feed parser + */ +public class FeedHandlerResult { + + public Feed feed; + public Map alternateFeedUrls; + + public FeedHandlerResult(Feed feed, Map alternateFeedUrls) { + this.feed = feed; + this.alternateFeedUrls = alternateFeedUrls; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/HandlerState.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/HandlerState.java new file mode 100644 index 000000000..4fe8e1aff --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/HandlerState.java @@ -0,0 +1,98 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.syndication.namespace.Namespace; +import de.danoeh.antennapod.core.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 alternateUrls; + protected ArrayList items; + protected FeedItem currentItem; + protected Stack tagstack; + /** + * Namespaces that have been defined so far. + */ + protected HashMap namespaces; + protected Stack defaultNamespaces; + /** + * Buffer for saving characters. + */ + protected StringBuffer contentBuf; + + public HandlerState(Feed feed) { + this.feed = feed; + alternateUrls = new LinkedHashMap(); + items = new ArrayList(); + tagstack = new Stack(); + namespaces = new HashMap(); + defaultNamespaces = new Stack(); + } + + public Feed getFeed() { + return feed; + } + + public ArrayList getItems() { + return items; + } + + public FeedItem getCurrentItem() { + return currentItem; + } + + public Stack 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/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/SyndHandler.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/SyndHandler.java new file mode 100644 index 000000000..1dda24944 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/SyndHandler.java @@ -0,0 +1,126 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.syndication.namespace.*; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/TypeGetter.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/TypeGetter.java new file mode 100644 index 000000000..32cd538d5 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/TypeGetter.java @@ -0,0 +1,111 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Feed; +import org.apache.commons.io.input.XmlStreamReader; +import org.jsoup.Jsoup; +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/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java new file mode 100644 index 000000000..3da9251d9 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.core.syndication.handler; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java new file mode 100644 index 000000000..71bf69ffa --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java new file mode 100644 index 000000000..fb794d7e0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java @@ -0,0 +1,51 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java new file mode 100644 index 000000000..7f03f1139 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java @@ -0,0 +1,68 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.syndication.handler.HandlerState; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java new file mode 100644 index 000000000..c29741456 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java @@ -0,0 +1,141 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.syndication.handler.HandlerState; +import de.danoeh.antennapod.core.syndication.util.SyndDateUtils; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSSimpleChapters.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSSimpleChapters.java new file mode 100644 index 000000000..2b4a2767d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSSimpleChapters.java @@ -0,0 +1,42 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.SimpleChapter; +import de.danoeh.antennapod.core.syndication.handler.HandlerState; +import de.danoeh.antennapod.core.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()); + } 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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/Namespace.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/Namespace.java new file mode 100644 index 000000000..cf118d202 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/Namespace.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core.syndication.namespace; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/SyndElement.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/SyndElement.java new file mode 100644 index 000000000..8adcd2086 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/SyndElement.java @@ -0,0 +1,22 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomText.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomText.java new file mode 100644 index 000000000..43fe0edb7 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomText.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.syndication.namespace.atom; + +import de.danoeh.antennapod.core.syndication.namespace.Namespace; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java new file mode 100644 index 000000000..61cb9ec65 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java @@ -0,0 +1,194 @@ +package de.danoeh.antennapod.core.syndication.namespace.atom; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.FeedImage; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.syndication.handler.HandlerState; +import de.danoeh.antennapod.core.syndication.namespace.NSRSS20; +import de.danoeh.antennapod.core.syndication.namespace.Namespace; +import de.danoeh.antennapod.core.syndication.namespace.SyndElement; +import de.danoeh.antennapod.core.syndication.util.SyndDateUtils; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndDateUtils.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndDateUtils.java new file mode 100644 index 000000000..977d92304 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndDateUtils.java @@ -0,0 +1,153 @@ +package de.danoeh.antennapod.core.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 RFC822Formatter = new ThreadLocal() { + @Override + protected SimpleDateFormat initialValue() { + return new SimpleDateFormat(RFC822DATES[0], Locale.US); + } + + }; + + private static ThreadLocal RFC3339Formatter = new ThreadLocal() { + @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/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndTypeUtils.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndTypeUtils.java new file mode 100644 index 000000000..8d1d8ffde --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/util/SyndTypeUtils.java @@ -0,0 +1,42 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java new file mode 100644 index 000000000..759a60f43 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java @@ -0,0 +1,261 @@ +package de.danoeh.antennapod.core.util; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.util.comparator.ChapterStartTimeComparator; +import de.danoeh.antennapod.core.util.id3reader.ChapterReader; +import de.danoeh.antennapod.core.util.id3reader.ID3ReaderException; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.vorbiscommentreader.VorbisCommentChapterReader; +import de.danoeh.antennapod.core.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 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 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 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 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 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 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/core/src/main/java/de/danoeh/antennapod/core/util/Converter.java b/core/src/main/java/de/danoeh/antennapod/core/util/Converter.java new file mode 100644 index 000000000..a0b514bd6 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/Converter.java @@ -0,0 +1,103 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java new file mode 100644 index 000000000..602c221bf --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java @@ -0,0 +1,52 @@ +package de.danoeh.antennapod.core.util; + +import android.content.Context; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/DuckType.java b/core/src/main/java/de/danoeh/antennapod/core/util/DuckType.java new file mode 100644 index 000000000..f432424f8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/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.core.util; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import de.danoeh.antennapod.core.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 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 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/core/src/main/java/de/danoeh/antennapod/core/util/EpisodeFilter.java b/core/src/main/java/de/danoeh/antennapod/core/util/EpisodeFilter.java new file mode 100644 index 000000000..4c23b161b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/EpisodeFilter.java @@ -0,0 +1,49 @@ +package de.danoeh.antennapod.core.util; + +import de.danoeh.antennapod.core.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 getEpisodeList(List items) { + ArrayList episodes = new ArrayList(items); + for (FeedItem item : items) { + if (item.getMedia() == null) { + episodes.remove(item); + } + } + return episodes; + } + + public static int countItemsWithEpisodes(List items) { + int count = 0; + for (FeedItem item : items) { + if (item.getMedia() != null) { + count++; + } + } + return count; + } + + public static FeedItem accessEpisodeByIndex(List 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/core/src/main/java/de/danoeh/antennapod/core/util/FeedtitleComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/FeedtitleComparator.java new file mode 100644 index 000000000..bf14cd23e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/FeedtitleComparator.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.core.util; + +import de.danoeh.antennapod.core.feed.Feed; + +import java.util.Comparator; + +/** Compares the title of two feeds for sorting. */ +public class FeedtitleComparator implements Comparator { + + @Override + public int compare(Feed lhs, Feed rhs) { + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java new file mode 100644 index 000000000..00c023b64 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/InvalidFeedException.java b/core/src/main/java/de/danoeh/antennapod/core/util/InvalidFeedException.java new file mode 100644 index 000000000..c98c2d82a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/InvalidFeedException.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java new file mode 100644 index 000000000..07432d28a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java @@ -0,0 +1,120 @@ +package de.danoeh.antennapod.core.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 languages; + static { + languages = new HashMap(); + 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/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java new file mode 100644 index 000000000..b321536a3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.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 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/core/src/main/java/de/danoeh/antennapod/core/util/QueueAccess.java b/core/src/main/java/de/danoeh/antennapod/core/util/QueueAccess.java new file mode 100644 index 000000000..8e40ae184 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/QueueAccess.java @@ -0,0 +1,93 @@ +package de.danoeh.antennapod.core.util; + +import de.danoeh.antennapod.core.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 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 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 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/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java new file mode 100644 index 000000000..85f32ed50 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.core.util; + +import android.content.Context; +import android.content.Intent; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java b/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java new file mode 100644 index 000000000..7e7c6c08b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java @@ -0,0 +1,16 @@ +package de.danoeh.antennapod.core.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 loadShownotes(); + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java new file mode 100644 index 000000000..dea380937 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.core.util; + +import android.app.Activity; +import android.content.Context; +import android.os.Build; +import android.os.StatFs; +import android.util.Log; + +import java.io.File; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.preferences.UserPreferences; + +/** + * 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(ClientConfig.applicationCallbacks.getStorageErrorActivity(activity)); + } + 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( + ClientConfig.applicationCallbacks.getApplicationInstance(), 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/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java new file mode 100644 index 000000000..f67367643 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.core.util; + +import android.util.Log; + +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.preferences.UserPreferences; + +public class ThemeUtils { + private static final String TAG = "ThemeUtils"; + + public static int getSelectionBackgroundColor() { + int theme = UserPreferences.getTheme(); + if (theme == R.style.Theme_AntennaPod_Dark) { + return R.color.selection_background_color_dark; + } else if (theme == R.style.Theme_AntennaPod_Light) { + return R.color.selection_background_color_light; + } else { + Log.e(TAG, + "getSelectionBackgroundColor could not match the current theme to any color!"); + return R.color.selection_background_color_light; + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/URIUtil.java b/core/src/main/java/de/danoeh/antennapod/core/util/URIUtil.java new file mode 100644 index 000000000..092c06b4a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/URIUtil.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.core.util; + +import android.util.Log; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java b/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java new file mode 100644 index 000000000..ca49427c0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java @@ -0,0 +1,51 @@ +package de.danoeh.antennapod.core.util; + +import android.util.Log; + +import org.apache.commons.lang3.StringUtils; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/UndoBarController.java b/core/src/main/java/de/danoeh/antennapod/core/util/UndoBarController.java new file mode 100644 index 000000000..5843c5f8f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/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.core.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.core.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/core/src/main/java/de/danoeh/antennapod/core/util/comparator/ChapterStartTimeComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/ChapterStartTimeComparator.java new file mode 100644 index 000000000..5274ffc9e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/ChapterStartTimeComparator.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.core.util.comparator; + +import de.danoeh.antennapod.core.feed.Chapter; + +import java.util.Comparator; + +public class ChapterStartTimeComparator implements Comparator { + + @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/core/src/main/java/de/danoeh/antennapod/core/util/comparator/DownloadStatusComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/DownloadStatusComparator.java new file mode 100644 index 000000000..ebdbfe2a5 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/DownloadStatusComparator.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.core.util.comparator; + +import de.danoeh.antennapod.core.service.download.DownloadStatus; + +import java.util.Comparator; + +/** Compares the completion date of two Downloadstatus objects. */ +public class DownloadStatusComparator implements Comparator { + + @Override + public int compare(DownloadStatus lhs, DownloadStatus rhs) { + return rhs.getCompletionDate().compareTo(lhs.getCompletionDate()); + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/comparator/FeedItemPubdateComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/FeedItemPubdateComparator.java new file mode 100644 index 000000000..a1f3ec699 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/FeedItemPubdateComparator.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.core.util.comparator; + +import de.danoeh.antennapod.core.feed.FeedItem; + +import java.util.Comparator; + +/** Compares the pubDate of two FeedItems for sorting*/ +public class FeedItemPubdateComparator implements Comparator { + + /** 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/core/src/main/java/de/danoeh/antennapod/core/util/comparator/PlaybackCompletionDateComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/PlaybackCompletionDateComparator.java new file mode 100644 index 000000000..84d244660 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/PlaybackCompletionDateComparator.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.core.util.comparator; + +import de.danoeh.antennapod.core.feed.FeedItem; + +import java.util.Comparator; + +public class PlaybackCompletionDateComparator implements Comparator { + + 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/core/src/main/java/de/danoeh/antennapod/core/util/comparator/SearchResultValueComparator.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/SearchResultValueComparator.java new file mode 100644 index 000000000..b16e0949d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/SearchResultValueComparator.java @@ -0,0 +1,14 @@ +package de.danoeh.antennapod.core.util.comparator; + +import de.danoeh.antennapod.core.feed.SearchResult; + +import java.util.Comparator; + +public class SearchResultValueComparator implements Comparator { + + @Override + public int compare(SearchResult lhs, SearchResult rhs) { + return rhs.getValue() - lhs.getValue(); + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/exception/MediaFileNotFoundException.java b/core/src/main/java/de/danoeh/antennapod/core/util/exception/MediaFileNotFoundException.java new file mode 100644 index 000000000..287fe1100 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/exception/MediaFileNotFoundException.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.core.util.exception; + +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrServiceCreator.java b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrServiceCreator.java new file mode 100644 index 000000000..e4818214e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrServiceCreator.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.core.util.flattr; + +import android.util.Log; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrStatus.java b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrStatus.java new file mode 100644 index 000000000..d82171d1a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrStatus.java @@ -0,0 +1,68 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrThing.java b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrThing.java new file mode 100644 index 000000000..515028ab6 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrThing.java @@ -0,0 +1,7 @@ +package de.danoeh.antennapod.core.util.flattr; + +public interface FlattrThing { + public String getTitle(); + public String getPaymentLink(); + public FlattrStatus getFlattrStatus(); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrUtils.java new file mode 100644 index 000000000..42eeeadce --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/FlattrUtils.java @@ -0,0 +1,304 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.asynctask.FlattrTokenFetcher; +import de.danoeh.antennapod.core.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, ClientConfig.flattrCallbacks.getFlattrAppKey(), + ClientConfig.flattrCallbacks.getFlattrAppSecret()); + } + + 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( + ClientConfig.applicationCallbacks.getApplicationInstance()) + .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(ClientConfig.flattrCallbacks.getFlattrAppKey()) + && StringUtils.isNotEmpty(ClientConfig.flattrCallbacks.getFlattrAppSecret()); + } + + 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(ClientConfig.applicationCallbacks.getApplicationInstance()).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 retrieveFlattredThings() + throws FlattrException { + ArrayList myFlattrs = new ArrayList(); + + 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( + ClientConfig.flattrCallbacks.getFlattrAuthenticationActivityIntent(context)); + } + + } + ); + + 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( + ClientConfig.flattrCallbacks.getFlattrAuthenticationActivityIntent(context)); + } + + } + ); + 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/core/src/main/java/de/danoeh/antennapod/core/util/flattr/SimpleFlattrThing.java b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/SimpleFlattrThing.java new file mode 100644 index 000000000..2c178496e --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/flattr/SimpleFlattrThing.java @@ -0,0 +1,30 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/gui/FeedItemUndoToken.java b/core/src/main/java/de/danoeh/antennapod/core/util/gui/FeedItemUndoToken.java new file mode 100644 index 000000000..17581d3e9 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/gui/FeedItemUndoToken.java @@ -0,0 +1,55 @@ +package de.danoeh.antennapod.core.util.gui; + +import android.os.Parcel; +import android.os.Parcelable; +import de.danoeh.antennapod.core.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 CREATOR = new Parcelable.Creator() { + 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/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java new file mode 100644 index 000000000..9f3c4c6d5 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java @@ -0,0 +1,118 @@ +package de.danoeh.antennapod.core.util.id3reader; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.ID3Chapter; +import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader; +import de.danoeh.antennapod.core.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 chapters; + private ID3Chapter currentChapter; + + @Override + public int onStartTagHeader(TagHeader header) { + chapters = new ArrayList(); + 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 getChapters() { + return chapters; + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java new file mode 100644 index 000000000..a238c11e9 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java @@ -0,0 +1,250 @@ +package de.danoeh.antennapod.core.util.id3reader; + +import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3ReaderException.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3ReaderException.java new file mode 100644 index 000000000..0c746d7e5 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3ReaderException.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java new file mode 100644 index 000000000..89eab1398 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java @@ -0,0 +1,17 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/Header.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/Header.java new file mode 100644 index 000000000..346e2893f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/Header.java @@ -0,0 +1,29 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java new file mode 100644 index 000000000..0a6b8357f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java new file mode 100644 index 000000000..aafcea307 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java new file mode 100644 index 000000000..49769f4f0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java @@ -0,0 +1,235 @@ +package de.danoeh.antennapod.core.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.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.util.ChapterUtils; + +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 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 loadShownotes() { + return new Callable() { + @Override + public String call() throws Exception { + return ""; + } + }; + } + + @Override + public List 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 chapters) { + this.chapters = chapters; + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + 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/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java new file mode 100644 index 000000000..147c7848d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java new file mode 100644 index 000000000..0650225f0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/MediaPlayerError.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.core.util.playback; + +import android.content.Context; +import android.media.MediaPlayer; +import de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java new file mode 100644 index 000000000..7ebd580f7 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -0,0 +1,207 @@ +package de.danoeh.antennapod.core.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.core.asynctask.PicassoImageResource; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.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 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 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/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java new file mode 100644 index 000000000..5118d92ae --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java @@ -0,0 +1,784 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.core.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 intentLoader = new AsyncTask() { + @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 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/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java new file mode 100644 index 000000000..443ff0ad1 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java @@ -0,0 +1,161 @@ +package de.danoeh.antennapod.core.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.core.BuildConfig; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.core.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. + *

+ * 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 = "%s"; + 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). + *

+ * 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/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java new file mode 100644 index 000000000..dc5270d8f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/syndication/FeedDiscoverer.java b/core/src/main/java/de/danoeh/antennapod/core/util/syndication/FeedDiscoverer.java new file mode 100644 index 000000000..9588265b8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/syndication/FeedDiscoverer.java @@ -0,0 +1,78 @@ +package de.danoeh.antennapod.core.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: + *

+ * http://www.rssboard.org/rss-autodiscovery + *

+ * 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 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 findLinks(String in, String baseUrl) throws IOException { + return findLinks(Jsoup.parse(in), baseUrl); + } + + private Map findLinks(Document document, String baseUrl) { + Map res = new LinkedHashMap(); + 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/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/OggInputStream.java b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/OggInputStream.java new file mode 100644 index 000000000..4799d3881 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/OggInputStream.java @@ -0,0 +1,81 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReader.java b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReader.java new file mode 100644 index 000000000..c4961a3ab --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReader.java @@ -0,0 +1,101 @@ +package de.danoeh.antennapod.core.util.vorbiscommentreader; + +import android.util.Log; +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.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 chapters; + + public VorbisCommentChapterReader() { + } + + @Override + public void onVorbisCommentFound() { + System.out.println("Vorbis comment found"); + } + + @Override + public void onVorbisCommentHeaderFound(VorbisCommentHeader header) { + chapters = new ArrayList(); + 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 getChapters() { + return chapters; + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentHeader.java new file mode 100644 index 000000000..5f9dd0faf --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentHeader.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReader.java b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReader.java new file mode 100644 index 000000000..9639b9c42 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReader.java @@ -0,0 +1,194 @@ +package de.danoeh.antennapod.core.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/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReaderException.java b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReaderException.java new file mode 100644 index 000000000..89ab20db0 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentReaderException.java @@ -0,0 +1,24 @@ +package de.danoeh.antennapod.core.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 + } + +} -- cgit v1.2.3