From a1a18f77a50804e0127dfa4b0f5240c49c541184 Mon Sep 17 00:00:00 2001 From: Scott Jackson Date: Mon, 2 Jul 2012 21:24:02 -0700 Subject: Initial Commit --- .../androidapp/service/CachedMusicService.java | 237 ++++++ .../subsonic/androidapp/service/DownloadFile.java | 323 +++++++ .../androidapp/service/DownloadService.java | 112 +++ .../androidapp/service/DownloadServiceImpl.java | 930 +++++++++++++++++++++ .../service/DownloadServiceLifecycleSupport.java | 266 ++++++ .../androidapp/service/JukeboxService.java | 356 ++++++++ .../androidapp/service/MediaStoreService.java | 109 +++ .../subsonic/androidapp/service/MusicService.java | 91 ++ .../androidapp/service/MusicServiceFactory.java | 36 + .../androidapp/service/OfflineException.java | 32 + .../androidapp/service/OfflineMusicService.java | 244 ++++++ .../androidapp/service/RESTMusicService.java | 768 +++++++++++++++++ .../subsonic/androidapp/service/Scrobbler.java | 52 ++ .../androidapp/service/ServerTooOldException.java | 51 ++ .../androidapp/service/parser/AbstractParser.java | 138 +++ .../androidapp/service/parser/AlbumListParser.java | 62 ++ .../androidapp/service/parser/ErrorParser.java | 49 ++ .../androidapp/service/parser/IndexesParser.java | 104 +++ .../service/parser/JukeboxStatusParser.java | 62 ++ .../androidapp/service/parser/LicenseParser.java | 62 ++ .../androidapp/service/parser/LyricsParser.java | 65 ++ .../service/parser/MusicDirectoryEntryParser.java | 59 ++ .../service/parser/MusicDirectoryParser.java | 71 ++ .../service/parser/MusicFoldersParser.java | 69 ++ .../androidapp/service/parser/PlaylistParser.java | 62 ++ .../androidapp/service/parser/PlaylistsParser.java | 67 ++ .../service/parser/RandomSongsParser.java | 62 ++ .../service/parser/SearchResult2Parser.java | 75 ++ .../service/parser/SearchResultParser.java | 67 ++ .../service/parser/SubsonicRESTException.java | 19 + .../androidapp/service/parser/VersionParser.java | 47 ++ .../androidapp/service/ssl/SSLSocketFactory.java | 497 +++++++++++ .../service/ssl/TrustManagerDecorator.java | 65 ++ .../service/ssl/TrustSelfSignedStrategy.java | 44 + .../androidapp/service/ssl/TrustStrategy.java | 57 ++ 35 files changed, 5410 insertions(+) create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java create mode 100644 subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java (limited to 'subsonic-android/src/net/sourceforge/subsonic/androidapp/service') diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java new file mode 100644 index 00000000..a06f3995 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java @@ -0,0 +1,237 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.http.HttpResponse; + +import android.content.Context; +import android.graphics.Bitmap; +import net.sourceforge.subsonic.androidapp.domain.Indexes; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; +import net.sourceforge.subsonic.androidapp.domain.Lyrics; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.MusicFolder; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.domain.SearchCritera; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.domain.Version; +import net.sourceforge.subsonic.androidapp.util.CancellableTask; +import net.sourceforge.subsonic.androidapp.util.LRUCache; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import net.sourceforge.subsonic.androidapp.util.TimeLimitedCache; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + */ +public class CachedMusicService implements MusicService { + + private static final int MUSIC_DIR_CACHE_SIZE = 20; + private static final int TTL_MUSIC_DIR = 5 * 60; // Five minutes + + private final MusicService musicService; + private final LRUCache> cachedMusicDirectories; + private final TimeLimitedCache cachedLicenseValid = new TimeLimitedCache(120, TimeUnit.SECONDS); + private final TimeLimitedCache cachedIndexes = new TimeLimitedCache(60 * 60, TimeUnit.SECONDS); + private final TimeLimitedCache> cachedPlaylists = new TimeLimitedCache>(60, TimeUnit.SECONDS); + private final TimeLimitedCache> cachedMusicFolders = new TimeLimitedCache>(10 * 3600, TimeUnit.SECONDS); + private String restUrl; + + public CachedMusicService(MusicService musicService) { + this.musicService = musicService; + cachedMusicDirectories = new LRUCache>(MUSIC_DIR_CACHE_SIZE); + } + + @Override + public void ping(Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + musicService.ping(context, progressListener); + } + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + Boolean result = cachedLicenseValid.get(); + if (result == null) { + result = musicService.isLicenseValid(context, progressListener); + cachedLicenseValid.set(result, result ? 30L * 60L : 2L * 60L, TimeUnit.SECONDS); + } + return result; + } + + @Override + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + if (refresh) { + cachedMusicFolders.clear(); + } + List result = cachedMusicFolders.get(); + if (result == null) { + result = musicService.getMusicFolders(refresh, context, progressListener); + cachedMusicFolders.set(result); + } + return result; + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + if (refresh) { + cachedIndexes.clear(); + cachedMusicFolders.clear(); + cachedMusicDirectories.clear(); + } + Indexes result = cachedIndexes.get(); + if (result == null) { + result = musicService.getIndexes(musicFolderId, refresh, context, progressListener); + cachedIndexes.set(result); + } + return result; + } + + @Override + public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + TimeLimitedCache cache = refresh ? null : cachedMusicDirectories.get(id); + MusicDirectory dir = cache == null ? null : cache.get(); + if (dir == null) { + dir = musicService.getMusicDirectory(id, refresh, context, progressListener); + cache = new TimeLimitedCache(TTL_MUSIC_DIR, TimeUnit.SECONDS); + cache.set(dir); + cachedMusicDirectories.put(id, cache); + } + return dir; + } + + @Override + public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception { + return musicService.search(criteria, context, progressListener); + } + + @Override + public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + return musicService.getPlaylist(id, context, progressListener); + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + checkSettingsChanged(context); + List result = refresh ? null : cachedPlaylists.get(); + if (result == null) { + result = musicService.getPlaylists(refresh, context, progressListener); + cachedPlaylists.set(result); + } + return result; + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + musicService.createPlaylist(id, name, entries, context, progressListener); + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + return musicService.getLyrics(artist, title, context, progressListener); + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + musicService.scrobble(id, submission, context, progressListener); + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + return musicService.getAlbumList(type, size, offset, context, progressListener); + } + + @Override + public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception { + return musicService.getRandomSongs(size, context, progressListener); + } + + @Override + public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception { + return musicService.getCoverArt(context, entry, size, saveToFile, progressListener); + } + + @Override + public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception { + return musicService.getDownloadInputStream(context, song, offset, maxBitrate, task); + } + + @Override + public Version getLocalVersion(Context context) throws Exception { + return musicService.getLocalVersion(context); + } + + @Override + public Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception { + return musicService.getLatestVersion(context, progressListener); + } + + @Override + public String getVideoUrl(Context context, String id) { + return musicService.getVideoUrl(context, id); + } + + @Override + public JukeboxStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + return musicService.updateJukeboxPlaylist(ids, context, progressListener); + } + + @Override + public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + return musicService.skipJukebox(index, offsetSeconds, context, progressListener); + } + + @Override + public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + return musicService.stopJukebox(context, progressListener); + } + + @Override + public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + return musicService.startJukebox(context, progressListener); + } + + @Override + public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + return musicService.getJukeboxStatus(context, progressListener); + } + + @Override + public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + return musicService.setJukeboxGain(gain, context, progressListener); + } + + private void checkSettingsChanged(Context context) { + String newUrl = Util.getRestUrl(context, null); + if (!Util.equals(newUrl, restUrl)) { + cachedMusicFolders.clear(); + cachedMusicDirectories.clear(); + cachedLicenseValid.clear(); + cachedIndexes.clear(); + cachedPlaylists.clear(); + restUrl = newUrl; + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java new file mode 100644 index 00000000..46373afe --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadFile.java @@ -0,0 +1,323 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import android.content.Context; +import android.os.PowerManager; +import android.util.DisplayMetrics; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.CancellableTask; +import net.sourceforge.subsonic.androidapp.util.FileUtil; +import net.sourceforge.subsonic.androidapp.util.Util; +import net.sourceforge.subsonic.androidapp.util.CacheCleaner; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class DownloadFile { + + private static final String TAG = DownloadFile.class.getSimpleName(); + private final Context context; + private final MusicDirectory.Entry song; + private final File partialFile; + private final File completeFile; + private final File saveFile; + + private final MediaStoreService mediaStoreService; + private CancellableTask downloadTask; + private boolean save; + private boolean failed; + private int bitRate; + + public DownloadFile(Context context, MusicDirectory.Entry song, boolean save) { + this.context = context; + this.song = song; + this.save = save; + saveFile = FileUtil.getSongFile(context, song); + bitRate = Util.getMaxBitrate(context); + partialFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + "." + bitRate + ".partial." + FileUtil.getExtension(saveFile.getName())); + completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + ".complete." + FileUtil.getExtension(saveFile.getName())); + mediaStoreService = new MediaStoreService(context); + } + + public MusicDirectory.Entry getSong() { + return song; + } + + /** + * Returns the effective bit rate. + */ + public int getBitRate() { + if (bitRate > 0) { + return bitRate; + } + return song.getBitRate() == null ? 160 : song.getBitRate(); + } + + public synchronized void download() { + FileUtil.createDirectoryForParent(saveFile); + failed = false; + downloadTask = new DownloadTask(); + downloadTask.start(); + } + + public synchronized void cancelDownload() { + if (downloadTask != null) { + downloadTask.cancel(); + } + } + + public File getCompleteFile() { + if (saveFile.exists()) { + return saveFile; + } + + if (completeFile.exists()) { + return completeFile; + } + + return saveFile; + } + + public File getPartialFile() { + return partialFile; + } + + public boolean isSaved() { + return saveFile.exists(); + } + + public synchronized boolean isCompleteFileAvailable() { + return saveFile.exists() || completeFile.exists(); + } + + public synchronized boolean isWorkDone() { + return saveFile.exists() || (completeFile.exists() && !save); + } + + public synchronized boolean isDownloading() { + return downloadTask != null && downloadTask.isRunning(); + } + + public synchronized boolean isDownloadCancelled() { + return downloadTask != null && downloadTask.isCancelled(); + } + + public boolean shouldSave() { + return save; + } + + public boolean isFailed() { + return failed; + } + + public void delete() { + cancelDownload(); + Util.delete(partialFile); + Util.delete(completeFile); + Util.delete(saveFile); + mediaStoreService.deleteFromMediaStore(this); + } + + public void unpin() { + if (saveFile.exists()) { + saveFile.renameTo(completeFile); + } + } + + public boolean cleanup() { + boolean ok = true; + if (completeFile.exists() || saveFile.exists()) { + ok = Util.delete(partialFile); + } + if (saveFile.exists()) { + ok &= Util.delete(completeFile); + } + return ok; + } + + // In support of LRU caching. + public void updateModificationDate() { + updateModificationDate(saveFile); + updateModificationDate(partialFile); + updateModificationDate(completeFile); + } + + private void updateModificationDate(File file) { + if (file.exists()) { + boolean ok = file.setLastModified(System.currentTimeMillis()); + if (!ok) { + Log.w(TAG, "Failed to set last-modified date on " + file); + } + } + } + + @Override + public String toString() { + return "DownloadFile (" + song + ")"; + } + + private class DownloadTask extends CancellableTask { + + @Override + public void execute() { + + InputStream in = null; + FileOutputStream out = null; + PowerManager.WakeLock wakeLock = null; + try { + + if (Util.isScreenLitOnDownload(context)) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, toString()); + wakeLock.acquire(); + Log.i(TAG, "Acquired wake lock " + wakeLock); + } + + if (saveFile.exists()) { + Log.i(TAG, saveFile + " already exists. Skipping."); + return; + } + if (completeFile.exists()) { + if (save) { + Util.atomicCopy(completeFile, saveFile); + } else { + Log.i(TAG, completeFile + " already exists. Skipping."); + } + return; + } + + MusicService musicService = MusicServiceFactory.getMusicService(context); + + // Attempt partial HTTP GET, appending to the file if it exists. + HttpResponse response = musicService.getDownloadInputStream(context, song, partialFile.length(), bitRate, DownloadTask.this); + in = response.getEntity().getContent(); + boolean partial = response.getStatusLine().getStatusCode() == HttpStatus.SC_PARTIAL_CONTENT; + if (partial) { + Log.i(TAG, "Executed partial HTTP GET, skipping " + partialFile.length() + " bytes"); + } + + out = new FileOutputStream(partialFile, partial); + long n = copy(in, out); + Log.i(TAG, "Downloaded " + n + " bytes to " + partialFile); + out.flush(); + out.close(); + + if (isCancelled()) { + throw new Exception("Download of '" + song + "' was cancelled"); + } + + downloadAndSaveCoverArt(musicService); + + if (save) { + Util.atomicCopy(partialFile, saveFile); + mediaStoreService.saveInMediaStore(DownloadFile.this); + } else { + Util.atomicCopy(partialFile, completeFile); + } + + } catch (Exception x) { + Util.close(out); + Util.delete(completeFile); + Util.delete(saveFile); + if (!isCancelled()) { + failed = true; + Log.w(TAG, "Failed to download '" + song + "'.", x); + } + + } finally { + Util.close(in); + Util.close(out); + if (wakeLock != null) { + wakeLock.release(); + Log.i(TAG, "Released wake lock " + wakeLock); + } + new CacheCleaner(context, DownloadServiceImpl.getInstance()).clean(); + } + } + + @Override + public String toString() { + return "DownloadTask (" + song + ")"; + } + + private void downloadAndSaveCoverArt(MusicService musicService) throws Exception { + try { + if (song.getCoverArt() != null) { + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + int size = Math.min(metrics.widthPixels, metrics.heightPixels); + musicService.getCoverArt(context, song, size, true, null); + } + } catch (Exception x) { + Log.e(TAG, "Failed to get cover art.", x); + } + } + + private long copy(final InputStream in, OutputStream out) throws IOException, InterruptedException { + + // Start a thread that will close the input stream if the task is + // cancelled, thus causing the copy() method to return. + new Thread() { + @Override + public void run() { + while (true) { + Util.sleepQuietly(3000L); + if (isCancelled()) { + Util.close(in); + return; + } + if (!isRunning()) { + return; + } + } + } + }.start(); + + byte[] buffer = new byte[1024 * 16]; + long count = 0; + int n; + long lastLog = System.currentTimeMillis(); + + while (!isCancelled() && (n = in.read(buffer)) != -1) { + out.write(buffer, 0, n); + count += n; + + long now = System.currentTimeMillis(); + if (now - lastLog > 3000L) { // Only every so often. + Log.i(TAG, "Downloaded " + Util.formatBytes(count) + " of " + song); + lastLog = now; + } + } + return count; + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java new file mode 100644 index 00000000..b136bdbc --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadService.java @@ -0,0 +1,112 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.util.List; + +import net.sourceforge.subsonic.androidapp.audiofx.EqualizerController; +import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.PlayerState; +import net.sourceforge.subsonic.androidapp.domain.RepeatMode; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public interface DownloadService { + + void download(List songs, boolean save, boolean autoplay, boolean playNext); + + void setShufflePlayEnabled(boolean enabled); + + boolean isShufflePlayEnabled(); + + void shuffle(); + + RepeatMode getRepeatMode(); + + void setRepeatMode(RepeatMode repeatMode); + + boolean getKeepScreenOn(); + + void setKeepScreenOn(boolean screenOn); + + boolean getShowVisualization(); + + void setShowVisualization(boolean showVisualization); + + void clear(); + + void clearIncomplete(); + + int size(); + + void remove(DownloadFile downloadFile); + + List getDownloads(); + + int getCurrentPlayingIndex(); + + DownloadFile getCurrentPlaying(); + + DownloadFile getCurrentDownloading(); + + void play(int index); + + void seekTo(int position); + + void previous(); + + void next(); + + void pause(); + + void start(); + + void reset(); + + PlayerState getPlayerState(); + + int getPlayerPosition(); + + int getPlayerDuration(); + + void delete(List songs); + + void unpin(List songs); + + DownloadFile forSong(MusicDirectory.Entry song); + + long getDownloadListUpdateRevision(); + + void setSuggestedPlaylistName(String name); + + String getSuggestedPlaylistName(); + + EqualizerController getEqualizerController(); + + VisualizerController getVisualizerController(); + + boolean isJukeboxEnabled(); + + void setJukeboxEnabled(boolean b); + + void adjustJukeboxVolume(boolean up); +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java new file mode 100644 index 00000000..2e668fea --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceImpl.java @@ -0,0 +1,930 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.audiofx.EqualizerController; +import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.PlayerState; +import net.sourceforge.subsonic.androidapp.domain.RepeatMode; +import net.sourceforge.subsonic.androidapp.util.CancellableTask; +import net.sourceforge.subsonic.androidapp.util.LRUCache; +import net.sourceforge.subsonic.androidapp.util.ShufflePlayBuffer; +import net.sourceforge.subsonic.androidapp.util.SimpleServiceBinder; +import net.sourceforge.subsonic.androidapp.util.Util; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import static net.sourceforge.subsonic.androidapp.domain.PlayerState.*; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class DownloadServiceImpl extends Service implements DownloadService { + + private static final String TAG = DownloadServiceImpl.class.getSimpleName(); + + public static final String CMD_PLAY = "net.sourceforge.subsonic.androidapp.CMD_PLAY"; + public static final String CMD_TOGGLEPAUSE = "net.sourceforge.subsonic.androidapp.CMD_TOGGLEPAUSE"; + public static final String CMD_PAUSE = "net.sourceforge.subsonic.androidapp.CMD_PAUSE"; + public static final String CMD_STOP = "net.sourceforge.subsonic.androidapp.CMD_STOP"; + public static final String CMD_PREVIOUS = "net.sourceforge.subsonic.androidapp.CMD_PREVIOUS"; + public static final String CMD_NEXT = "net.sourceforge.subsonic.androidapp.CMD_NEXT"; + + private final IBinder binder = new SimpleServiceBinder(this); + private MediaPlayer mediaPlayer; + private final List downloadList = new ArrayList(); + private final Handler handler = new Handler(); + private final DownloadServiceLifecycleSupport lifecycleSupport = new DownloadServiceLifecycleSupport(this); + private final ShufflePlayBuffer shufflePlayBuffer = new ShufflePlayBuffer(this); + + private final LRUCache downloadFileCache = new LRUCache(100); + private final List cleanupCandidates = new ArrayList(); + private final Scrobbler scrobbler = new Scrobbler(); + private final JukeboxService jukeboxService = new JukeboxService(this); + private DownloadFile currentPlaying; + private DownloadFile currentDownloading; + private CancellableTask bufferTask; + private PlayerState playerState = IDLE; + private boolean shufflePlay; + private long revision; + private static DownloadService instance; + private String suggestedPlaylistName; + private PowerManager.WakeLock wakeLock; + private boolean keepScreenOn = false; + + private static boolean equalizerAvailable; + private static boolean visualizerAvailable; + private EqualizerController equalizerController; + private VisualizerController visualizerController; + private boolean showVisualization; + private boolean jukeboxEnabled; + + static { + try { + EqualizerController.checkAvailable(); + equalizerAvailable = true; + } catch (Throwable t) { + equalizerAvailable = false; + } + } + static { + try { + VisualizerController.checkAvailable(); + visualizerAvailable = true; + } catch (Throwable t) { + visualizerAvailable = false; + } + } + + @Override + public void onCreate() { + super.onCreate(); + + mediaPlayer = new MediaPlayer(); + mediaPlayer.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); + + mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mediaPlayer, int what, int more) { + handleError(new Exception("MediaPlayer error: " + what + " (" + more + ")")); + return false; + } + }); + + if (equalizerAvailable) { + equalizerController = new EqualizerController(this, mediaPlayer); + if (!equalizerController.isAvailable()) { + equalizerController = null; + } else { + equalizerController.loadSettings(); + } + } + if (visualizerAvailable) { + visualizerController = new VisualizerController(this, mediaPlayer); + if (!visualizerController.isAvailable()) { + visualizerController = null; + } + } + + PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, this.getClass().getName()); + wakeLock.setReferenceCounted(false); + + instance = this; + lifecycleSupport.onCreate(); + } + + @Override + public void onStart(Intent intent, int startId) { + super.onStart(intent, startId); + lifecycleSupport.onStart(intent); + } + + @Override + public void onDestroy() { + super.onDestroy(); + lifecycleSupport.onDestroy(); + mediaPlayer.release(); + shufflePlayBuffer.shutdown(); + if (equalizerController != null) { + equalizerController.release(); + } + if (visualizerController != null) { + visualizerController.release(); + } + + instance = null; + } + + public static DownloadService getInstance() { + return instance; + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public synchronized void download(List songs, boolean save, boolean autoplay, boolean playNext) { + shufflePlay = false; + int offset = 1; + + if (songs.isEmpty()) { + return; + } + if (playNext) { + if (autoplay && getCurrentPlayingIndex() >= 0) { + offset = 0; + } + for (MusicDirectory.Entry song : songs) { + DownloadFile downloadFile = new DownloadFile(this, song, save); + downloadList.add(getCurrentPlayingIndex() + offset, downloadFile); + offset++; + } + revision++; + } else { + for (MusicDirectory.Entry song : songs) { + DownloadFile downloadFile = new DownloadFile(this, song, save); + downloadList.add(downloadFile); + } + revision++; + } + updateJukeboxPlaylist(); + + if (autoplay) { + play(0); + } else { + if (currentPlaying == null) { + currentPlaying = downloadList.get(0); + } + checkDownloads(); + } + lifecycleSupport.serializeDownloadQueue(); + } + + private void updateJukeboxPlaylist() { + if (jukeboxEnabled) { + jukeboxService.updatePlaylist(); + } + } + + public void restore(List songs, int currentPlayingIndex, int currentPlayingPosition) { + download(songs, false, false, false); + if (currentPlayingIndex != -1) { + play(currentPlayingIndex, false); + if (currentPlaying.isCompleteFileAvailable()) { + doPlay(currentPlaying, currentPlayingPosition, false); + } + } + } + + @Override + public synchronized void setShufflePlayEnabled(boolean enabled) { + if (shufflePlay == enabled) { + return; + } + + shufflePlay = enabled; + if (shufflePlay) { + clear(); + checkDownloads(); + } + } + + @Override + public synchronized boolean isShufflePlayEnabled() { + return shufflePlay; + } + + @Override + public synchronized void shuffle() { + Collections.shuffle(downloadList); + if (currentPlaying != null) { + downloadList.remove(getCurrentPlayingIndex()); + downloadList.add(0, currentPlaying); + } + revision++; + lifecycleSupport.serializeDownloadQueue(); + updateJukeboxPlaylist(); + } + + @Override + public RepeatMode getRepeatMode() { + return Util.getRepeatMode(this); + } + + @Override + public void setRepeatMode(RepeatMode repeatMode) { + Util.setRepeatMode(this, repeatMode); + } + + @Override + public boolean getKeepScreenOn() { + return keepScreenOn; + } + + @Override + public void setKeepScreenOn(boolean keepScreenOn) { + this.keepScreenOn = keepScreenOn; + } + + @Override + public boolean getShowVisualization() { + return showVisualization; + } + + @Override + public void setShowVisualization(boolean showVisualization) { + this.showVisualization = showVisualization; + } + + @Override + public synchronized DownloadFile forSong(MusicDirectory.Entry song) { + for (DownloadFile downloadFile : downloadList) { + if (downloadFile.getSong().equals(song)) { + return downloadFile; + } + } + + DownloadFile downloadFile = downloadFileCache.get(song); + if (downloadFile == null) { + downloadFile = new DownloadFile(this, song, false); + downloadFileCache.put(song, downloadFile); + } + return downloadFile; + } + + @Override + public synchronized void clear() { + clear(true); + } + + @Override + public synchronized void clearIncomplete() { + reset(); + Iterator iterator = downloadList.iterator(); + while (iterator.hasNext()) { + DownloadFile downloadFile = iterator.next(); + if (!downloadFile.isCompleteFileAvailable()) { + iterator.remove(); + } + } + lifecycleSupport.serializeDownloadQueue(); + updateJukeboxPlaylist(); + } + + @Override + public synchronized int size() { + return downloadList.size(); + } + + public synchronized void clear(boolean serialize) { + reset(); + downloadList.clear(); + revision++; + if (currentDownloading != null) { + currentDownloading.cancelDownload(); + currentDownloading = null; + } + setCurrentPlaying(null, false); + + if (serialize) { + lifecycleSupport.serializeDownloadQueue(); + } + updateJukeboxPlaylist(); + } + + @Override + public synchronized void remove(DownloadFile downloadFile) { + if (downloadFile == currentDownloading) { + currentDownloading.cancelDownload(); + currentDownloading = null; + } + if (downloadFile == currentPlaying) { + reset(); + setCurrentPlaying(null, false); + } + downloadList.remove(downloadFile); + revision++; + lifecycleSupport.serializeDownloadQueue(); + updateJukeboxPlaylist(); + } + + @Override + public synchronized void delete(List songs) { + for (MusicDirectory.Entry song : songs) { + forSong(song).delete(); + } + } + + @Override + public synchronized void unpin(List songs) { + for (MusicDirectory.Entry song : songs) { + forSong(song).unpin(); + } + } + + synchronized void setCurrentPlaying(int currentPlayingIndex, boolean showNotification) { + try { + setCurrentPlaying(downloadList.get(currentPlayingIndex), showNotification); + } catch (IndexOutOfBoundsException x) { + // Ignored + } + } + + synchronized void setCurrentPlaying(DownloadFile currentPlaying, boolean showNotification) { + this.currentPlaying = currentPlaying; + + if (currentPlaying != null) { + Util.broadcastNewTrackInfo(this, currentPlaying.getSong()); + } else { + Util.broadcastNewTrackInfo(this, null); + } + + if (currentPlaying != null && showNotification) { + Util.showPlayingNotification(this, this, handler, currentPlaying.getSong()); + } else { + Util.hidePlayingNotification(this, this, handler); + } + } + + @Override + public synchronized int getCurrentPlayingIndex() { + return downloadList.indexOf(currentPlaying); + } + + @Override + public DownloadFile getCurrentPlaying() { + return currentPlaying; + } + + @Override + public DownloadFile getCurrentDownloading() { + return currentDownloading; + } + + @Override + public synchronized List getDownloads() { + return new ArrayList(downloadList); + } + + /** Plays either the current song (resume) or the first/next one in queue. */ + public synchronized void play() + { + int current = getCurrentPlayingIndex(); + if (current == -1) { + play(0); + } else { + play(current); + } + } + + @Override + public synchronized void play(int index) { + play(index, true); + } + + private synchronized void play(int index, boolean start) { + if (index < 0 || index >= size()) { + reset(); + setCurrentPlaying(null, false); + } else { + setCurrentPlaying(index, start); + checkDownloads(); + if (start) { + if (jukeboxEnabled) { + jukeboxService.skip(getCurrentPlayingIndex(), 0); + setPlayerState(STARTED); + } else { + bufferAndPlay(); + } + } + } + } + + /** Plays or resumes the playback, depending on the current player state. */ + public synchronized void togglePlayPause() + { + if (playerState == PAUSED || playerState == COMPLETED) { + start(); + } else if (playerState == STOPPED || playerState == IDLE) { + play(); + } else if (playerState == STARTED) { + pause(); + } + } + + @Override + public synchronized void seekTo(int position) { + try { + if (jukeboxEnabled) { + jukeboxService.skip(getCurrentPlayingIndex(), position / 1000); + } else { + mediaPlayer.seekTo(position); + } + } catch (Exception x) { + handleError(x); + } + } + + @Override + public synchronized void previous() { + int index = getCurrentPlayingIndex(); + if (index == -1) { + return; + } + + // Restart song if played more than five seconds. + if (getPlayerPosition() > 5000 || index == 0) { + play(index); + } else { + play(index - 1); + } + } + + @Override + public synchronized void next() { + int index = getCurrentPlayingIndex(); + if (index != -1) { + play(index + 1); + } + } + + private void onSongCompleted() { + int index = getCurrentPlayingIndex(); + if (index != -1) { + switch (getRepeatMode()) { + case OFF: + play(index + 1); + break; + case ALL: + play((index + 1) % size()); + break; + case SINGLE: + play(index); + break; + default: + break; + } + } + } + + @Override + public synchronized void pause() { + try { + if (playerState == STARTED) { + if (jukeboxEnabled) { + jukeboxService.stop(); + } else { + mediaPlayer.pause(); + } + setPlayerState(PAUSED); + } + } catch (Exception x) { + handleError(x); + } + } + + @Override + public synchronized void start() { + try { + if (jukeboxEnabled) { + jukeboxService.start(); + } else { + mediaPlayer.start(); + } + setPlayerState(STARTED); + } catch (Exception x) { + handleError(x); + } + } + + @Override + public synchronized void reset() { + if (bufferTask != null) { + bufferTask.cancel(); + } + try { + mediaPlayer.reset(); + setPlayerState(IDLE); + } catch (Exception x) { + handleError(x); + } + } + + @Override + public synchronized int getPlayerPosition() { + try { + if (playerState == IDLE || playerState == DOWNLOADING || playerState == PREPARING) { + return 0; + } + if (jukeboxEnabled) { + return jukeboxService.getPositionSeconds() * 1000; + } else { + return mediaPlayer.getCurrentPosition(); + } + } catch (Exception x) { + handleError(x); + return 0; + } + } + + @Override + public synchronized int getPlayerDuration() { + if (currentPlaying != null) { + Integer duration = currentPlaying.getSong().getDuration(); + if (duration != null) { + return duration * 1000; + } + } + if (playerState != IDLE && playerState != DOWNLOADING && playerState != PlayerState.PREPARING) { + try { + return mediaPlayer.getDuration(); + } catch (Exception x) { + handleError(x); + } + } + return 0; + } + + @Override + public PlayerState getPlayerState() { + return playerState; + } + + synchronized void setPlayerState(PlayerState playerState) { + Log.i(TAG, this.playerState.name() + " -> " + playerState.name() + " (" + currentPlaying + ")"); + + if (playerState == PAUSED) { + lifecycleSupport.serializeDownloadQueue(); + } + + boolean show = this.playerState == PAUSED && playerState == PlayerState.STARTED; + boolean hide = this.playerState == STARTED && playerState == PlayerState.PAUSED; + Util.broadcastPlaybackStatusChange(this, playerState); + + this.playerState = playerState; + if (show) { + Util.showPlayingNotification(this, this, handler, currentPlaying.getSong()); + } else if (hide) { + Util.hidePlayingNotification(this, this, handler); + } + + if (playerState == STARTED) { + scrobbler.scrobble(this, currentPlaying, false); + } else if (playerState == COMPLETED) { + scrobbler.scrobble(this, currentPlaying, true); + } + } + + @Override + public void setSuggestedPlaylistName(String name) { + this.suggestedPlaylistName = name; + } + + @Override + public String getSuggestedPlaylistName() { + return suggestedPlaylistName; + } + + @Override + public EqualizerController getEqualizerController() { + return equalizerController; + } + + @Override + public VisualizerController getVisualizerController() { + return visualizerController; + } + + @Override + public boolean isJukeboxEnabled() { + return jukeboxEnabled; + } + + @Override + public void setJukeboxEnabled(boolean jukeboxEnabled) { + this.jukeboxEnabled = jukeboxEnabled; + jukeboxService.setEnabled(jukeboxEnabled); + if (jukeboxEnabled) { + reset(); + + // Cancel current download, if necessary. + if (currentDownloading != null) { + currentDownloading.cancelDownload(); + } + } + } + + @Override + public void adjustJukeboxVolume(boolean up) { + jukeboxService.adjustVolume(up); + } + + private synchronized void bufferAndPlay() { + reset(); + + bufferTask = new BufferTask(currentPlaying, 0); + bufferTask.start(); + } + + private synchronized void doPlay(final DownloadFile downloadFile, int position, boolean start) { + try { + final File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteFile() : downloadFile.getPartialFile(); + downloadFile.updateModificationDate(); + mediaPlayer.setOnCompletionListener(null); + mediaPlayer.reset(); + setPlayerState(IDLE); + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mediaPlayer.setDataSource(file.getPath()); + setPlayerState(PREPARING); + mediaPlayer.prepare(); + setPlayerState(PREPARED); + + mediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer mediaPlayer) { + + // Acquire a temporary wakelock, since when we return from + // this callback the MediaPlayer will release its wakelock + // and allow the device to go to sleep. + wakeLock.acquire(60000); + + setPlayerState(COMPLETED); + + // If COMPLETED and not playing partial file, we are *really" finished + // with the song and can move on to the next. + if (!file.equals(downloadFile.getPartialFile())) { + onSongCompleted(); + return; + } + + // If file is not completely downloaded, restart the playback from the current position. + int pos = mediaPlayer.getCurrentPosition(); + synchronized (DownloadServiceImpl.this) { + + // Work-around for apparent bug on certain phones: If close (less than ten seconds) to the end + // of the song, skip to the next rather than restarting it. + Integer duration = downloadFile.getSong().getDuration() == null ? null : downloadFile.getSong().getDuration() * 1000; + if (duration != null) { + if (Math.abs(duration - pos) < 10000) { + Log.i(TAG, "Skipping restart from " + pos + " of " + duration); + onSongCompleted(); + return; + } + } + + Log.i(TAG, "Requesting restart from " + pos + " of " + duration); + reset(); + bufferTask = new BufferTask(downloadFile, pos); + bufferTask.start(); + } + } + }); + + if (position != 0) { + Log.i(TAG, "Restarting player from position " + position); + mediaPlayer.seekTo(position); + } + + if (start) { + mediaPlayer.start(); + setPlayerState(STARTED); + } else { + setPlayerState(PAUSED); + } + lifecycleSupport.serializeDownloadQueue(); + + } catch (Exception x) { + handleError(x); + } + } + + private void handleError(Exception x) { + Log.w(TAG, "Media player error: " + x, x); + mediaPlayer.reset(); + setPlayerState(IDLE); + } + + protected synchronized void checkDownloads() { + + if (!Util.isExternalStoragePresent() || !lifecycleSupport.isExternalStorageAvailable()) { + return; + } + + if (shufflePlay) { + checkShufflePlay(); + } + + if (jukeboxEnabled || !Util.isNetworkConnected(this)) { + return; + } + + if (downloadList.isEmpty()) { + return; + } + + // Need to download current playing? + if (currentPlaying != null && + currentPlaying != currentDownloading && + !currentPlaying.isCompleteFileAvailable()) { + + // Cancel current download, if necessary. + if (currentDownloading != null) { + currentDownloading.cancelDownload(); + } + + currentDownloading = currentPlaying; + currentDownloading.download(); + cleanupCandidates.add(currentDownloading); + } + + // Find a suitable target for download. + else if (currentDownloading == null || currentDownloading.isWorkDone() || currentDownloading.isFailed()) { + + int n = size(); + if (n == 0) { + return; + } + + int preloaded = 0; + + int start = currentPlaying == null ? 0 : getCurrentPlayingIndex(); + int i = start; + do { + DownloadFile downloadFile = downloadList.get(i); + if (!downloadFile.isWorkDone()) { + if (downloadFile.shouldSave() || preloaded < Util.getPreloadCount(this)) { + currentDownloading = downloadFile; + currentDownloading.download(); + cleanupCandidates.add(currentDownloading); + break; + } + } else if (currentPlaying != downloadFile) { + preloaded++; + } + + i = (i + 1) % n; + } while (i != start); + } + + // Delete obsolete .partial and .complete files. + cleanup(); + } + + private synchronized void checkShufflePlay() { + + final int listSize = 20; + boolean wasEmpty = downloadList.isEmpty(); + + long revisionBefore = revision; + + // First, ensure that list is at least 20 songs long. + int size = size(); + if (size < listSize) { + for (MusicDirectory.Entry song : shufflePlayBuffer.get(listSize - size)) { + DownloadFile downloadFile = new DownloadFile(this, song, false); + downloadList.add(downloadFile); + revision++; + } + } + + int currIndex = currentPlaying == null ? 0 : getCurrentPlayingIndex(); + + // Only shift playlist if playing song #5 or later. + if (currIndex > 4) { + int songsToShift = currIndex - 2; + for (MusicDirectory.Entry song : shufflePlayBuffer.get(songsToShift)) { + downloadList.add(new DownloadFile(this, song, false)); + downloadList.get(0).cancelDownload(); + downloadList.remove(0); + revision++; + } + } + + if (revisionBefore != revision) { + updateJukeboxPlaylist(); + } + + if (wasEmpty && !downloadList.isEmpty()) { + play(0); + } + } + + public long getDownloadListUpdateRevision() { + return revision; + } + + private synchronized void cleanup() { + Iterator iterator = cleanupCandidates.iterator(); + while (iterator.hasNext()) { + DownloadFile downloadFile = iterator.next(); + if (downloadFile != currentPlaying && downloadFile != currentDownloading) { + if (downloadFile.cleanup()) { + iterator.remove(); + } + } + } + } + + private class BufferTask extends CancellableTask { + + private static final int BUFFER_LENGTH_SECONDS = 5; + + private final DownloadFile downloadFile; + private final int position; + private final long expectedFileSize; + private final File partialFile; + + public BufferTask(DownloadFile downloadFile, int position) { + this.downloadFile = downloadFile; + this.position = position; + partialFile = downloadFile.getPartialFile(); + + // Calculate roughly how many bytes BUFFER_LENGTH_SECONDS corresponds to. + int bitRate = downloadFile.getBitRate(); + long byteCount = Math.max(100000, bitRate * 1024 / 8 * BUFFER_LENGTH_SECONDS); + + // Find out how large the file should grow before resuming playback. + expectedFileSize = partialFile.length() + byteCount; + } + + @Override + public void execute() { + setPlayerState(DOWNLOADING); + + while (!bufferComplete()) { + Util.sleepQuietly(1000L); + if (isCancelled()) { + return; + } + } + doPlay(downloadFile, position, true); + } + + private boolean bufferComplete() { + boolean completeFileAvailable = downloadFile.isCompleteFileAvailable(); + long size = partialFile.length(); + + Log.i(TAG, "Buffering " + partialFile + " (" + size + "/" + expectedFileSize + ", " + completeFileAvailable + ")"); + return completeFileAvailable || size >= expectedFileSize; + } + + @Override + public String toString() { + return "BufferTask (" + downloadFile + ")"; + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java new file mode 100644 index 00000000..2010a4a1 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/DownloadServiceLifecycleSupport.java @@ -0,0 +1,266 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.util.Log; +import android.view.KeyEvent; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.PlayerState; +import net.sourceforge.subsonic.androidapp.util.CacheCleaner; +import net.sourceforge.subsonic.androidapp.util.FileUtil; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + */ +public class DownloadServiceLifecycleSupport { + + private static final String TAG = DownloadServiceLifecycleSupport.class.getSimpleName(); + private static final String FILENAME_DOWNLOADS_SER = "downloadstate.ser"; + + private final DownloadServiceImpl downloadService; + private ScheduledExecutorService executorService; + private BroadcastReceiver headsetEventReceiver; + private BroadcastReceiver ejectEventReceiver; + private PhoneStateListener phoneStateListener; + private boolean externalStorageAvailable= true; + + /** + * This receiver manages the intent that could come from other applications. + */ + private BroadcastReceiver intentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + Log.i(TAG, "intentReceiver.onReceive: " + action); + if (DownloadServiceImpl.CMD_PLAY.equals(action)) { + downloadService.play(); + } else if (DownloadServiceImpl.CMD_NEXT.equals(action)) { + downloadService.next(); + } else if (DownloadServiceImpl.CMD_PREVIOUS.equals(action)) { + downloadService.previous(); + } else if (DownloadServiceImpl.CMD_TOGGLEPAUSE.equals(action)) { + downloadService.togglePlayPause(); + } else if (DownloadServiceImpl.CMD_PAUSE.equals(action)) { + downloadService.pause(); + } else if (DownloadServiceImpl.CMD_STOP.equals(action)) { + downloadService.pause(); + downloadService.seekTo(0); + } + } + }; + + + public DownloadServiceLifecycleSupport(DownloadServiceImpl downloadService) { + this.downloadService = downloadService; + } + + public void onCreate() { + Runnable downloadChecker = new Runnable() { + @Override + public void run() { + try { + downloadService.checkDownloads(); + } catch (Throwable x) { + Log.e(TAG, "checkDownloads() failed.", x); + } + } + }; + + executorService = Executors.newScheduledThreadPool(2); + executorService.scheduleWithFixedDelay(downloadChecker, 5, 5, TimeUnit.SECONDS); + + // Pause when headset is unplugged. + headsetEventReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "Headset event for: " + intent.getExtras().get("name")); + if (intent.getExtras().getInt("state") == 0) { + downloadService.pause(); + } + } + }; + downloadService.registerReceiver(headsetEventReceiver, new IntentFilter(Intent.ACTION_HEADSET_PLUG)); + + // Stop when SD card is ejected. + ejectEventReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction()); + if (!externalStorageAvailable) { + Log.i(TAG, "External media is ejecting. Stopping playback."); + downloadService.reset(); + } else { + Log.i(TAG, "External media is available."); + } + } + }; + IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT); + ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED); + ejectFilter.addDataScheme("file"); + downloadService.registerReceiver(ejectEventReceiver, ejectFilter); + + // React to media buttons. + Util.registerMediaButtonEventReceiver(downloadService); + + // Pause temporarily on incoming phone calls. + phoneStateListener = new MyPhoneStateListener(); + TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + + // Register the handler for outside intents. + IntentFilter commandFilter = new IntentFilter(); + commandFilter.addAction(DownloadServiceImpl.CMD_PLAY); + commandFilter.addAction(DownloadServiceImpl.CMD_TOGGLEPAUSE); + commandFilter.addAction(DownloadServiceImpl.CMD_PAUSE); + commandFilter.addAction(DownloadServiceImpl.CMD_STOP); + commandFilter.addAction(DownloadServiceImpl.CMD_PREVIOUS); + commandFilter.addAction(DownloadServiceImpl.CMD_NEXT); + downloadService.registerReceiver(intentReceiver, commandFilter); + + deserializeDownloadQueue(); + + new CacheCleaner(downloadService, downloadService).clean(); + } + + public void onStart(Intent intent) { + if (intent != null && intent.getExtras() != null) { + KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); + if (event != null) { + handleKeyEvent(event); + } + } + } + + public void onDestroy() { + executorService.shutdown(); + serializeDownloadQueue(); + downloadService.clear(false); + downloadService.unregisterReceiver(ejectEventReceiver); + downloadService.unregisterReceiver(headsetEventReceiver); + downloadService.unregisterReceiver(intentReceiver); + + TelephonyManager telephonyManager = (TelephonyManager) downloadService.getSystemService(Context.TELEPHONY_SERVICE); + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); + } + + public boolean isExternalStorageAvailable() { + return externalStorageAvailable; + } + + public void serializeDownloadQueue() { + State state = new State(); + for (DownloadFile downloadFile : downloadService.getDownloads()) { + state.songs.add(downloadFile.getSong()); + } + state.currentPlayingIndex = downloadService.getCurrentPlayingIndex(); + state.currentPlayingPosition = downloadService.getPlayerPosition(); + + Log.i(TAG, "Serialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); + FileUtil.serialize(downloadService, state, FILENAME_DOWNLOADS_SER); + } + + private void deserializeDownloadQueue() { + State state = FileUtil.deserialize(downloadService, FILENAME_DOWNLOADS_SER); + if (state == null) { + return; + } + Log.i(TAG, "Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); + downloadService.restore(state.songs, state.currentPlayingIndex, state.currentPlayingPosition); + + // Work-around: Serialize again, as the restore() method creates a serialization without current playing info. + serializeDownloadQueue(); + } + + private void handleKeyEvent(KeyEvent event) { + if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) { + return; + } + + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_HEADSETHOOK: + downloadService.togglePlayPause(); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + downloadService.previous(); + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + if (downloadService.getCurrentPlayingIndex() < downloadService.size() - 1) { + downloadService.next(); + } + break; + case KeyEvent.KEYCODE_MEDIA_STOP: + downloadService.reset(); + break; + default: + break; + } + } + + /** + * Logic taken from packages/apps/Music. Will pause when an incoming + * call rings or if a call (incoming or outgoing) is connected. + */ + private class MyPhoneStateListener extends PhoneStateListener { + private boolean resumeAfterCall; + + @Override + public void onCallStateChanged(int state, String incomingNumber) { + switch (state) { + case TelephonyManager.CALL_STATE_RINGING: + case TelephonyManager.CALL_STATE_OFFHOOK: + if (downloadService.getPlayerState() == PlayerState.STARTED) { + resumeAfterCall = true; + downloadService.pause(); + } + break; + case TelephonyManager.CALL_STATE_IDLE: + if (resumeAfterCall) { + resumeAfterCall = false; + downloadService.start(); + } + break; + default: + break; + } + } + } + + private static class State implements Serializable { + private static final long serialVersionUID = -6346438781062572270L; + + private List songs = new ArrayList(); + private int currentPlayingIndex; + private int currentPlayingPosition; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java new file mode 100644 index 00000000..e3145f4e --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/JukeboxService.java @@ -0,0 +1,356 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import android.content.Context; +import android.os.Handler; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.Toast; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; +import net.sourceforge.subsonic.androidapp.domain.PlayerState; +import net.sourceforge.subsonic.androidapp.service.parser.SubsonicRESTException; +import net.sourceforge.subsonic.androidapp.util.Util; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Provides an asynchronous interface to the remote jukebox on the Subsonic server. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class JukeboxService { + + private static final String TAG = JukeboxService.class.getSimpleName(); + private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L; + + private final Handler handler = new Handler(); + private final TaskQueue tasks = new TaskQueue(); + private final DownloadServiceImpl downloadService; + private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + private ScheduledFuture statusUpdateFuture; + private final AtomicLong timeOfLastUpdate = new AtomicLong(); + private JukeboxStatus jukeboxStatus; + private float gain = 0.5f; + private VolumeToast volumeToast; + + // TODO: Report warning if queue fills up. + // TODO: Create shutdown method? + // TODO: Disable repeat. + // TODO: Persist RC state? + // TODO: Minimize status updates. + + public JukeboxService(DownloadServiceImpl downloadService) { + this.downloadService = downloadService; + new Thread() { + @Override + public void run() { + processTasks(); + } + }.start(); + } + + private synchronized void startStatusUpdate() { + stopStatusUpdate(); + Runnable updateTask = new Runnable() { + @Override + public void run() { + tasks.remove(GetStatus.class); + tasks.add(new GetStatus()); + } + }; + statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, + STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + + private synchronized void stopStatusUpdate() { + if (statusUpdateFuture != null) { + statusUpdateFuture.cancel(false); + statusUpdateFuture = null; + } + } + + private void processTasks() { + while (true) { + JukeboxTask task = null; + try { + task = tasks.take(); + JukeboxStatus status = task.execute(); + onStatusUpdate(status); + } catch (Throwable x) { + onError(task, x); + } + } + } + + private void onStatusUpdate(JukeboxStatus jukeboxStatus) { + timeOfLastUpdate.set(System.currentTimeMillis()); + this.jukeboxStatus = jukeboxStatus; + + // Track change? + Integer index = jukeboxStatus.getCurrentPlayingIndex(); + if (index != null && index != -1 && index != downloadService.getCurrentPlayingIndex()) { + downloadService.setCurrentPlaying(index, true); + } + } + + private void onError(JukeboxTask task, Throwable x) { + if (x instanceof ServerTooOldException && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_server_too_old); + } else if (x instanceof OfflineException && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_offline); + } else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop)) { + disableJukeboxOnError(x, R.string.download_jukebox_not_authorized); + } else { + Log.e(TAG, "Failed to process jukebox task: " + x, x); + } + } + + private void disableJukeboxOnError(Throwable x, final int resourceId) { + Log.w(TAG, x.toString()); + handler.post(new Runnable() { + @Override + public void run() { + Util.toast(downloadService, resourceId, false); + } + }); + downloadService.setJukeboxEnabled(false); + } + + public void updatePlaylist() { + tasks.remove(Skip.class); + tasks.remove(Stop.class); + tasks.remove(Start.class); + + List ids = new ArrayList(); + for (DownloadFile file : downloadService.getDownloads()) { + ids.add(file.getSong().getId()); + } + tasks.add(new SetPlaylist(ids)); + } + + public void skip(final int index, final int offsetSeconds) { + tasks.remove(Skip.class); + tasks.remove(Stop.class); + tasks.remove(Start.class); + + startStatusUpdate(); + if (jukeboxStatus != null) { + jukeboxStatus.setPositionSeconds(offsetSeconds); + } + tasks.add(new Skip(index, offsetSeconds)); + downloadService.setPlayerState(PlayerState.STARTED); + } + + public void stop() { + tasks.remove(Stop.class); + tasks.remove(Start.class); + + stopStatusUpdate(); + tasks.add(new Stop()); + } + + public void start() { + tasks.remove(Stop.class); + tasks.remove(Start.class); + + startStatusUpdate(); + tasks.add(new Start()); + } + + public synchronized void adjustVolume(boolean up) { + float delta = up ? 0.1f : -0.1f; + gain += delta; + gain = Math.max(gain, 0.0f); + gain = Math.min(gain, 1.0f); + + tasks.remove(SetGain.class); + tasks.add(new SetGain(gain)); + + if (volumeToast == null) { + volumeToast = new VolumeToast(downloadService); + } + volumeToast.setVolume(gain); + } + + private MusicService getMusicService() { + return MusicServiceFactory.getMusicService(downloadService); + } + + public int getPositionSeconds() { + if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0) { + return 0; + } + + if (jukeboxStatus.isPlaying()) { + int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L); + return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate; + } + + return jukeboxStatus.getPositionSeconds(); + } + + public void setEnabled(boolean enabled) { + tasks.clear(); + if (enabled) { + updatePlaylist(); + } + stop(); + downloadService.setPlayerState(PlayerState.IDLE); + } + + private static class TaskQueue { + + private final LinkedBlockingQueue queue = new LinkedBlockingQueue(); + + void add(JukeboxTask jukeboxTask) { + queue.add(jukeboxTask); + } + + JukeboxTask take() throws InterruptedException { + return queue.take(); + } + + void remove(Class clazz) { + try { + Iterator iterator = queue.iterator(); + while (iterator.hasNext()) { + JukeboxTask task = iterator.next(); + if (clazz.equals(task.getClass())) { + iterator.remove(); + } + } + } catch (Throwable x) { + Log.w(TAG, "Failed to clean-up task queue.", x); + } + } + + void clear() { + queue.clear(); + } + } + + private abstract class JukeboxTask { + + abstract JukeboxStatus execute() throws Exception; + + @Override + public String toString() { + return getClass().getSimpleName(); + } + } + + private class GetStatus extends JukeboxTask { + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().getJukeboxStatus(downloadService, null); + } + } + + private class SetPlaylist extends JukeboxTask { + + private final List ids; + + SetPlaylist(List ids) { + this.ids = ids; + } + + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().updateJukeboxPlaylist(ids, downloadService, null); + } + } + + private class Skip extends JukeboxTask { + private final int index; + private final int offsetSeconds; + + Skip(int index, int offsetSeconds) { + this.index = index; + this.offsetSeconds = offsetSeconds; + } + + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().skipJukebox(index, offsetSeconds, downloadService, null); + } + } + + private class Stop extends JukeboxTask { + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().stopJukebox(downloadService, null); + } + } + + private class Start extends JukeboxTask { + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().startJukebox(downloadService, null); + } + } + + private class SetGain extends JukeboxTask { + + private final float gain; + + private SetGain(float gain) { + this.gain = gain; + } + + @Override + JukeboxStatus execute() throws Exception { + return getMusicService().setJukeboxGain(gain, downloadService, null); + } + } + + private static class VolumeToast extends Toast { + + private final ProgressBar progressBar; + + public VolumeToast(Context context) { + super(context); + setDuration(Toast.LENGTH_SHORT); + LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.jukebox_volume, null); + progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar); + + setView(view); + setGravity(Gravity.TOP, 0, 0); + } + + public void setVolume(float volume) { + progressBar.setProgress(Math.round(100 * volume)); + show(); + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java new file mode 100644 index 00000000..775fa3f5 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MediaStoreService.java @@ -0,0 +1,109 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.io.File; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.MediaStore; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.FileUtil; + +/** + * @author Sindre Mehus + */ +public class MediaStoreService { + + private static final String TAG = MediaStoreService.class.getSimpleName(); + private static final Uri ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart"); + + private final Context context; + + public MediaStoreService(Context context) { + this.context = context; + } + + public void saveInMediaStore(DownloadFile downloadFile) { + MusicDirectory.Entry song = downloadFile.getSong(); + File songFile = downloadFile.getCompleteFile(); + + // Delete existing row in case the song has been downloaded before. + deleteFromMediaStore(downloadFile); + + ContentResolver contentResolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.TITLE, song.getTitle()); + values.put(MediaStore.Audio.AudioColumns.ARTIST, song.getArtist()); + values.put(MediaStore.Audio.AudioColumns.ALBUM, song.getAlbum()); + values.put(MediaStore.Audio.AudioColumns.TRACK, song.getTrack()); + values.put(MediaStore.Audio.AudioColumns.YEAR, song.getYear()); + values.put(MediaStore.MediaColumns.DATA, songFile.getAbsolutePath()); + values.put(MediaStore.MediaColumns.MIME_TYPE, song.getContentType()); + values.put(MediaStore.Audio.AudioColumns.IS_MUSIC, 1); + + Uri uri = contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values); + + // Look up album, and add cover art if found. + Cursor cursor = contentResolver.query(uri, new String[]{MediaStore.Audio.AudioColumns.ALBUM_ID}, null, null, null); + if (cursor.moveToFirst()) { + int albumId = cursor.getInt(0); + insertAlbumArt(albumId, downloadFile); + } + cursor.close(); + } + + public void deleteFromMediaStore(DownloadFile downloadFile) { + ContentResolver contentResolver = context.getContentResolver(); + MusicDirectory.Entry song = downloadFile.getSong(); + File file = downloadFile.getCompleteFile(); + + int n = contentResolver.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + MediaStore.Audio.AudioColumns.TITLE_KEY + "=? AND " + + MediaStore.MediaColumns.DATA + "=?", + new String[]{MediaStore.Audio.keyFor(song.getTitle()), file.getAbsolutePath()}); + if (n > 0) { + Log.i(TAG, "Deleting media store row for " + song); + } + } + + private void insertAlbumArt(int albumId, DownloadFile downloadFile) { + ContentResolver contentResolver = context.getContentResolver(); + + Cursor cursor = contentResolver.query(Uri.withAppendedPath(ALBUM_ART_URI, String.valueOf(albumId)), null, null, null, null); + if (!cursor.moveToFirst()) { + + // No album art found, add it. + File albumArtFile = FileUtil.getAlbumArtFile(context, downloadFile.getSong()); + if (albumArtFile.exists()) { + ContentValues values = new ContentValues(); + values.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId); + values.put(MediaStore.MediaColumns.DATA, albumArtFile.getPath()); + contentResolver.insert(ALBUM_ART_URI, values); + Log.i(TAG, "Added album art: " + albumArtFile); + } + } + cursor.close(); + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java new file mode 100644 index 00000000..2acb4c65 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicService.java @@ -0,0 +1,91 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.util.List; + +import org.apache.http.HttpResponse; + +import android.content.Context; +import android.graphics.Bitmap; +import net.sourceforge.subsonic.androidapp.domain.Indexes; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; +import net.sourceforge.subsonic.androidapp.domain.Lyrics; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.MusicFolder; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.domain.SearchCritera; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.domain.Version; +import net.sourceforge.subsonic.androidapp.util.CancellableTask; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; + +/** + * @author Sindre Mehus + */ +public interface MusicService { + + void ping(Context context, ProgressListener progressListener) throws Exception; + + boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception; + + List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception; + + List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception; + + void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception; + + Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception; + + void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception; + + MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception; + + Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception; + + HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception; + + Version getLocalVersion(Context context) throws Exception; + + Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception; + + String getVideoUrl(Context context, String id); + + JukeboxStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception; + + JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception; + + JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception; + + JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception; + + JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception; + + JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception; +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java new file mode 100644 index 00000000..552d1d32 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/MusicServiceFactory.java @@ -0,0 +1,36 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class MusicServiceFactory { + + private static final MusicService REST_MUSIC_SERVICE = new CachedMusicService(new RESTMusicService()); + private static final MusicService OFFLINE_MUSIC_SERVICE = new OfflineMusicService(); + + public static MusicService getMusicService(Context context) { + return Util.isOffline(context) ? OFFLINE_MUSIC_SERVICE : REST_MUSIC_SERVICE; + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java new file mode 100644 index 00000000..49c000bf --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineException.java @@ -0,0 +1,32 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +/** + * Thrown by service methods that are not available in offline mode. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class OfflineException extends Exception { + + public OfflineException(String message) { + super(message); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java new file mode 100644 index 00000000..6a8ad6d0 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/OfflineMusicService.java @@ -0,0 +1,244 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import net.sourceforge.subsonic.androidapp.domain.Artist; +import net.sourceforge.subsonic.androidapp.domain.Indexes; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; +import net.sourceforge.subsonic.androidapp.domain.Lyrics; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.MusicFolder; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.domain.SearchCritera; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.util.Constants; +import net.sourceforge.subsonic.androidapp.util.FileUtil; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + */ +public class OfflineMusicService extends RESTMusicService { + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + return true; + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List artists = new ArrayList(); + File root = FileUtil.getMusicDirectory(context); + for (File file : FileUtil.listFiles(root)) { + if (file.isDirectory()) { + Artist artist = new Artist(); + artist.setId(file.getPath()); + artist.setIndex(file.getName().substring(0, 1)); + artist.setName(file.getName()); + artists.add(artist); + } + } + return new Indexes(0L, Collections.emptyList(), artists); + } + + @Override + public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + File dir = new File(id); + MusicDirectory result = new MusicDirectory(); + result.setName(dir.getName()); + + Set names = new HashSet(); + + for (File file : FileUtil.listMusicFiles(dir)) { + String name = getName(file); + if (name != null & !names.contains(name)) { + names.add(name); + result.addChild(createEntry(context, file, name)); + } + } + return result; + } + + private String getName(File file) { + String name = file.getName(); + if (file.isDirectory()) { + return name; + } + + if (name.endsWith(".partial") || name.contains(".partial.") || name.equals(Constants.ALBUM_ART_FILE)) { + return null; + } + + name = name.replace(".complete", ""); + return FileUtil.getBaseName(name); + } + + private MusicDirectory.Entry createEntry(Context context, File file, String name) { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + entry.setDirectory(file.isDirectory()); + entry.setId(file.getPath()); + entry.setParent(file.getParent()); + entry.setSize(file.length()); + String root = FileUtil.getMusicDirectory(context).getPath(); + entry.setPath(file.getPath().replaceFirst("^" + root + "/" , "")); + if (file.isFile()) { + entry.setArtist(file.getParentFile().getParentFile().getName()); + entry.setAlbum(file.getParentFile().getName()); + } + entry.setTitle(name); + entry.setSuffix(FileUtil.getExtension(file.getName().replace(".complete", ""))); + + File albumArt = FileUtil.getAlbumArtFile(context, entry); + if (albumArt.exists()) { + entry.setCoverArt(albumArt.getPath()); + } + return entry; + } + + @Override + public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception { + InputStream in = new FileInputStream(entry.getCoverArt()); + try { + byte[] bytes = Util.toByteArray(in); + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + return Bitmap.createScaledBitmap(bitmap, size, size, true); + } finally { + Util.close(in); + } + } + + @Override + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Music folders not available in offline mode"); + } + + @Override + public SearchResult search(SearchCritera criteria, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Search not available in offline mode"); + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Playlists not available in offline mode"); + } + + @Override + public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Playlists not available in offline mode"); + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Playlists not available in offline mode"); + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Lyrics not available in offline mode"); + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Scrobbling not available in offline mode"); + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Album lists not available in offline mode"); + } + + @Override + public String getVideoUrl(Context context, String id) { + return null; + } + + @Override + public JukeboxStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + throw new OfflineException("Jukebox not available in offline mode"); + } + + @Override + public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception { + File root = FileUtil.getMusicDirectory(context); + List children = new LinkedList(); + listFilesRecursively(root, children); + MusicDirectory result = new MusicDirectory(); + + if (children.isEmpty()) { + return result; + } + Random random = new Random(); + for (int i = 0; i < size; i++) { + File file = children.get(random.nextInt(children.size())); + result.addChild(createEntry(context, file, getName(file))); + } + + return result; + } + + private void listFilesRecursively(File parent, List children) { + for (File file : FileUtil.listMusicFiles(parent)) { + if (file.isFile()) { + children.add(file); + } else { + listFilesRecursively(file, children); + } + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java new file mode 100644 index 00000000..1d99f2d9 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java @@ -0,0 +1,768 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHost; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.conn.params.ConnManagerParams; +import org.apache.http.conn.params.ConnPerRouteBean; +import org.apache.http.conn.scheme.PlainSocketFactory; +import org.apache.http.conn.scheme.Scheme; +import org.apache.http.conn.scheme.SchemeRegistry; +import org.apache.http.conn.scheme.SocketFactory; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; +import org.apache.http.message.BasicHeader; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; +import org.apache.http.protocol.BasicHttpContext; +import org.apache.http.protocol.ExecutionContext; +import org.apache.http.protocol.HttpContext; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Indexes; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; +import net.sourceforge.subsonic.androidapp.domain.Lyrics; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.MusicFolder; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.domain.SearchCritera; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.domain.ServerInfo; +import net.sourceforge.subsonic.androidapp.domain.Version; +import net.sourceforge.subsonic.androidapp.service.parser.AlbumListParser; +import net.sourceforge.subsonic.androidapp.service.parser.ErrorParser; +import net.sourceforge.subsonic.androidapp.service.parser.IndexesParser; +import net.sourceforge.subsonic.androidapp.service.parser.JukeboxStatusParser; +import net.sourceforge.subsonic.androidapp.service.parser.LicenseParser; +import net.sourceforge.subsonic.androidapp.service.parser.LyricsParser; +import net.sourceforge.subsonic.androidapp.service.parser.MusicDirectoryParser; +import net.sourceforge.subsonic.androidapp.service.parser.MusicFoldersParser; +import net.sourceforge.subsonic.androidapp.service.parser.PlaylistParser; +import net.sourceforge.subsonic.androidapp.service.parser.PlaylistsParser; +import net.sourceforge.subsonic.androidapp.service.parser.RandomSongsParser; +import net.sourceforge.subsonic.androidapp.service.parser.SearchResult2Parser; +import net.sourceforge.subsonic.androidapp.service.parser.SearchResultParser; +import net.sourceforge.subsonic.androidapp.service.parser.VersionParser; +import net.sourceforge.subsonic.androidapp.service.ssl.SSLSocketFactory; +import net.sourceforge.subsonic.androidapp.service.ssl.TrustSelfSignedStrategy; +import net.sourceforge.subsonic.androidapp.util.CancellableTask; +import net.sourceforge.subsonic.androidapp.util.Constants; +import net.sourceforge.subsonic.androidapp.util.FileUtil; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + */ +public class RESTMusicService implements MusicService { + + private static final String TAG = RESTMusicService.class.getSimpleName(); + + private static final int SOCKET_CONNECT_TIMEOUT = 10 * 1000; + private static final int SOCKET_READ_TIMEOUT_DEFAULT = 10 * 1000; + private static final int SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000; + private static final int SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS = 60 * 1000; + private static final int SOCKET_READ_TIMEOUT_GET_PLAYLIST = 60 * 1000; + + // Allow 20 seconds extra timeout per MB offset. + private static final double TIMEOUT_MILLIS_PER_OFFSET_BYTE = 20000.0 / 1000000.0; + + /** + * URL from which to fetch latest versions. + */ + private static final String VERSION_URL = "http://subsonic.org/backend/version.view"; + + private static final int HTTP_REQUEST_MAX_ATTEMPTS = 5; + private static final long REDIRECTION_CHECK_INTERVAL_MILLIS = 60L * 60L * 1000L; + + private final DefaultHttpClient httpClient; + private long redirectionLastChecked; + private int redirectionNetworkType = -1; + private String redirectFrom; + private String redirectTo; + private final ThreadSafeClientConnManager connManager; + + public RESTMusicService() { + + // Create and initialize default HTTP parameters + HttpParams params = new BasicHttpParams(); + ConnManagerParams.setMaxTotalConnections(params, 20); + ConnManagerParams.setMaxConnectionsPerRoute(params, new ConnPerRouteBean(20)); + HttpConnectionParams.setConnectionTimeout(params, SOCKET_CONNECT_TIMEOUT); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_DEFAULT); + + // Turn off stale checking. Our connections break all the time anyway, + // and it's not worth it to pay the penalty of checking every time. + HttpConnectionParams.setStaleCheckingEnabled(params, false); + + // Create and initialize scheme registry + SchemeRegistry schemeRegistry = new SchemeRegistry(); + schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); + schemeRegistry.register(new Scheme("https", createSSLSocketFactory(), 443)); + + // Create an HttpClient with the ThreadSafeClientConnManager. + // This connection manager must be used if more than one thread will + // be using the HttpClient. + connManager = new ThreadSafeClientConnManager(params, schemeRegistry); + httpClient = new DefaultHttpClient(connManager, params); + } + + private SocketFactory createSSLSocketFactory() { + try { + return new SSLSocketFactory(new TrustSelfSignedStrategy(), SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); + } catch (Throwable x) { + Log.e(TAG, "Failed to create custom SSL socket factory, using default.", x); + return org.apache.http.conn.ssl.SSLSocketFactory.getSocketFactory(); + } + } + + @Override + public void ping(Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "ping", null); + try { + new ErrorParser(context).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public boolean isLicenseValid(Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getLicense", null); + try { + ServerInfo serverInfo = new LicenseParser(context).parse(reader); + return serverInfo.isLicenseValid(); + } finally { + Util.close(reader); + } + } + + public List getMusicFolders(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + List cachedMusicFolders = readCachedMusicFolders(context); + if (cachedMusicFolders != null && !refresh) { + return cachedMusicFolders; + } + + Reader reader = getReader(context, progressListener, "getMusicFolders", null); + try { + List musicFolders = new MusicFoldersParser(context).parse(reader, progressListener); + writeCachedMusicFolders(context, musicFolders); + return musicFolders; + } finally { + Util.close(reader); + } + } + + @Override + public Indexes getIndexes(String musicFolderId, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Indexes cachedIndexes = readCachedIndexes(context, musicFolderId); + if (cachedIndexes != null && !refresh) { + return cachedIndexes; + } + + long lastModified = cachedIndexes == null ? 0L : cachedIndexes.getLastModified(); + + List parameterNames = new ArrayList(); + List parameterValues = new ArrayList(); + + parameterNames.add("ifModifiedSince"); + parameterValues.add(lastModified); + + if (musicFolderId != null) { + parameterNames.add("musicFolderId"); + parameterValues.add(musicFolderId); + } + + Reader reader = getReader(context, progressListener, "getIndexes", null, parameterNames, parameterValues); + try { + Indexes indexes = new IndexesParser(context).parse(reader, progressListener); + if (indexes != null) { + writeCachedIndexes(context, indexes, musicFolderId); + return indexes; + } + return cachedIndexes; + } finally { + Util.close(reader); + } + } + + private Indexes readCachedIndexes(Context context, String musicFolderId) { + String filename = getCachedIndexesFilename(context, musicFolderId); + return FileUtil.deserialize(context, filename); + } + + private void writeCachedIndexes(Context context, Indexes indexes, String musicFolderId) { + String filename = getCachedIndexesFilename(context, musicFolderId); + FileUtil.serialize(context, indexes, filename); + } + + private String getCachedIndexesFilename(Context context, String musicFolderId) { + String s = Util.getRestUrl(context, null) + musicFolderId; + return "indexes-" + Math.abs(s.hashCode()) + ".ser"; + } + + private ArrayList readCachedMusicFolders(Context context) { + String filename = getCachedMusicFoldersFilename(context); + return FileUtil.deserialize(context, filename); + } + + private void writeCachedMusicFolders(Context context, List musicFolders) { + String filename = getCachedMusicFoldersFilename(context); + FileUtil.serialize(context, new ArrayList(musicFolders), filename); + } + + private String getCachedMusicFoldersFilename(Context context) { + String s = Util.getRestUrl(context, null); + return "musicFolders-" + Math.abs(s.hashCode()) + ".ser"; + } + + @Override + public MusicDirectory getMusicDirectory(String id, boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getMusicDirectory", null, "id", id); + try { + return new MusicDirectoryParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public SearchResult search(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + try { + return searchNew(critera, context, progressListener); + } catch (ServerTooOldException x) { + // Ensure backward compatibility with REST 1.3. + return searchOld(critera, context, progressListener); + } + } + + /** + * Search using the "search" REST method. + */ + private SearchResult searchOld(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("any", "songCount"); + List parameterValues = Arrays.asList(critera.getQuery(), critera.getSongCount()); + Reader reader = getReader(context, progressListener, "search", null, parameterNames, parameterValues); + try { + return new SearchResultParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + /** + * Search using the "search2" REST method, available in 1.4.0 and later. + */ + private SearchResult searchNew(SearchCritera critera, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.4", null); + + List parameterNames = Arrays.asList("query", "artistCount", "albumCount", "songCount"); + List parameterValues = Arrays.asList(critera.getQuery(), critera.getArtistCount(), + critera.getAlbumCount(), critera.getSongCount()); + Reader reader = getReader(context, progressListener, "search2", null, parameterNames, parameterValues); + try { + return new SearchResult2Parser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getPlaylist(String id, Context context, ProgressListener progressListener) throws Exception { + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_PLAYLIST); + + Reader reader = getReader(context, progressListener, "getPlaylist", params, "id", id); + try { + return new PlaylistParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public List getPlaylists(boolean refresh, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getPlaylists", null); + try { + return new PlaylistsParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void createPlaylist(String id, String name, List entries, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = new LinkedList(); + List parameterValues = new LinkedList(); + + if (id != null) { + parameterNames.add("playlistId"); + parameterValues.add(id); + } + if (name != null) { + parameterNames.add("name"); + parameterValues.add(name); + } + for (MusicDirectory.Entry entry : entries) { + parameterNames.add("songId"); + parameterValues.add(entry.getId()); + } + + Reader reader = getReader(context, progressListener, "createPlaylist", null, parameterNames, parameterValues); + try { + new ErrorParser(context).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public Lyrics getLyrics(String artist, String title, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getLyrics", null, Arrays.asList("artist", "title"), Arrays.asList(artist, title)); + try { + return new LyricsParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public void scrobble(String id, boolean submission, Context context, ProgressListener progressListener) throws Exception { + checkServerVersion(context, "1.5", "Scrobbling not supported."); + Reader reader = getReader(context, progressListener, "scrobble", null, Arrays.asList("id", "submission"), Arrays.asList(id, submission)); + try { + new ErrorParser(context).parse(reader); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getAlbumList(String type, int size, int offset, Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReader(context, progressListener, "getAlbumList", + null, Arrays.asList("type", "size", "offset"), Arrays.asList(type, size, offset)); + try { + return new AlbumListParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public MusicDirectory getRandomSongs(int size, Context context, ProgressListener progressListener) throws Exception { + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setSoTimeout(params, SOCKET_READ_TIMEOUT_GET_RANDOM_SONGS); + + Reader reader = getReader(context, progressListener, "getRandomSongs", params, "size", size); + try { + return new RandomSongsParser(context).parse(reader, progressListener); + } finally { + Util.close(reader); + } + } + + @Override + public Version getLocalVersion(Context context) throws Exception { + PackageInfo packageInfo = context.getPackageManager().getPackageInfo("net.sourceforge.subsonic.androidapp", 0); + return new Version(packageInfo.versionName); + } + + @Override + public Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception { + Reader reader = getReaderForURL(context, VERSION_URL, null, null, null, progressListener); + try { + return new VersionParser().parse(reader); + } finally { + Util.close(reader); + } + } + + private void checkServerVersion(Context context, String version, String text) throws ServerTooOldException { + Version serverVersion = Util.getServerRestVersion(context); + Version requiredVersion = new Version(version); + boolean ok = serverVersion == null || serverVersion.compareTo(requiredVersion) >= 0; + + if (!ok) { + throw new ServerTooOldException(text, serverVersion, requiredVersion); + } + } + + @Override + public Bitmap getCoverArt(Context context, MusicDirectory.Entry entry, int size, boolean saveToFile, ProgressListener progressListener) throws Exception { + + // Synchronize on the entry so that we don't download concurrently for the same song. + synchronized (entry) { + + // Use cached file, if existing. + Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, entry, size); + if (bitmap != null) { + return bitmap; + } + + String url = Util.getRestUrl(context, "getCoverArt"); + + InputStream in = null; + try { + List parameterNames = Arrays.asList("id", "size"); + List parameterValues = Arrays.asList(entry.getCoverArt(), size); + HttpEntity entity = getEntityForURL(context, url, null, parameterNames, parameterValues, progressListener); + in = entity.getContent(); + + // If content type is XML, an error occured. Get it. + String contentType = Util.getContentType(entity); + if (contentType != null && contentType.startsWith("text/xml")) { + new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8)); + return null; // Never reached. + } + + byte[] bytes = Util.toByteArray(in); + + if (saveToFile) { + OutputStream out = null; + try { + out = new FileOutputStream(FileUtil.getAlbumArtFile(context, entry)); + out.write(bytes); + } finally { + Util.close(out); + } + } + + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + + } finally { + Util.close(in); + } + } + } + + @Override + public HttpResponse getDownloadInputStream(Context context, MusicDirectory.Entry song, long offset, int maxBitrate, CancellableTask task) throws Exception { + + String url = Util.getRestUrl(context, "stream"); + + // Set socket read timeout. Note: The timeout increases as the offset gets larger. This is + // to avoid the thrashing effect seen when offset is combined with transcoding/downsampling on the server. + // In that case, the server uses a long time before sending any data, causing the client to time out. + HttpParams params = new BasicHttpParams(); + int timeout = (int) (SOCKET_READ_TIMEOUT_DOWNLOAD + offset * TIMEOUT_MILLIS_PER_OFFSET_BYTE); + HttpConnectionParams.setSoTimeout(params, timeout); + + // Add "Range" header if offset is given. + List
headers = new ArrayList
(); + if (offset > 0) { + headers.add(new BasicHeader("Range", "bytes=" + offset + "-")); + } + List parameterNames = Arrays.asList("id", "maxBitRate"); + List parameterValues = Arrays.asList(song.getId(), maxBitrate); + HttpResponse response = getResponseForURL(context, url, params, parameterNames, parameterValues, headers, null, task); + + // If content type is XML, an error occurred. Get it. + String contentType = Util.getContentType(response.getEntity()); + if (contentType != null && contentType.startsWith("text/xml")) { + InputStream in = response.getEntity().getContent(); + try { + new ErrorParser(context).parse(new InputStreamReader(in, Constants.UTF_8)); + } finally { + Util.close(in); + } + } + + return response; + } + + @Override + public String getVideoUrl(Context context, String id) { + StringBuilder builder = new StringBuilder(Util.getRestUrl(context, "videoPlayer")); + builder.append("&id=").append(id); + builder.append("&maxBitRate=500"); + builder.append("&autoplay=true"); + + String url = rewriteUrlWithRedirect(context, builder.toString()); + Log.i(TAG, "Using video URL: " + url); + return url; + } + + @Override + public JukeboxStatus updateJukeboxPlaylist(List ids, Context context, ProgressListener progressListener) throws Exception { + int n = ids.size(); + List parameterNames = new ArrayList(n + 1); + parameterNames.add("action"); + for (int i = 0; i < n; i++) { + parameterNames.add("id"); + } + List parameterValues = new ArrayList(); + parameterValues.add("set"); + parameterValues.addAll(ids); + + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + } + + @Override + public JukeboxStatus skipJukebox(int index, int offsetSeconds, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("action", "index", "offset"); + List parameterValues = Arrays.asList("skip", index, offsetSeconds); + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + } + + @Override + public JukeboxStatus stopJukebox(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("stop")); + } + + @Override + public JukeboxStatus startJukebox(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("start")); + } + + @Override + public JukeboxStatus getJukeboxStatus(Context context, ProgressListener progressListener) throws Exception { + return executeJukeboxCommand(context, progressListener, Arrays.asList("action"), Arrays.asList("status")); + } + + @Override + public JukeboxStatus setJukeboxGain(float gain, Context context, ProgressListener progressListener) throws Exception { + List parameterNames = Arrays.asList("action", "gain"); + List parameterValues = Arrays.asList("setGain", gain); + return executeJukeboxCommand(context, progressListener, parameterNames, parameterValues); + + } + + private JukeboxStatus executeJukeboxCommand(Context context, ProgressListener progressListener, List parameterNames, List parameterValues) throws Exception { + checkServerVersion(context, "1.7", "Jukebox not supported."); + Reader reader = getReader(context, progressListener, "jukeboxControl", null, parameterNames, parameterValues); + try { + return new JukeboxStatusParser(context).parse(reader); + } finally { + Util.close(reader); + } + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, HttpParams requestParams) throws Exception { + return getReader(context, progressListener, method, requestParams, Collections.emptyList(), Collections.emptyList()); + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, + HttpParams requestParams, String parameterName, Object parameterValue) throws Exception { + return getReader(context, progressListener, method, requestParams, Arrays.asList(parameterName), Arrays.asList(parameterValue)); + } + + private Reader getReader(Context context, ProgressListener progressListener, String method, + HttpParams requestParams, List parameterNames, List parameterValues) throws Exception { + + if (progressListener != null) { + progressListener.updateProgress(R.string.service_connecting); + } + + String url = Util.getRestUrl(context, method); + return getReaderForURL(context, url, requestParams, parameterNames, parameterValues, progressListener); + } + + private Reader getReaderForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener) throws Exception { + HttpEntity entity = getEntityForURL(context, url, requestParams, parameterNames, parameterValues, progressListener); + if (entity == null) { + throw new RuntimeException("No entity received for URL " + url); + } + + InputStream in = entity.getContent(); + return new InputStreamReader(in, Constants.UTF_8); + } + + private HttpEntity getEntityForURL(Context context, String url, HttpParams requestParams, List parameterNames, + List parameterValues, ProgressListener progressListener) throws Exception { + return getResponseForURL(context, url, requestParams, parameterNames, parameterValues, null, progressListener, null).getEntity(); + } + + private HttpResponse getResponseForURL(Context context, String url, HttpParams requestParams, + List parameterNames, List parameterValues, + List
headers, ProgressListener progressListener, CancellableTask task) throws Exception { + Log.d(TAG, "Connections in pool: " + connManager.getConnectionsInPool()); + + // If not too many parameters, extract them to the URL rather than relying on the HTTP POST request being + // received intact. Remember, HTTP POST requests are converted to GET requests during HTTP redirects, thus + // loosing its entity. + if (parameterNames != null && parameterNames.size() < 10) { + StringBuilder builder = new StringBuilder(url); + for (int i = 0; i < parameterNames.size(); i++) { + builder.append("&").append(parameterNames.get(i)).append("="); + builder.append(URLEncoder.encode(String.valueOf(parameterValues.get(i)), "UTF-8")); + } + url = builder.toString(); + parameterNames = null; + parameterValues = null; + } + + String rewrittenUrl = rewriteUrlWithRedirect(context, url); + return executeWithRetry(context, rewrittenUrl, url, requestParams, parameterNames, parameterValues, headers, progressListener, task); + } + + private HttpResponse executeWithRetry(Context context, String url, String originalUrl, HttpParams requestParams, + List parameterNames, List parameterValues, + List
headers, ProgressListener progressListener, CancellableTask task) throws IOException { + Log.i(TAG, "Using URL " + url); + + final AtomicReference cancelled = new AtomicReference(false); + int attempts = 0; + while (true) { + attempts++; + HttpContext httpContext = new BasicHttpContext(); + final HttpPost request = new HttpPost(url); + + if (task != null) { + // Attempt to abort the HTTP request if the task is cancelled. + task.setOnCancelListener(new CancellableTask.OnCancelListener() { + @Override + public void onCancel() { + cancelled.set(true); + request.abort(); + } + }); + } + + if (parameterNames != null) { + List params = new ArrayList(); + for (int i = 0; i < parameterNames.size(); i++) { + params.add(new BasicNameValuePair(parameterNames.get(i), String.valueOf(parameterValues.get(i)))); + } + request.setEntity(new UrlEncodedFormEntity(params, Constants.UTF_8)); + } + + if (requestParams != null) { + request.setParams(requestParams); + Log.d(TAG, "Socket read timeout: " + HttpConnectionParams.getSoTimeout(requestParams) + " ms."); + } + + if (headers != null) { + for (Header header : headers) { + request.addHeader(header); + } + } + + // Set credentials to get through apache proxies that require authentication. + SharedPreferences prefs = Util.getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null); + httpClient.getCredentialsProvider().setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT), + new UsernamePasswordCredentials(username, password)); + + try { + HttpResponse response = httpClient.execute(request, httpContext); + detectRedirect(originalUrl, context, httpContext); + return response; + } catch (IOException x) { + request.abort(); + if (attempts >= HTTP_REQUEST_MAX_ATTEMPTS || cancelled.get()) { + throw x; + } + if (progressListener != null) { + String msg = context.getResources().getString(R.string.music_service_retry, attempts, HTTP_REQUEST_MAX_ATTEMPTS - 1); + progressListener.updateProgress(msg); + } + Log.w(TAG, "Got IOException (" + attempts + "), will retry", x); + increaseTimeouts(requestParams); + Util.sleepQuietly(2000L); + } + } + } + + private void increaseTimeouts(HttpParams requestParams) { + if (requestParams != null) { + int connectTimeout = HttpConnectionParams.getConnectionTimeout(requestParams); + if (connectTimeout != 0) { + HttpConnectionParams.setConnectionTimeout(requestParams, (int) (connectTimeout * 1.3F)); + } + int readTimeout = HttpConnectionParams.getSoTimeout(requestParams); + if (readTimeout != 0) { + HttpConnectionParams.setSoTimeout(requestParams, (int) (readTimeout * 1.5F)); + } + } + } + + private void detectRedirect(String originalUrl, Context context, HttpContext httpContext) { + HttpUriRequest request = (HttpUriRequest) httpContext.getAttribute(ExecutionContext.HTTP_REQUEST); + HttpHost host = (HttpHost) httpContext.getAttribute(ExecutionContext.HTTP_TARGET_HOST); + String redirectedUrl = host.toURI() + request.getURI(); + + redirectFrom = originalUrl.substring(0, originalUrl.indexOf("/rest/")); + redirectTo = redirectedUrl.substring(0, redirectedUrl.indexOf("/rest/")); + + Log.i(TAG, redirectFrom + " redirects to " + redirectTo); + redirectionLastChecked = System.currentTimeMillis(); + redirectionNetworkType = getCurrentNetworkType(context); + } + + private String rewriteUrlWithRedirect(Context context, String url) { + + // Only cache for a certain time. + if (System.currentTimeMillis() - redirectionLastChecked > REDIRECTION_CHECK_INTERVAL_MILLIS) { + return url; + } + + // Ignore cache if network type has changed. + if (redirectionNetworkType != getCurrentNetworkType(context)) { + return url; + } + + if (redirectFrom == null || redirectTo == null) { + return url; + } + + return url.replace(redirectFrom, redirectTo); + } + + private int getCurrentNetworkType(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + return networkInfo == null ? -1 : networkInfo.getType(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java new file mode 100644 index 00000000..ce121a4b --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/Scrobbler.java @@ -0,0 +1,52 @@ +package net.sourceforge.subsonic.androidapp.service; + +import android.content.Context; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * Scrobbles played songs to Last.fm. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class Scrobbler { + + private static final String TAG = Scrobbler.class.getSimpleName(); + + private String lastSubmission; + private String lastNowPlaying; + + public void scrobble(final Context context, final DownloadFile song, final boolean submission) { + if (song == null || !Util.isScrobblingEnabled(context)) { + return; + } + final String id = song.getSong().getId(); + + // Avoid duplicate registrations. + if (submission && id.equals(lastSubmission)) { + return; + } + if (!submission && id.equals(lastNowPlaying)) { + return; + } + if (submission) { + lastSubmission = id; + } else { + lastNowPlaying = id; + } + + new Thread("Scrobble " + song) { + @Override + public void run() { + MusicService service = MusicServiceFactory.getMusicService(context); + try { + service.scrobble(id, submission, context, null); + Log.i(TAG, "Scrobbled '" + (submission ? "submission" : "now playing") + "' for " + song); + } catch (Exception x) { + Log.i(TAG, "Failed to scrobble'" + (submission ? "submission" : "now playing") + "' for " + song, x); + } + } + }.start(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java new file mode 100644 index 00000000..9d433385 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ServerTooOldException.java @@ -0,0 +1,51 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service; + +import net.sourceforge.subsonic.androidapp.domain.Version; + +/** + * Thrown if the REST API version implemented by the server is too old. + * + * @author Sindre Mehus + * @version $Id$ + */ +public class ServerTooOldException extends Exception { + + private final String text; + private final Version serverVersion; + private final Version requiredVersion; + + public ServerTooOldException(String text, Version serverVersion, Version requiredVersion) { + this.text = text; + this.serverVersion = serverVersion; + this.requiredVersion = requiredVersion; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (text != null) { + builder.append(text).append(" "); + } + builder.append("Server API version too old. "); + builder.append("Requires ").append(requiredVersion).append(" but is ").append(serverVersion).append("."); + return builder.toString(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java new file mode 100644 index 00000000..4ddff7e9 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AbstractParser.java @@ -0,0 +1,138 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import java.io.Reader; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import android.util.Xml; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Version; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import net.sourceforge.subsonic.androidapp.util.Util; + +/** + * @author Sindre Mehus + */ +public abstract class AbstractParser { + + private final Context context; + private XmlPullParser parser; + private boolean rootElementFound; + + public AbstractParser(Context context) { + this.context = context; + } + + protected Context getContext() { + return context; + } + + protected void handleError() throws Exception { + int code = getInteger("code"); + String message; + switch (code) { + case 20: + message = context.getResources().getString(R.string.parser_upgrade_client); + break; + case 30: + message = context.getResources().getString(R.string.parser_upgrade_server); + break; + case 40: + message = context.getResources().getString(R.string.parser_not_authenticated); + break; + case 50: + message = context.getResources().getString(R.string.parser_not_authorized); + break; + default: + message = get("message"); + break; + } + throw new SubsonicRESTException(code, message); + } + + protected void updateProgress(ProgressListener progressListener, int messageId) { + if (progressListener != null) { + progressListener.updateProgress(messageId); + } + } + + protected void updateProgress(ProgressListener progressListener, String message) { + if (progressListener != null) { + progressListener.updateProgress(message); + } + } + + protected String getText() { + return parser.getText(); + } + + protected String get(String name) { + return parser.getAttributeValue(null, name); + } + + protected boolean getBoolean(String name) { + return "true".equals(get(name)); + } + + protected Integer getInteger(String name) { + String s = get(name); + return s == null ? null : Integer.valueOf(s); + } + + protected Long getLong(String name) { + String s = get(name); + return s == null ? null : Long.valueOf(s); + } + + protected Float getFloat(String name) { + String s = get(name); + return s == null ? null : Float.valueOf(s); + } + + protected void init(Reader reader) throws Exception { + parser = Xml.newPullParser(); + parser.setInput(reader); + rootElementFound = false; + } + + protected int nextParseEvent() throws Exception { + return parser.next(); + } + + protected String getElementName() { + String name = parser.getName(); + if ("subsonic-response".equals(name)) { + rootElementFound = true; + String version = get("version"); + if (version != null) { + Util.setServerRestVersion(context, new Version(version)); + } + } + return name; + } + + protected void validate() throws Exception { + if (!rootElementFound) { + throw new Exception(context.getResources().getString(R.string.background_task_parse_error)); + } + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java new file mode 100644 index 00000000..298ef114 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/AlbumListParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class AlbumListParser extends MusicDirectoryEntryParser { + + public AlbumListParser(Context context) { + super(context); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("album".equals(name)) { + dir.addChild(parseEntry()); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return dir; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java new file mode 100644 index 00000000..b2c61c5b --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/ErrorParser.java @@ -0,0 +1,49 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class ErrorParser extends AbstractParser { + + public ErrorParser(Context context) { + super(context); + } + + public void parse(Reader reader) throws Exception { + + init(reader); + + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG && "error".equals(getElementName())) { + handleError(); + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java new file mode 100644 index 00000000..83ef3e77 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/IndexesParser.java @@ -0,0 +1,104 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import java.io.Reader; +import java.util.List; +import java.util.ArrayList; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Artist; +import net.sourceforge.subsonic.androidapp.domain.Indexes; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import android.util.Log; + +/** + * @author Sindre Mehus + */ +public class IndexesParser extends AbstractParser { + private static final String TAG = IndexesParser.class.getSimpleName(); + + public IndexesParser(Context context) { + super(context); + } + + public Indexes parse(Reader reader, ProgressListener progressListener) throws Exception { + + long t0 = System.currentTimeMillis(); + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + List artists = new ArrayList(); + List shortcuts = new ArrayList(); + Long lastModified = null; + int eventType; + String index = "#"; + boolean changed = false; + + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("indexes".equals(name)) { + changed = true; + lastModified = getLong("lastModified"); + } else if ("index".equals(name)) { + index = get("name"); + + } else if ("artist".equals(name)) { + Artist artist = new Artist(); + artist.setId(get("id")); + artist.setName(get("name")); + artist.setIndex(index); + artists.add(artist); + + if (artists.size() % 10 == 0) { + String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size()); + updateProgress(progressListener, msg); + } + } else if ("shortcut".equals(name)) { + Artist shortcut = new Artist(); + shortcut.setId(get("id")); + shortcut.setName(get("name")); + shortcut.setIndex("*"); + shortcuts.add(shortcut); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + if (!changed) { + return null; + } + + long t1 = System.currentTimeMillis(); + Log.d(TAG, "Got " + artists.size() + " artist(s) in " + (t1 - t0) + "ms."); + + String msg = getContext().getResources().getString(R.string.parser_artist_count, artists.size()); + updateProgress(progressListener, msg); + + return new Indexes(lastModified == null ? 0L : lastModified, shortcuts, artists); + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java new file mode 100644 index 00000000..2a61508d --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/JukeboxStatusParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import java.io.Reader; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.domain.JukeboxStatus; + +/** + * @author Sindre Mehus + */ +public class JukeboxStatusParser extends AbstractParser { + + public JukeboxStatusParser(Context context) { + super(context); + } + + public JukeboxStatus parse(Reader reader) throws Exception { + + init(reader); + + JukeboxStatus jukeboxStatus = new JukeboxStatus(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("jukeboxPlaylist".equals(name) || "jukeboxStatus".equals(name)) { + jukeboxStatus.setPositionSeconds(getInteger("position")); + jukeboxStatus.setCurrentIndex(getInteger("currentIndex")); + jukeboxStatus.setPlaying(getBoolean("playing")); + jukeboxStatus.setGain(getFloat("gain")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return jukeboxStatus; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java new file mode 100644 index 00000000..636c3e6e --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LicenseParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +import net.sourceforge.subsonic.androidapp.domain.ServerInfo; +import net.sourceforge.subsonic.androidapp.domain.Version; + +/** + * @author Sindre Mehus + */ +public class LicenseParser extends AbstractParser { + + public LicenseParser(Context context) { + super(context); + } + + public ServerInfo parse(Reader reader) throws Exception { + + init(reader); + + ServerInfo serverInfo = new ServerInfo(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("subsonic-response".equals(name)) { + serverInfo.setRestVersion(new Version(get("version"))); + } else if ("license".equals(name)) { + serverInfo.setLicenseValid(getBoolean("valid")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + + return serverInfo; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java new file mode 100644 index 00000000..698fb4b8 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/LyricsParser.java @@ -0,0 +1,65 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2010 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Lyrics; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class LyricsParser extends AbstractParser { + + public LyricsParser(Context context) { + super(context); + } + + public Lyrics parse(Reader reader, ProgressListener progressListener) throws Exception { + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + Lyrics lyrics = null; + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("lyrics".equals(name)) { + lyrics = new Lyrics(); + lyrics.setArtist(get("artist")); + lyrics.setTitle(get("title")); + } else if ("error".equals(name)) { + handleError(); + } + } else if (eventType == XmlPullParser.TEXT) { + if (lyrics != null && lyrics.getText() == null) { + lyrics.setText(getText()); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + return lyrics; + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java new file mode 100644 index 00000000..3da90613 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryEntryParser.java @@ -0,0 +1,59 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; + +/** + * @author Sindre Mehus + */ +public class MusicDirectoryEntryParser extends AbstractParser { + + public MusicDirectoryEntryParser(Context context) { + super(context); + } + + protected MusicDirectory.Entry parseEntry() { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + entry.setId(get("id")); + entry.setParent(get("parent")); + entry.setTitle(get("title")); + entry.setDirectory(getBoolean("isDir")); + entry.setCoverArt(get("coverArt")); + entry.setArtist(get("artist")); + + if (!entry.isDirectory()) { + entry.setAlbum(get("album")); + entry.setTrack(getInteger("track")); + entry.setYear(getInteger("year")); + entry.setGenre(get("genre")); + entry.setContentType(get("contentType")); + entry.setSuffix(get("suffix")); + entry.setTranscodedContentType(get("transcodedContentType")); + entry.setTranscodedSuffix(get("transcodedSuffix")); + entry.setSize(getLong("size")); + entry.setDuration(getInteger("duration")); + entry.setBitRate(getInteger("bitRate")); + entry.setPath(get("path")); + entry.setVideo(getBoolean("isVideo")); + } + return entry; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java new file mode 100644 index 00000000..b818fc3d --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicDirectoryParser.java @@ -0,0 +1,71 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class MusicDirectoryParser extends MusicDirectoryEntryParser { + + private static final String TAG = MusicDirectoryParser.class.getSimpleName(); + + public MusicDirectoryParser(Context context) { + super(context); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + + long t0 = System.currentTimeMillis(); + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("child".equals(name)) { + dir.addChild(parseEntry()); + } else if ("directory".equals(name)) { + dir.setName(get("name")); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + long t1 = System.currentTimeMillis(); + Log.d(TAG, "Got music directory in " + (t1 - t0) + "ms."); + + return dir; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java new file mode 100644 index 00000000..35057bd9 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/MusicFoldersParser.java @@ -0,0 +1,69 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +import org.xmlpull.v1.XmlPullParser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicFolder; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; + +/** + * @author Sindre Mehus + */ +public class MusicFoldersParser extends AbstractParser { + + public MusicFoldersParser(Context context) { + super(context); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + List result = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String tag = getElementName(); + if ("musicFolder".equals(tag)) { + String id = get("id"); + String name = get("name"); + result.add(new MusicFolder(id, name)); + } else if ("error".equals(tag)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return result; + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java new file mode 100644 index 00000000..ee829639 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class PlaylistParser extends MusicDirectoryEntryParser { + + public PlaylistParser(Context context) { + super(context); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("entry".equals(name)) { + dir.addChild(parseEntry()); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return dir; + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java new file mode 100644 index 00000000..c1b88b8c --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/PlaylistsParser.java @@ -0,0 +1,67 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Playlist; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Sindre Mehus + */ +public class PlaylistsParser extends AbstractParser { + + public PlaylistsParser(Context context) { + super(context); + } + + public List parse(Reader reader, ProgressListener progressListener) throws Exception { + + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + List result = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String tag = getElementName(); + if ("playlist".equals(tag)) { + String id = get("id"); + String name = get("name"); + result.add(new Playlist(id, name)); + } else if ("error".equals(tag)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return result; + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java new file mode 100644 index 00000000..0bf422b7 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/RandomSongsParser.java @@ -0,0 +1,62 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; + +/** + * @author Sindre Mehus + */ +public class RandomSongsParser extends MusicDirectoryEntryParser { + + public RandomSongsParser(Context context) { + super(context); + } + + public MusicDirectory parse(Reader reader, ProgressListener progressListener) throws Exception { + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + MusicDirectory dir = new MusicDirectory(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("song".equals(name)) { + dir.addChild(parseEntry()); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return dir; + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java new file mode 100644 index 00000000..01052f25 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResult2Parser.java @@ -0,0 +1,75 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.domain.Artist; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.List; +import java.util.ArrayList; + +/** + * @author Sindre Mehus + */ +public class SearchResult2Parser extends MusicDirectoryEntryParser { + + public SearchResult2Parser(Context context) { + super(context); + } + + public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception { + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + List artists = new ArrayList(); + List albums = new ArrayList(); + List songs = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("artist".equals(name)) { + Artist artist = new Artist(); + artist.setId(get("id")); + artist.setName(get("name")); + artists.add(artist); + } else if ("album".equals(name)) { + albums.add(parseEntry()); + } else if ("song".equals(name)) { + songs.add(parseEntry()); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return new SearchResult(artists, albums, songs); + } + +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java new file mode 100644 index 00000000..c38b077f --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SearchResultParser.java @@ -0,0 +1,67 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import android.content.Context; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.SearchResult; +import net.sourceforge.subsonic.androidapp.domain.Artist; +import net.sourceforge.subsonic.androidapp.util.ProgressListener; +import org.xmlpull.v1.XmlPullParser; + +import java.io.Reader; +import java.util.Collections; +import java.util.List; +import java.util.ArrayList; + +/** + * @author Sindre Mehus + */ +public class SearchResultParser extends MusicDirectoryEntryParser { + + public SearchResultParser(Context context) { + super(context); + } + + public SearchResult parse(Reader reader, ProgressListener progressListener) throws Exception { + updateProgress(progressListener, R.string.parser_reading); + init(reader); + + List songs = new ArrayList(); + int eventType; + do { + eventType = nextParseEvent(); + if (eventType == XmlPullParser.START_TAG) { + String name = getElementName(); + if ("match".equals(name)) { + songs.add(parseEntry()); + } else if ("error".equals(name)) { + handleError(); + } + } + } while (eventType != XmlPullParser.END_DOCUMENT); + + validate(); + updateProgress(progressListener, R.string.parser_reading_done); + + return new SearchResult(Collections.emptyList(), Collections.emptyList(), songs); + } + +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java new file mode 100644 index 00000000..b46b6f22 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/SubsonicRESTException.java @@ -0,0 +1,19 @@ +package net.sourceforge.subsonic.androidapp.service.parser; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class SubsonicRESTException extends Exception { + + private final int code; + + public SubsonicRESTException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java new file mode 100644 index 00000000..b8a05531 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/parser/VersionParser.java @@ -0,0 +1,47 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.service.parser; + +import net.sourceforge.subsonic.androidapp.domain.Version; + +import java.io.BufferedReader; +import java.io.Reader; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Sindre Mehus + */ +public class VersionParser { + + public Version parse(Reader reader) throws Exception { + + BufferedReader bufferedReader = new BufferedReader(reader); + Pattern pattern = Pattern.compile("SUBSONIC_ANDROID_VERSION_BEGIN(.*)SUBSONIC_ANDROID_VERSION_END"); + String line = bufferedReader.readLine(); + while (line != null) { + Matcher finalMatcher = pattern.matcher(line); + if (finalMatcher.find()) { + return new Version(finalMatcher.group(1)); + } + line = bufferedReader.readLine(); + } + return null; + } +} \ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java new file mode 100644 index 00000000..0e146650 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/SSLSocketFactory.java @@ -0,0 +1,497 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package net.sourceforge.subsonic.androidapp.service.ssl; + +import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.conn.scheme.HostNameResolver; +import org.apache.http.conn.scheme.LayeredSocketFactory; +import org.apache.http.conn.ssl.AllowAllHostnameVerifier; +import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier; +import org.apache.http.conn.ssl.StrictHostnameVerifier; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; + +/** + * Layered socket factory for TLS/SSL connections. + *

+ * SSLSocketFactory can be used to validate the identity of the HTTPS server against a list of + * trusted certificates and to authenticate to the HTTPS server using a private key. + *

+ * SSLSocketFactory will enable server authentication when supplied with + * a {@link KeyStore trust-store} file containing one or several trusted certificates. The client + * secure socket will reject the connection during the SSL session handshake if the target HTTPS + * server attempts to authenticate itself with a non-trusted certificate. + *

+ * Use JDK keytool utility to import a trusted certificate and generate a trust-store file: + *

+ *     keytool -import -alias "my server cert" -file server.crt -keystore my.truststore
+ *    
+ *

+ * In special cases the standard trust verification process can be bypassed by using a custom + * {@link TrustStrategy}. This interface is primarily intended for allowing self-signed + * certificates to be accepted as trusted without having to add them to the trust-store file. + *

+ * The following parameters can be used to customize the behavior of this + * class: + *

    + *
  • {@link org.apache.http.params.CoreConnectionPNames#CONNECTION_TIMEOUT}
  • + *
  • {@link org.apache.http.params.CoreConnectionPNames#SO_TIMEOUT}
  • + *
+ *

+ * SSLSocketFactory will enable client authentication when supplied with + * a {@link KeyStore key-store} file containing a private key/public certificate + * pair. The client secure socket will use the private key to authenticate + * itself to the target HTTPS server during the SSL session handshake if + * requested to do so by the server. + * The target HTTPS server will in its turn verify the certificate presented + * by the client in order to establish client's authenticity + *

+ * Use the following sequence of actions to generate a key-store file + *

+ *
    + *
  • + *

    + * Use JDK keytool utility to generate a new key + *

    keytool -genkey -v -alias "my client key" -validity 365 -keystore my.keystore
    + * For simplicity use the same password for the key as that of the key-store + *

    + *
  • + *
  • + *

    + * Issue a certificate signing request (CSR) + *

    keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Send the certificate request to the trusted Certificate Authority for signature. + * One may choose to act as her own CA and sign the certificate request using a PKI + * tool, such as OpenSSL. + *

    + *
  • + *
  • + *

    + * Import the trusted CA root certificate + *

    keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Import the PKCS#7 file containg the complete certificate chain + *

    keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore
    + *

    + *
  • + *
  • + *

    + * Verify the content the resultant keystore file + *

    keytool -list -v -keystore my.keystore
    + *

    + *
  • + *
+ * + * @since 4.0 + */ +public class SSLSocketFactory implements LayeredSocketFactory { + + public static final String TLS = "TLS"; + public static final String SSL = "SSL"; + public static final String SSLV2 = "SSLv2"; + + public static final X509HostnameVerifier ALLOW_ALL_HOSTNAME_VERIFIER + = new AllowAllHostnameVerifier(); + + public static final X509HostnameVerifier BROWSER_COMPATIBLE_HOSTNAME_VERIFIER + = new BrowserCompatHostnameVerifier(); + + public static final X509HostnameVerifier STRICT_HOSTNAME_VERIFIER + = new StrictHostnameVerifier(); + + /** + * The default factory using the default JVM settings for secure connections. + */ + private static final SSLSocketFactory DEFAULT_FACTORY = new SSLSocketFactory(); + + /** + * Gets the default factory, which uses the default JVM settings for secure + * connections. + * + * @return the default factory + */ + public static SSLSocketFactory getSocketFactory() { + return DEFAULT_FACTORY; + } + + private final javax.net.ssl.SSLSocketFactory socketfactory; + private final HostNameResolver nameResolver; + // TODO: make final + private volatile X509HostnameVerifier hostnameVerifier; + + private static SSLContext createSSLContext( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final TrustStrategy trustStrategy) + throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException, KeyManagementException { + if (algorithm == null) { + algorithm = TLS; + } + KeyManagerFactory kmfactory = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + kmfactory.init(keystore, keystorePassword != null ? keystorePassword.toCharArray(): null); + KeyManager[] keymanagers = kmfactory.getKeyManagers(); + TrustManagerFactory tmfactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmfactory.init(keystore); + TrustManager[] trustmanagers = tmfactory.getTrustManagers(); + if (trustmanagers != null && trustStrategy != null) { + for (int i = 0; i < trustmanagers.length; i++) { + TrustManager tm = trustmanagers[i]; + if (tm instanceof X509TrustManager) { + trustmanagers[i] = new TrustManagerDecorator( + (X509TrustManager) tm, trustStrategy); + } + } + } + + SSLContext sslcontext = SSLContext.getInstance(algorithm); + sslcontext.init(keymanagers, trustmanagers, random); + return sslcontext; + } + + /** + * @deprecated Use {@link #SSLSocketFactory(String, KeyStore, String, KeyStore, SecureRandom, X509HostnameVerifier)} + */ + @Deprecated + public SSLSocketFactory( + final String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final HostNameResolver nameResolver) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, null), + nameResolver); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, null), + hostnameVerifier); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + String algorithm, + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore, + final SecureRandom random, + final TrustStrategy trustStrategy, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(createSSLContext( + algorithm, keystore, keystorePassword, truststore, random, trustStrategy), + hostnameVerifier); + } + + public SSLSocketFactory( + final KeyStore keystore, + final String keystorePassword, + final KeyStore truststore) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, keystore, keystorePassword, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory( + final KeyStore keystore, + final String keystorePassword) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException{ + this(TLS, keystore, keystorePassword, null, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory( + final KeyStore truststore) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final TrustStrategy trustStrategy, + final X509HostnameVerifier hostnameVerifier) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, null, null, trustStrategy, hostnameVerifier); + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final TrustStrategy trustStrategy) + throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException { + this(TLS, null, null, null, null, trustStrategy, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + public SSLSocketFactory(final SSLContext sslContext) { + this(sslContext, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER); + } + + /** + * @deprecated Use {@link #SSLSocketFactory(SSLContext)} + */ + @Deprecated + public SSLSocketFactory( + final SSLContext sslContext, final HostNameResolver nameResolver) { + super(); + this.socketfactory = sslContext.getSocketFactory(); + this.hostnameVerifier = BROWSER_COMPATIBLE_HOSTNAME_VERIFIER; + this.nameResolver = nameResolver; + } + + /** + * @since 4.1 + */ + public SSLSocketFactory( + final SSLContext sslContext, final X509HostnameVerifier hostnameVerifier) { + super(); + this.socketfactory = sslContext.getSocketFactory(); + this.hostnameVerifier = hostnameVerifier; + this.nameResolver = null; + } + + private SSLSocketFactory() { + super(); + this.socketfactory = HttpsURLConnection.getDefaultSSLSocketFactory(); + this.hostnameVerifier = null; + this.nameResolver = null; + } + + /** + * @param params Optional parameters. Parameters passed to this method will have no effect. + * This method will create a unconnected instance of {@link Socket} class + * using {@link javax.net.ssl.SSLSocketFactory#createSocket()} method. + * @since 4.1 + */ + @SuppressWarnings("cast") + public Socket createSocket(final HttpParams params) throws IOException { + // the cast makes sure that the factory is working as expected + return (SSLSocket) this.socketfactory.createSocket(); + } + + @SuppressWarnings("cast") + public Socket createSocket() throws IOException { + // the cast makes sure that the factory is working as expected + return (SSLSocket) this.socketfactory.createSocket(); + } + + /** + * @since 4.1 + */ + public Socket connectSocket( + final Socket sock, + final InetSocketAddress remoteAddress, + final InetSocketAddress localAddress, + final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException { + if (remoteAddress == null) { + throw new IllegalArgumentException("Remote address may not be null"); + } + if (params == null) { + throw new IllegalArgumentException("HTTP parameters may not be null"); + } + SSLSocket sslsock = (SSLSocket) (sock != null ? sock : createSocket()); + if (localAddress != null) { +// sslsock.setReuseAddress(HttpConnectionParams.getSoReuseaddr(params)); + sslsock.bind(localAddress); + } + + int connTimeout = HttpConnectionParams.getConnectionTimeout(params); + int soTimeout = HttpConnectionParams.getSoTimeout(params); + + try { + sslsock.connect(remoteAddress, connTimeout); + } catch (SocketTimeoutException ex) { + throw new ConnectTimeoutException("Connect to " + remoteAddress.getHostName() + "/" + + remoteAddress.getAddress() + " timed out"); + } + sslsock.setSoTimeout(soTimeout); + if (this.hostnameVerifier != null) { + try { + this.hostnameVerifier.verify(remoteAddress.getHostName(), sslsock); + // verifyHostName() didn't blowup - good! + } catch (IOException iox) { + // close the socket before re-throwing the exception + try { sslsock.close(); } catch (Exception x) { /*ignore*/ } + throw iox; + } + } + return sslsock; + } + + + /** + * Checks whether a socket connection is secure. + * This factory creates TLS/SSL socket connections + * which, by default, are considered secure. + *
+ * Derived classes may override this method to perform + * runtime checks, for example based on the cypher suite. + * + * @param sock the connected socket + * + * @return true + * + * @throws IllegalArgumentException if the argument is invalid + */ + public boolean isSecure(final Socket sock) throws IllegalArgumentException { + if (sock == null) { + throw new IllegalArgumentException("Socket may not be null"); + } + // This instanceof check is in line with createSocket() above. + if (!(sock instanceof SSLSocket)) { + throw new IllegalArgumentException("Socket not created by this factory"); + } + // This check is performed last since it calls the argument object. + if (sock.isClosed()) { + throw new IllegalArgumentException("Socket is closed"); + } + return true; + } + + /** + * @since 4.1 + */ + public Socket createLayeredSocket( + final Socket socket, + final String host, + final int port, + final boolean autoClose) throws IOException, UnknownHostException { + SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket( + socket, + host, + port, + autoClose + ); + if (this.hostnameVerifier != null) { + this.hostnameVerifier.verify(host, sslSocket); + } + // verifyHostName() didn't blowup - good! + return sslSocket; + } + + @Deprecated + public void setHostnameVerifier(X509HostnameVerifier hostnameVerifier) { + if ( hostnameVerifier == null ) { + throw new IllegalArgumentException("Hostname verifier may not be null"); + } + this.hostnameVerifier = hostnameVerifier; + } + + public X509HostnameVerifier getHostnameVerifier() { + return this.hostnameVerifier; + } + + /** + * @deprecated Use {@link #connectSocket(Socket, InetSocketAddress, InetSocketAddress, HttpParams)} + */ + @Deprecated + public Socket connectSocket( + final Socket socket, + final String host, int port, + final InetAddress localAddress, int localPort, + final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException { + InetSocketAddress local = null; + if (localAddress != null || localPort > 0) { + // we need to bind explicitly + if (localPort < 0) { + localPort = 0; // indicates "any" + } + local = new InetSocketAddress(localAddress, localPort); + } + InetAddress remoteAddress; + if (this.nameResolver != null) { + remoteAddress = this.nameResolver.resolve(host); + } else { + remoteAddress = InetAddress.getByName(host); + } + InetSocketAddress remote = new InetSocketAddress(remoteAddress, port); + return connectSocket(socket, remote, local, params); + } + + /** + * @deprecated Use {@link #createLayeredSocket(Socket, String, int, boolean)} + */ + @Deprecated + public Socket createSocket( + final Socket socket, + final String host, int port, + boolean autoClose) throws IOException, UnknownHostException { + return createLayeredSocket(socket, host, port, autoClose); + } + +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java new file mode 100644 index 00000000..41d98249 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java @@ -0,0 +1,65 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package net.sourceforge.subsonic.androidapp.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.X509TrustManager; + + +/** + * @since 4.1 + */ +class TrustManagerDecorator implements X509TrustManager { + + private final X509TrustManager trustManager; + private final TrustStrategy trustStrategy; + + TrustManagerDecorator(final X509TrustManager trustManager, final TrustStrategy trustStrategy) { + super(); + this.trustManager = trustManager; + this.trustStrategy = trustStrategy; + } + + public void checkClientTrusted( + final X509Certificate[] chain, final String authType) throws CertificateException { + this.trustManager.checkClientTrusted(chain, authType); + } + + public void checkServerTrusted( + final X509Certificate[] chain, final String authType) throws CertificateException { + if (!this.trustStrategy.isTrusted(chain, authType)) { + this.trustManager.checkServerTrusted(chain, authType); + } + } + + public X509Certificate[] getAcceptedIssuers() { + return this.trustManager.getAcceptedIssuers(); + } + +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java new file mode 100644 index 00000000..4fdaaba2 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package net.sourceforge.subsonic.androidapp.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * A trust strategy that accepts self-signed certificates as trusted. Verification of all other + * certificates is done by the trust manager configured in the SSL context. + * + * @since 4.1 + */ +public class TrustSelfSignedStrategy implements TrustStrategy { + + public boolean isTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { + return true; + } + +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java new file mode 100644 index 00000000..3cf75b68 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java @@ -0,0 +1,57 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package net.sourceforge.subsonic.androidapp.service.ssl; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** + * A strategy to establish trustworthiness of certificates without consulting the trust manager + * configured in the actual SSL context. This interface can be used to override the standard + * JSSE certificate verification process. + * + * @since 4.1 + */ +public interface TrustStrategy { + + /** + * Determines whether the certificate chain can be trusted without consulting the trust manager + * configured in the actual SSL context. This method can be used to override the standard JSSE + * certificate verification process. + *

+ * Please note that, if this method returns false, the trust manager configured + * in the actual SSL context can still clear the certificate as trusted. + * + * @param chain the peer certificate chain + * @param authType the authentication type based on the client certificate + * @return true if the certificate can be trusted without verification by + * the trust manager, false otherwise. + * @throws CertificateException thrown if the certificate is not trusted or invalid. + */ + boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException; + +} -- cgit v1.2.3