diff options
Diffstat (limited to 'app/src/main/java/github/daneren2005/dsub/util')
32 files changed, 6551 insertions, 0 deletions
diff --git a/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java b/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java new file mode 100644 index 00000000..6e9b8309 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ArtistRadioBuffer.java @@ -0,0 +1,148 @@ +/* + 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 <http://www.gnu.org/licenses/>. + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; + +public class ArtistRadioBuffer { + private static final String TAG = ArtistRadioBuffer.class.getSimpleName(); + + private ScheduledExecutorService executorService; + private Runnable runnable; + private final ArrayList<MusicDirectory.Entry> buffer = new ArrayList<MusicDirectory.Entry>(); + private int lastCount = -1; + private DownloadService context; + private boolean awaitingResults = false; + private int capacity; + private int refillThreshold; + + private String artistId; + + public ArtistRadioBuffer(DownloadService context) { + this.context = context; + runnable = new Runnable() { + @Override + public void run() { + refill(); + } + }; + + // Calculate out the capacity and refill threshold based on the user's random size preference + int shuffleListSize = Integer.parseInt(Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + // ex: default 20 -> 50 + capacity = shuffleListSize * 5 / 2; + capacity = Math.min(500, capacity); + + // ex: default 20 -> 40 + refillThreshold = capacity * 4 / 5; + } + + public void setArtist(String artistId) { + if(!Util.equals(this.artistId, artistId)) { + buffer.clear(); + } + + context.clear(); + this.artistId = artistId; + awaitingResults = true; + refill(); + } + public void restoreArtist(String artistId) { + this.artistId = artistId; + awaitingResults = false; + restart(); + } + + public List<MusicDirectory.Entry> get(int size) { + // Make sure fetcher is running if needed + restart(); + + List<MusicDirectory.Entry> result = new ArrayList<MusicDirectory.Entry>(size); + synchronized (buffer) { + while (!buffer.isEmpty() && result.size() < size) { + result.add(buffer.remove(buffer.size() - 1)); + } + } + Log.i(TAG, "Taking " + result.size() + " songs from artist radio buffer. " + buffer.size() + " remaining."); + if(result.isEmpty()) { + awaitingResults = true; + } + return result; + } + + public void shutdown() { + executorService.shutdown(); + } + + private void restart() { + synchronized(buffer) { + if(buffer.size() <= refillThreshold && lastCount != 0 && (executorService == null || executorService.isShutdown())) { + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0, 10, TimeUnit.SECONDS); + } + } + } + + private void refill() { + if (buffer != null && (buffer.size() > refillThreshold || (!Util.isNetworkConnected(context) && !Util.isOffline(context)) || lastCount == 0)) { + executorService.shutdown(); + return; + } + + try { + MusicService service = MusicServiceFactory.getMusicService(context); + + // Get capacity based + int n = capacity - buffer.size(); + MusicDirectory songs = service.getRandomSongs(n, artistId, context, null); + + synchronized (buffer) { + lastCount = 0; + for(MusicDirectory.Entry entry: songs.getChildren()) { + if(!buffer.contains(entry) && entry.getRating() != 1) { + buffer.add(entry); + lastCount++; + } + } + Log.i(TAG, "Refilled artist radio buffer with " + lastCount + " songs."); + } + } catch (Exception x) { + // Give it one more try before quitting + if(lastCount != -2) { + lastCount = -2; + } else if(lastCount == -2) { + lastCount = 0; + } + Log.w(TAG, "Failed to refill artist radio buffer.", x); + } + + if(awaitingResults) { + awaitingResults = false; + context.checkDownloads(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java new file mode 100644 index 00000000..9b39ac82 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/BackgroundTask.java @@ -0,0 +1,307 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.xmlpull.v1.XmlPullParserException; + +import android.app.Activity; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.view.ErrorDialog; + +/** + * @author Sindre Mehus + */ +public abstract class BackgroundTask<T> implements ProgressListener { + private static final String TAG = BackgroundTask.class.getSimpleName(); + + private final Context context; + protected AtomicBoolean cancelled = new AtomicBoolean(false); + protected OnCancelListener cancelListener; + protected Runnable onCompletionListener = null; + protected Task task; + + private static final int DEFAULT_CONCURRENCY = 8; + private static final Collection<Thread> threads = Collections.synchronizedCollection(new ArrayList<Thread>()); + protected static final BlockingQueue<BackgroundTask.Task> queue = new LinkedBlockingQueue<BackgroundTask.Task>(10); + private static Handler handler = null; + static { + try { + handler = new Handler(Looper.getMainLooper()); + } catch(Exception e) { + // Not called from main thread + } + } + + public BackgroundTask(Context context) { + this.context = context; + + if(threads.size() < DEFAULT_CONCURRENCY) { + for(int i = threads.size(); i < DEFAULT_CONCURRENCY; i++) { + Thread thread = new Thread(new TaskRunnable(), String.format("BackgroundTask_%d", i)); + threads.add(thread); + thread.start(); + } + } + if(handler == null) { + try { + handler = new Handler(Looper.getMainLooper()); + } catch(Exception e) { + // Not called from main thread + } + } + } + + public static void stopThreads() { + for(Thread thread: threads) { + thread.interrupt(); + } + threads.clear(); + queue.clear(); + } + + protected Activity getActivity() { + return (context instanceof Activity) ? ((Activity) context) : null; + } + + protected Handler getHandler() { + return handler; + } + + public abstract void execute(); + + protected abstract T doInBackground() throws Throwable; + + protected abstract void done(T result); + + protected void error(Throwable error) { + Log.w(TAG, "Got exception: " + error, error); + Activity activity = getActivity(); + if(activity != null) { + new ErrorDialog(activity, getErrorMessage(error), true); + } + } + + protected String getErrorMessage(Throwable error) { + + if (error instanceof IOException && !Util.isNetworkConnected(context)) { + return context.getResources().getString(R.string.background_task_no_network); + } + + if (error instanceof FileNotFoundException) { + return context.getResources().getString(R.string.background_task_not_found); + } + + if (error instanceof IOException) { + return context.getResources().getString(R.string.background_task_network_error); + } + + if (error instanceof XmlPullParserException) { + return context.getResources().getString(R.string.background_task_parse_error); + } + + String message = error.getMessage(); + if (message != null) { + return message; + } + return error.getClass().getSimpleName(); + } + + public void cancel() { + if(cancelled.compareAndSet(false, true)) { + if(isRunning()) { + if(cancelListener != null) { + cancelListener.onCancel(); + } else { + task.cancel(); + } + } + + task = null; + } + } + public boolean isCancelled() { + return cancelled.get(); + } + public void setOnCancelListener(OnCancelListener listener) { + cancelListener = listener; + } + + public boolean isRunning() { + if(task == null) { + return false; + } else { + return task.isRunning(); + } + } + + @Override + public abstract void updateProgress(final String message); + + @Override + public void updateProgress(int messageId) { + updateProgress(context.getResources().getString(messageId)); + } + + public void setOnCompletionListener(Runnable onCompletionListener) { + this.onCompletionListener = onCompletionListener; + } + + protected class Task { + private Thread thread; + private AtomicBoolean taskStart = new AtomicBoolean(false); + + private void execute() throws Exception { + // Don't run if cancelled already + if(isCancelled()) { + return; + } + + try { + thread = Thread.currentThread(); + taskStart.set(true); + + final T result = doInBackground(); + if(isCancelled()) { + taskStart.set(false); + return; + } + + if(handler != null) { + handler.post(new Runnable() { + @Override + public void run() { + if(!isCancelled()) { + onDone(result); + } + + taskStart.set(false); + } + }); + } else { + taskStart.set(false); + } + } catch(InterruptedException interrupt) { + if(taskStart.get()) { + // Don't exit root thread if task cancelled + throw interrupt; + } + } catch(final Throwable t) { + if(isCancelled()) { + taskStart.set(false); + return; + } + + if(handler != null) { + handler.post(new Runnable() { + @Override + public void run() { + if(!isCancelled()) { + try { + onError(t); + } catch(Exception e) { + // Don't care + } + } + + taskStart.set(false); + } + }); + } else { + taskStart.set(false); + } + } finally { + thread = null; + } + } + + public void cancel() { + if(taskStart.compareAndSet(true, false)) { + if (thread != null) { + thread.interrupt(); + } + } + } + public boolean isCancelled() { + if(Thread.interrupted()) { + return true; + } else if(BackgroundTask.this.isCancelled()) { + return true; + } else { + return false; + } + } + public void onDone(T result) { + done(result); + + if(onCompletionListener != null) { + onCompletionListener.run(); + } + } + public void onError(Throwable t) { + error(t); + } + + public boolean isRunning() { + return taskStart.get(); + } + } + + private class TaskRunnable implements Runnable { + private boolean running = true; + + public TaskRunnable() { + + } + + @Override + public void run() { + Looper.prepare(); + while(running) { + try { + Task task = queue.take(); + task.execute(); + } catch(InterruptedException stop) { + Log.e(TAG, "Thread died"); + running = false; + threads.remove(Thread.currentThread()); + } catch(Throwable t) { + Log.e(TAG, "Unexpected crash in BackgroundTask thread", t); + } + } + } + } + + public static interface OnCancelListener { + void onCancel(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java b/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java new file mode 100644 index 00000000..ac8fa72a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/CacheCleaner.java @@ -0,0 +1,292 @@ +package github.daneren2005.dsub.util; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import android.content.Context; +import android.util.Log; +import android.os.StatFs; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MediaStoreService; + +import java.util.*; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class CacheCleaner { + + private static final String TAG = CacheCleaner.class.getSimpleName(); + private static final long MIN_FREE_SPACE = 500 * 1024L * 1024L; + private static final long MAX_COVER_ART_SPACE = 100 * 1024L * 1024L; + + private final Context context; + private final DownloadService downloadService; + private final MediaStoreService mediaStore; + + public CacheCleaner(Context context, DownloadService downloadService) { + this.context = context; + this.downloadService = downloadService; + this.mediaStore = new MediaStoreService(context); + } + + public void clean() { + new BackgroundCleanup(context).execute(); + } + public void cleanSpace() { + new BackgroundSpaceCleanup(context).execute(); + } + public void cleanPlaylists(List<Playlist> playlists) { + new BackgroundPlaylistsCleanup(context, playlists).execute(); + } + + private void deleteEmptyDirs(List<File> dirs, Set<File> undeletable) { + for (File dir : dirs) { + if (undeletable.contains(dir)) { + continue; + } + + FileUtil.deleteEmptyDir(dir); + } + } + + private long getMinimumDelete(List<File> files, List<File> pinned) { + if(files.size() == 0) { + return 0L; + } + + long cacheSizeBytes = Util.getCacheSizeMB(context) * 1024L * 1024L; + + long bytesUsedBySubsonic = 0L; + for (File file : files) { + bytesUsedBySubsonic += file.length(); + } + for (File file : pinned) { + bytesUsedBySubsonic += file.length(); + } + + // Ensure that file system is not more than 95% full. + StatFs stat = new StatFs(files.get(0).getPath()); + long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize(); + long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); + long bytesUsedFs = bytesTotalFs - bytesAvailableFs; + long minFsAvailability = bytesTotalFs - MIN_FREE_SPACE; + + long bytesToDeleteCacheLimit = Math.max(bytesUsedBySubsonic - cacheSizeBytes, 0L); + long bytesToDeleteFsLimit = Math.max(bytesUsedFs - minFsAvailability, 0L); + long bytesToDelete = Math.max(bytesToDeleteCacheLimit, bytesToDeleteFsLimit); + + Log.i(TAG, "File system : " + Util.formatBytes(bytesAvailableFs) + " of " + Util.formatBytes(bytesTotalFs) + " available"); + Log.i(TAG, "Cache limit : " + Util.formatBytes(cacheSizeBytes)); + Log.i(TAG, "Cache size before : " + Util.formatBytes(bytesUsedBySubsonic)); + Log.i(TAG, "Minimum to delete : " + Util.formatBytes(bytesToDelete)); + + return bytesToDelete; + } + + private void deleteFiles(List<File> files, Set<File> undeletable, long bytesToDelete, boolean deletePartials) { + if (files.isEmpty()) { + return; + } + + long bytesDeleted = 0L; + for (File file : files) { + if(!deletePartials && bytesDeleted > bytesToDelete) break; + + if (bytesToDelete > bytesDeleted || (deletePartials && (file.getName().endsWith(".partial") || file.getName().contains(".partial.")))) { + if (!undeletable.contains(file) && !file.getName().equals(Constants.ALBUM_ART_FILE)) { + long size = file.length(); + if (Util.delete(file)) { + bytesDeleted += size; + mediaStore.deleteFromMediaStore(file); + } + } + } + } + + Log.i(TAG, "Deleted : " + Util.formatBytes(bytesDeleted)); + } + + private void findCandidatesForDeletion(File file, List<File> files, List<File> pinned, List<File> dirs) { + if (file.isFile()) { + String name = file.getName(); + boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete."); + if (isCacheFile) { + files.add(file); + } else { + pinned.add(file); + } + } else { + // Depth-first + for (File child : FileUtil.listFiles(file)) { + findCandidatesForDeletion(child, files, pinned, dirs); + } + dirs.add(file); + } + } + + private void sortByAscendingModificationTime(List<File> files) { + Collections.sort(files, new Comparator<File>() { + @Override + public int compare(File a, File b) { + if (a.lastModified() < b.lastModified()) { + return -1; + } + if (a.lastModified() > b.lastModified()) { + return 1; + } + return 0; + } + }); + } + + private Set<File> findUndeletableFiles() { + Set<File> undeletable = new HashSet<File>(5); + + for (DownloadFile downloadFile : downloadService.getDownloads()) { + undeletable.add(downloadFile.getPartialFile()); + undeletable.add(downloadFile.getCompleteFile()); + } + + undeletable.add(FileUtil.getMusicDirectory(context)); + return undeletable; + } + + private void cleanupCoverArt(Context context) { + File dir = FileUtil.getAlbumArtDirectory(context); + + List<File> files = new ArrayList<File>(); + long bytesUsed = 0L; + for(File file: dir.listFiles()) { + if(file.isFile()) { + files.add(file); + bytesUsed += file.length(); + } + } + + // Don't waste time sorting if under limit already + if(bytesUsed < MAX_COVER_ART_SPACE) { + return; + } + + sortByAscendingModificationTime(files); + long bytesDeleted = 0L; + for(File file: files) { + // End as soon as the space used is below the threshold + if(bytesUsed < MAX_COVER_ART_SPACE) { + break; + } + + long bytes = file.length(); + if(file.delete()) { + bytesUsed -= bytes; + bytesDeleted += bytes; + } + } + + Log.i(TAG, "Deleted " + Util.formatBytes(bytesDeleted) + " worth of cover art"); + } + + private class BackgroundCleanup extends SilentBackgroundTask<Void> { + public BackgroundCleanup(Context context) { + super(context); + } + + @Override + protected Void doInBackground() { + if (downloadService == null) { + Log.e(TAG, "DownloadService not set. Aborting cache cleaning."); + return null; + } + + try { + List<File> files = new ArrayList<File>(); + List<File> pinned = new ArrayList<File>(); + List<File> dirs = new ArrayList<File>(); + + findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, pinned, dirs); + sortByAscendingModificationTime(files); + + Set<File> undeletable = findUndeletableFiles(); + + deleteFiles(files, undeletable, getMinimumDelete(files, pinned), true); + deleteEmptyDirs(dirs, undeletable); + + // Make sure cover art directory does not grow too large + cleanupCoverArt(context); + } catch (RuntimeException x) { + Log.e(TAG, "Error in cache cleaning.", x); + } + + return null; + } + } + + private class BackgroundSpaceCleanup extends SilentBackgroundTask<Void> { + public BackgroundSpaceCleanup(Context context) { + super(context); + } + + @Override + protected Void doInBackground() { + if (downloadService == null) { + Log.e(TAG, "DownloadService not set. Aborting cache cleaning."); + return null; + } + + try { + List<File> files = new ArrayList<File>(); + List<File> pinned = new ArrayList<File>(); + List<File> dirs = new ArrayList<File>(); + findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, pinned, dirs); + + long bytesToDelete = getMinimumDelete(files, pinned); + if(bytesToDelete > 0L) { + sortByAscendingModificationTime(files); + Set<File> undeletable = findUndeletableFiles(); + deleteFiles(files, undeletable, bytesToDelete, false); + } + } catch (RuntimeException x) { + Log.e(TAG, "Error in cache cleaning.", x); + } + + return null; + } + } + + private class BackgroundPlaylistsCleanup extends SilentBackgroundTask<Void> { + private final List<Playlist> playlists; + + public BackgroundPlaylistsCleanup(Context context, List<Playlist> playlists) { + super(context); + this.playlists = playlists; + } + + @Override + protected Void doInBackground() { + try { + String server = Util.getServerName(context); + SortedSet<File> playlistFiles = FileUtil.listFiles(FileUtil.getPlaylistDirectory(context, server)); + for (Playlist playlist : playlists) { + playlistFiles.remove(FileUtil.getPlaylistFile(context, server, playlist.getName())); + } + + for(File playlist : playlistFiles) { + playlist.delete(); + } + } catch (RuntimeException x) { + Log.e(TAG, "Error in playlist cache cleaning.", x); + } + + return null; + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Constants.java b/app/src/main/java/github/daneren2005/dsub/util/Constants.java new file mode 100644 index 00000000..31c5bef2 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Constants.java @@ -0,0 +1,206 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public final class Constants { + + // Character encoding used throughout. + public static final String UTF_8 = "UTF-8"; + + // REST protocol version and client ID. + // Note: Keep it as low as possible to maintain compatibility with older servers. + public static final String REST_PROTOCOL_VERSION = "1.2.0"; + public static final String REST_CLIENT_ID = "DSub"; + public static final String CHROMECAST_CLIENT_ID = "DSubCC"; + public static final String LAST_VERSION = "subsonic.version"; + + // Names for intent extras. + public static final String INTENT_EXTRA_NAME_ID = "subsonic.id"; + public static final String INTENT_EXTRA_NAME_NAME = "subsonic.name"; + public static final String INTENT_EXTRA_NAME_DIRECTORY = "subsonic.directory"; + public static final String INTENT_EXTRA_NAME_CHILD_ID = "subsonic.child.id"; + public static final String INTENT_EXTRA_NAME_ARTIST = "subsonic.artist"; + public static final String INTENT_EXTRA_NAME_TITLE = "subsonic.title"; + public static final String INTENT_EXTRA_NAME_AUTOPLAY = "subsonic.playall"; + public static final String INTENT_EXTRA_NAME_QUERY = "subsonic.query"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_ID = "subsonic.playlist.id"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_NAME = "subsonic.playlist.name"; + public static final String INTENT_EXTRA_NAME_PLAYLIST_OWNER = "subsonic.playlist.isOwner"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_TYPE = "subsonic.albumlisttype"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_EXTRA = "subsonic.albumlistextra"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_SIZE = "subsonic.albumlistsize"; + public static final String INTENT_EXTRA_NAME_ALBUM_LIST_OFFSET = "subsonic.albumlistoffset"; + public static final String INTENT_EXTRA_NAME_SHUFFLE = "subsonic.shuffle"; + public static final String INTENT_EXTRA_REQUEST_SEARCH = "subsonic.requestsearch"; + public static final String INTENT_EXTRA_NAME_EXIT = "subsonic.exit" ; + public static final String INTENT_EXTRA_NAME_DOWNLOAD = "subsonic.download"; + public static final String INTENT_EXTRA_NAME_DOWNLOAD_VIEW = "subsonic.download_view"; + public static final String INTENT_EXTRA_VIEW_ALBUM = "subsonic.view_album"; + public static final String INTENT_EXTRA_NAME_PODCAST_ID = "subsonic.podcast.id"; + public static final String INTENT_EXTRA_NAME_PODCAST_NAME = "subsonic.podcast.name"; + public static final String INTENT_EXTRA_NAME_PODCAST_DESCRIPTION = "subsonic.podcast.description"; + public static final String INTENT_EXTRA_NAME_SHARE = "subsonic.share"; + public static final String INTENT_EXTRA_FRAGMENT_TYPE = "fragmentType"; + public static final String INTENT_EXTRA_REFRESH_LISTINGS = "refreshListings"; + public static final String INTENT_EXTRA_SEARCH_SONG = "searchSong"; + public static final String INTENT_EXTRA_TOP_TRACKS = "topTracks"; + public static final String INTENT_EXTRA_SHOW_ALL = "showAll"; + + // Preferences keys. + public static final String PREFERENCES_KEY_SERVER_KEY = "server"; + public static final String PREFERENCES_KEY_SERVER_COUNT = "serverCount"; + public static final String PREFERENCES_KEY_SERVER_ADD = "serverAdd"; + public static final String PREFERENCES_KEY_SERVER_REMOVE = "serverRemove"; + public static final String PREFERENCES_KEY_SERVER_INSTANCE = "serverInstanceId"; + public static final String PREFERENCES_KEY_SERVER_NAME = "serverName"; + public static final String PREFERENCES_KEY_SERVER_URL = "serverUrl"; + public static final String PREFERENCES_KEY_SERVER_INTERNAL_URL = "serverInternalUrl"; + public static final String PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID = "serverLocalNetworkSSID"; + public static final String PREFERENCES_KEY_TEST_CONNECTION = "serverTestConnection"; + public static final String PREFERENCES_KEY_OPEN_BROWSER = "openBrowser"; + public static final String PREFERENCES_KEY_MUSIC_FOLDER_ID = "musicFolderId"; + public static final String PREFERENCES_KEY_USERNAME = "username"; + public static final String PREFERENCES_KEY_PASSWORD = "password"; + public static final String PREFERENCES_KEY_INSTALL_TIME = "installTime"; + public static final String PREFERENCES_KEY_THEME = "theme"; + public static final String PREFERENCES_KEY_FULL_SCREEN = "fullScreen"; + public static final String PREFERENCES_KEY_DISPLAY_TRACK = "displayTrack"; + public static final String PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi"; + public static final String PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile"; + public static final String PREFERENCES_KEY_MAX_VIDEO_BITRATE_WIFI = "maxVideoBitrateWifi"; + public static final String PREFERENCES_KEY_MAX_VIDEO_BITRATE_MOBILE = "maxVideoBitrateMobile"; + public static final String PREFERENCES_KEY_NETWORK_TIMEOUT = "networkTimeout"; + public static final String PREFERENCES_KEY_CACHE_SIZE = "cacheSize"; + public static final String PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation"; + public static final String PREFERENCES_KEY_PRELOAD_COUNT_WIFI = "preloadCountWifi"; + public static final String PREFERENCES_KEY_PRELOAD_COUNT_MOBILE = "preloadCountMobile"; + public static final String PREFERENCES_KEY_HIDE_MEDIA = "hideMedia"; + public static final String PREFERENCES_KEY_MEDIA_BUTTONS = "mediaButtons"; + public static final String PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD = "screenLitOnDownload"; + public static final String PREFERENCES_KEY_SCROBBLE = "scrobble"; + public static final String PREFERENCES_KEY_REPEAT_MODE = "repeatMode"; + public static final String PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD = "wifiRequiredForDownload"; + public static final String PREFERENCES_KEY_RANDOM_SIZE = "randomSize"; + public static final String PREFERENCES_KEY_SLEEP_TIMER_DURATION = "sleepTimerDuration"; + public static final String PREFERENCES_KEY_OFFLINE = "offline"; + public static final String PREFERENCES_KEY_TEMP_LOSS = "tempLoss"; + public static final String PREFERENCES_KEY_SHUFFLE_START_YEAR = "startYear"; + public static final String PREFERENCES_KEY_SHUFFLE_END_YEAR = "endYear"; + public static final String PREFERENCES_KEY_SHUFFLE_GENRE = "genre"; + public static final String PREFERENCES_KEY_KEEP_SCREEN_ON = "keepScreenOn"; + public static final String PREFERENCES_EQUALIZER_ON = "equalizerOn"; + public static final String PREFERENCES_EQUALIZER_SETTINGS = "equalizerSettings"; + public static final String PREFERENCES_KEY_PERSISTENT_NOTIFICATION = "persistentNotification"; + public static final String PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback"; + public static final String PREFERENCES_KEY_REMOVE_PLAYED = "removePlayed"; + public static final String PREFERENCES_KEY_SHUFFLE_MODE = "shuffleMode2"; + public static final String PREFERENCES_KEY_SHUFFLE_MODE_EXTRA = "shuffleModeExtra"; + public static final String PREFERENCES_KEY_CHAT_REFRESH = "chatRefreshRate"; + public static final String PREFERENCES_KEY_CHAT_ENABLED = "chatEnabled"; + public static final String PREFERENCES_KEY_VIDEO_PLAYER = "videoPlayer"; + public static final String PREFERENCES_KEY_CONTROL_MODE = "remoteControlMode"; + public static final String PREFERENCES_KEY_CONTROL_ID = "remoteControlId"; + public static final String PREFERENCES_KEY_SYNC_ENABLED = "syncEnabled"; + public static final String PREFERENCES_KEY_SYNC_INTERVAL = "syncInterval"; + public static final String PREFERENCES_KEY_SYNC_WIFI = "syncWifi"; + public static final String PREFERENCES_KEY_SYNC_NOTIFICATION = "syncNotification"; + public static final String PREFERENCES_KEY_SYNC_STARRED = "syncStarred"; + public static final String PREFERENCES_KEY_SYNC_MOST_RECENT = "syncMostRecent"; + public static final String PREFERENCES_KEY_PAUSE_DISCONNECT = "pauseOnDisconnect"; + public static final String PREFERENCES_KEY_HIDE_WIDGET = "hideWidget"; + public static final String PREFERENCES_KEY_PODCASTS_ENABLED = "podcastsEnabled"; + public static final String PREFERENCES_KEY_BOOKMARKS_ENABLED = "bookmarksEnabled"; + public static final String PREFERENCES_KEY_CUSTOM_SORT_ENABLED = "customSortEnabled"; + public static final String PREFERENCES_KEY_MENU_PLAY_NEXT = "showPlayNext"; + public static final String PREFERENCES_KEY_MENU_PLAY_LAST = "showPlayLast"; + public static final String PREFERENCES_KEY_MENU_STAR = "showStar"; + public static final String PREFERENCES_KEY_MENU_SHARED = "showShared"; + public static final String PREFERENCES_KEY_SHARED_ENABLED = "sharedEnabled"; + public static final String PREFERENCES_KEY_BROWSE_TAGS = "browseTags"; + public static final String PREFERENCES_KEY_OPEN_TO_TAB = "openToTab"; + public static final String PREFERENCES_KEY_OVERRIDE_SYSTEM_LANGUAGE = "overrideSystemLanguage"; + public static final String PREFERENCES_KEY_PLAY_NOW_AFTER = "playNowAfter"; + public static final String PREFERENCES_KEY_LARGE_ALBUM_ART = "largeAlbumArt"; + public static final String PREFERENCES_KEY_ADMIN_ENABLED = "adminEnabled"; + public static final String PREFERENCES_KEY_PLAYLIST_NAME = "suggestedPlaylistName"; + public static final String PREFERENCES_KEY_PLAYLIST_ID = "suggestedPlaylistId"; + public static final String PREFERENCES_KEY_SERVER_SYNC = "serverSync"; + public static final String PREFERENCES_KEY_RECENT_COUNT = "mostRecentCount"; + public static final String PREFERENCES_KEY_MENU_RATING = "showRating"; + public static final String PREFERENCES_KEY_REPLAY_GAIN = "replayGain"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_BUMP = "replayGainBump2"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_UNTAGGED = "replayGainUntagged2"; + public static final String PREFERENCES_KEY_REPLAY_GAIN_TYPE= "replayGainType"; + public static final String PREFERENCES_KEY_ALBUMS_PER_FOLDER = "albumsPerFolder"; + public static final String PREFERENCES_KEY_CAST_PROXY = "castProxy"; + public static final String PREFERENCES_KEY_DISABLE_EXIT_PROMPT = "disableExitPrompt"; + public static final String PREFERENCES_KEY_RENAME_DUPLICATES = "renameDuplicates"; + public static final String PREFERENCES_KEY_FIRST_LEVEL_ARTIST = "firstLevelArtist"; + public static final String PREFERENCES_KEY_START_ON_HEADPHONES = "startOnHeadphones"; + + public static final String OFFLINE_SCROBBLE_COUNT = "scrobbleCount"; + public static final String OFFLINE_SCROBBLE_ID = "scrobbleID"; + public static final String OFFLINE_SCROBBLE_SEARCH = "scrobbleTitle"; + public static final String OFFLINE_SCROBBLE_TIME = "scrobbleTime"; + public static final String OFFLINE_STAR_COUNT = "starCount"; + public static final String OFFLINE_STAR_ID = "starID"; + public static final String OFFLINE_STAR_SEARCH = "starTitle"; + public static final String OFFLINE_STAR_SETTING = "starSetting"; + + public static final String CACHE_KEY_IGNORE = "ignoreArticles"; + + public static final String MAIN_BACK_STACK = "backStackIds"; + public static final String MAIN_BACK_STACK_SIZE = "backStackIdsSize"; + public static final String FRAGMENT_LIST = "fragmentList"; + public static final String FRAGMENT_LIST2 = "fragmentList2"; + public static final String FRAGMENT_EXTRA = "fragmentExtra"; + public static final String FRAGMENT_DOWNLOAD_FLIPPER = "fragmentDownloadFlipper"; + public static final String FRAGMENT_NAME = "fragmentName"; + public static final String FRAGMENT_POSITION = "fragmentPosition"; + + // Name of the preferences file. + public static final String PREFERENCES_FILE_NAME = "github.daneren2005.dsub_preferences"; + public static final String OFFLINE_SYNC_NAME = "github.daneren2005.dsub.offline"; + public static final String OFFLINE_SYNC_DEFAULT = "syncDefaults"; + + // Account prefs + public static final String SYNC_ACCOUNT_NAME = "Subsonic Account"; + public static final String SYNC_ACCOUNT_TYPE = "subsonic.org"; + public static final String SYNC_ACCOUNT_PLAYLIST_AUTHORITY = "github.daneren2005.dsub.playlists.provider"; + public static final String SYNC_ACCOUNT_PODCAST_AUTHORITY = "github.daneren2005.dsub.podcasts.provider"; + public static final String SYNC_ACCOUNT_STARRED_AUTHORITY = "github.daneren2005.dsub.starred.provider"; + public static final String SYNC_ACCOUNT_MOST_RECENT_AUTHORITY = "github.daneren2005.dsub.mostrecent.provider"; + + public static final String TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"; + + // Number of free trial days for non-licensed servers. + public static final int FREE_TRIAL_DAYS = 30; + + // URL for project donations. + public static final String DONATION_URL = "http://subsonic.org/pages/android-donation.jsp"; + + public static final String ALBUM_ART_FILE = "albumart.jpg"; + + private Constants() { + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java b/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java new file mode 100644 index 00000000..990eae06 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/FileUtil.java @@ -0,0 +1,860 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.Iterator; +import java.util.List; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; +import android.os.Environment; +import android.support.v4.content.ContextCompat; +import android.util.Log; +import github.daneren2005.dsub.domain.Artist; +import github.daneren2005.dsub.domain.Genre; +import github.daneren2005.dsub.domain.Indexes; +import github.daneren2005.dsub.domain.Playlist; +import github.daneren2005.dsub.domain.PodcastChannel; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.MusicFolder; +import github.daneren2005.dsub.domain.PodcastEpisode; +import github.daneren2005.dsub.service.MediaStoreService; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; + +/** + * @author Sindre Mehus + */ +public class FileUtil { + + private static final String TAG = FileUtil.class.getSimpleName(); + private static final String[] FILE_SYSTEM_UNSAFE = {"/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|"}; + private static final String[] FILE_SYSTEM_UNSAFE_DIR = {"\\", "..", ":", "\"", "?", "*", "<", ">", "|"}; + private static final List<String> MUSIC_FILE_EXTENSIONS = Arrays.asList("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma"); + private static final List<String> VIDEO_FILE_EXTENSIONS = Arrays.asList("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv"); + private static final List<String> PLAYLIST_FILE_EXTENSIONS = Arrays.asList("m3u"); + private static File DEFAULT_MUSIC_DIR; + private static final Kryo kryo = new Kryo(); + private static HashMap<String, MusicDirectory.Entry> entryLookup; + + static { + kryo.register(MusicDirectory.Entry.class); + kryo.register(Indexes.class); + kryo.register(Artist.class); + kryo.register(MusicFolder.class); + kryo.register(PodcastChannel.class); + kryo.register(Playlist.class); + kryo.register(Genre.class); + } + + public static File getAnySong(Context context) { + File dir = getMusicDirectory(context); + return getAnySong(context, dir); + } + private static File getAnySong(Context context, File dir) { + for(File file: dir.listFiles()) { + if(file.isDirectory()) { + return getAnySong(context, file); + } + + String extension = getExtension(file.getName()); + if(MUSIC_FILE_EXTENSIONS.contains(extension)) { + return file; + } + } + + return null; + } + + public static File getEntryFile(Context context, MusicDirectory.Entry entry) { + if(entry.isDirectory()) { + return getAlbumDirectory(context, entry); + } else { + return getSongFile(context, entry); + } + } + + public static File getSongFile(Context context, MusicDirectory.Entry song) { + File dir = getAlbumDirectory(context, song); + + StringBuilder fileName = new StringBuilder(); + Integer track = song.getTrack(); + if (track != null) { + if (track < 10) { + fileName.append("0"); + } + fileName.append(track).append("-"); + } + + fileName.append(fileSystemSafe(song.getTitle())).append("."); + + if(song.isVideo()) { + String videoPlayerType = Util.getVideoPlayerType(context); + if("hls".equals(videoPlayerType)) { + // HLS should be able to transcode to mp4 automatically + fileName.append("mp4"); + } else if("raw".equals(videoPlayerType)) { + // Download the original video without any transcoding + fileName.append(song.getSuffix()); + } + } else { + if (song.getTranscodedSuffix() != null) { + fileName.append(song.getTranscodedSuffix()); + } else { + fileName.append(song.getSuffix()); + } + } + + return new File(dir, fileName.toString()); + } + + public static File getPlaylistFile(Context context, String server, String name) { + File playlistDir = getPlaylistDirectory(context, server); + return new File(playlistDir, fileSystemSafe(name) + ".m3u"); + } + public static void writePlaylistFile(Context context, File file, MusicDirectory playlist) throws IOException { + FileWriter fw = new FileWriter(file); + BufferedWriter bw = new BufferedWriter(fw); + try { + fw.write("#EXTM3U\n"); + for (MusicDirectory.Entry e : playlist.getChildren()) { + String filePath = FileUtil.getSongFile(context, e).getAbsolutePath(); + if(! new File(filePath).exists()){ + String ext = FileUtil.getExtension(filePath); + String base = FileUtil.getBaseName(filePath); + filePath = base + ".complete." + ext; + } + fw.write(filePath + "\n"); + } + } catch(Exception e) { + Log.w(TAG, "Failed to save playlist: " + playlist.getName()); + } finally { + bw.close(); + fw.close(); + } + } + public static File getPlaylistDirectory(Context context) { + File playlistDir = new File(getSubsonicDirectory(context), "playlists"); + ensureDirectoryExistsAndIsReadWritable(playlistDir); + return playlistDir; + } + public static File getPlaylistDirectory(Context context, String server) { + File playlistDir = new File(getPlaylistDirectory(context), server); + ensureDirectoryExistsAndIsReadWritable(playlistDir); + return playlistDir; + } + + public static File getAlbumArtFile(Context context, MusicDirectory.Entry entry) { + File albumDir = getAlbumDirectory(context, entry); + File artFile; + File albumFile = getAlbumArtFile(albumDir); + File hexFile = getHexAlbumArtFile(context, albumDir); + if(albumDir.exists()) { + if(hexFile.exists()) { + hexFile.renameTo(albumFile); + } + artFile = albumFile; + } else { + artFile = hexFile; + } + return artFile; + } + + public static File getAlbumArtFile(File albumDir) { + return new File(albumDir, Constants.ALBUM_ART_FILE); + } + public static File getHexAlbumArtFile(Context context, File albumDir) { + return new File(getAlbumArtDirectory(context), Util.md5Hex(albumDir.getPath()) + ".jpeg"); + } + + public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size) { + File albumArtFile = getAlbumArtFile(context, entry); + if (albumArtFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(albumArtFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(albumArtFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size); + } + return null; + } + + public static File getAvatarDirectory(Context context) { + File avatarDir = new File(getSubsonicDirectory(context), "avatars"); + ensureDirectoryExistsAndIsReadWritable(avatarDir); + ensureDirectoryExistsAndIsReadWritable(new File(avatarDir, ".nomedia")); + return avatarDir; + } + + public static File getAvatarFile(Context context, String username) { + return new File(getAvatarDirectory(context), Util.md5Hex(username) + ".jpeg"); + } + + public static Bitmap getAvatarBitmap(Context context, String username, int size) { + File avatarFile = getAvatarFile(context, username); + if (avatarFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(avatarFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(avatarFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size, false); + } + return null; + } + + public static File getMiscDirectory(Context context) { + File dir = new File(getSubsonicDirectory(context), "misc"); + ensureDirectoryExistsAndIsReadWritable(dir); + ensureDirectoryExistsAndIsReadWritable(new File(dir, ".nomedia")); + return dir; + } + + public static File getMiscFile(Context context, String url) { + return new File(getMiscDirectory(context), Util.md5Hex(url) + ".jpeg"); + } + + public static Bitmap getMiscBitmap(Context context, String url, int size) { + File avatarFile = getMiscFile(context, url); + if (avatarFile.exists()) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeFile(avatarFile.getPath(), opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + + Bitmap bitmap = BitmapFactory.decodeFile(avatarFile.getPath(), opt); + return bitmap == null ? null : getScaledBitmap(bitmap, size, false); + } + return null; + } + + public static Bitmap getSampledBitmap(byte[] bytes, int size) { + return getSampledBitmap(bytes, size, true); + } + public static Bitmap getSampledBitmap(byte[] bytes, int size, boolean allowUnscaled) { + final BitmapFactory.Options opt = new BitmapFactory.Options(); + opt.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt); + opt.inPurgeable = true; + opt.inSampleSize = Util.calculateInSampleSize(opt, size, Util.getScaledHeight(opt.outHeight, opt.outWidth, size)); + opt.inJustDecodeBounds = false; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, opt); + if(bitmap == null) { + return null; + } else { + return getScaledBitmap(bitmap, size, allowUnscaled); + } + } + public static Bitmap getScaledBitmap(Bitmap bitmap, int size) { + return getScaledBitmap(bitmap, size, true); + } + public static Bitmap getScaledBitmap(Bitmap bitmap, int size, boolean allowUnscaled) { + // Don't waste time scaling if the difference is minor + // Large album arts still need to be scaled since displayed as is on now playing! + if(allowUnscaled && size < 400 && bitmap.getWidth() < (size * 1.1)) { + return bitmap; + } else { + return Bitmap.createScaledBitmap(bitmap, size, Util.getScaledHeight(bitmap, size), true); + } + } + + public static File getAlbumArtDirectory(Context context) { + File albumArtDir = new File(getSubsonicDirectory(context), "artwork"); + ensureDirectoryExistsAndIsReadWritable(albumArtDir); + ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia")); + return albumArtDir; + } + + public static File getArtistDirectory(Context context, Artist artist) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getName())); + return dir; + } + public static File getArtistDirectory(Context context, MusicDirectory.Entry artist) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(artist.getTitle())); + return dir; + } + + public static File getAlbumDirectory(Context context, MusicDirectory.Entry entry) { + File dir = null; + if (entry.getPath() != null) { + File f = new File(fileSystemSafeDir(entry.getPath())); + dir = new File(getMusicDirectory(context).getPath() + "/" + (entry.isDirectory() ? f.getPath() : f.getParent())); + } else { + MusicDirectory.Entry firstSong; + if(!Util.isOffline(context)) { + firstSong = lookupChild(context, entry, false); + if(firstSong != null) { + File songFile = FileUtil.getSongFile(context, firstSong); + dir = songFile.getParentFile(); + } + } + + if(dir == null) { + String artist = fileSystemSafe(entry.getArtist()); + String album = fileSystemSafe(entry.getAlbum()); + if("unnamed".equals(album)) { + album = fileSystemSafe(entry.getTitle()); + } + dir = new File(getMusicDirectory(context).getPath() + "/" + artist + "/" + album); + } + } + return dir; + } + + public static MusicDirectory.Entry lookupChild(Context context, MusicDirectory.Entry entry, boolean allowDir) { + // Initialize lookupMap if first time called + String lookupName = Util.getCacheName(context, "entryLookup"); + if(entryLookup == null) { + entryLookup = deserialize(context, lookupName, HashMap.class); + + // Create it if + if(entryLookup == null) { + entryLookup = new HashMap<String, MusicDirectory.Entry>(); + } + } + + // Check if this lookup has already been done before + MusicDirectory.Entry child = entryLookup.get(entry.getId()); + if(child != null) { + return child; + } + + // Do a special lookup since 4.7+ doesn't match artist/album to entry.getPath + String s = Util.getRestUrl(context, null, false) + entry.getId(); + String cacheName = (Util.isTagBrowsing(context) ? "album-" : "directory-") + s.hashCode() + ".ser"; + MusicDirectory entryDir = FileUtil.deserialize(context, cacheName, MusicDirectory.class); + + if(entryDir != null) { + List<MusicDirectory.Entry> songs = entryDir.getChildren(allowDir, true); + if(songs.size() > 0) { + child = songs.get(0); + entryLookup.put(entry.getId(), child); + serialize(context, entryLookup, lookupName); + return child; + } + } + + return null; + } + + public static String getPodcastPath(Context context, PodcastEpisode episode) { + return fileSystemSafe(episode.getArtist()) + "/" + fileSystemSafe(episode.getTitle()); + } + public static File getPodcastFile(Context context, String server) { + File dir = getPodcastDirectory(context); + return new File(dir.getPath() + "/" + fileSystemSafe(server)); + } + public static File getPodcastDirectory(Context context) { + File dir = new File(context.getCacheDir(), "podcasts"); + ensureDirectoryExistsAndIsReadWritable(dir); + return dir; + } + public static File getPodcastDirectory(Context context, PodcastChannel channel) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(channel.getName())); + return dir; + } + public static File getPodcastDirectory(Context context, String channel) { + File dir = new File(getMusicDirectory(context).getPath() + "/" + fileSystemSafe(channel)); + return dir; + } + + public static void createDirectoryForParent(File file) { + File dir = file.getParentFile(); + if (!dir.exists()) { + if (!dir.mkdirs()) { + Log.e(TAG, "Failed to create directory " + dir); + } + } + } + + private static File createDirectory(Context context, String name) { + File dir = new File(getSubsonicDirectory(context), name); + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "Failed to create " + name); + } + return dir; + } + + public static File getSubsonicDirectory(Context context) { + return context.getExternalFilesDir(null); + } + + public static File getDefaultMusicDirectory(Context context) { + if(DEFAULT_MUSIC_DIR == null) { + File[] dirs; + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + dirs = context.getExternalMediaDirs(); + } else { + dirs = ContextCompat.getExternalFilesDirs(context, null); + } + + DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music"); + Log.d(TAG, "Default: " + DEFAULT_MUSIC_DIR.getAbsolutePath()); + + if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) { + Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR); + + // Some devices seem to have screwed up the new media directory API. Go figure. Try again with standard locations + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + dirs = ContextCompat.getExternalFilesDirs(context, null); + + DEFAULT_MUSIC_DIR = new File(getBestDir(dirs), "music"); + if (!DEFAULT_MUSIC_DIR.exists() && !DEFAULT_MUSIC_DIR.mkdirs()) { + Log.e(TAG, "Failed to create default dir " + DEFAULT_MUSIC_DIR); + } else { + Log.w(TAG, "Stupid OEM's messed up media dir API added in 5.0"); + } + } + } + } + + return DEFAULT_MUSIC_DIR; + } + private static File getBestDir(File[] dirs) { + // Past 5.0 we can query directly for SD Card + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + for(int i = 0; i < dirs.length; i++) { + if(dirs[i] != null && Environment.isExternalStorageRemovable(dirs[i])) { + return dirs[i]; + } + } + } + + // Before 5.0, we have to guess. Most of the time the SD card is last + for(int i = dirs.length - 1; i >= 0; i--) { + if(dirs[i] != null) { + return dirs[i]; + } + } + + // Should be impossible to be reached + return dirs[0]; + } + + public static File getMusicDirectory(Context context) { + String path = Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, getDefaultMusicDirectory(context).getPath()); + File dir = new File(path); + return ensureDirectoryExistsAndIsReadWritable(dir) ? dir : getDefaultMusicDirectory(context); + } + public static boolean deleteMusicDirectory(Context context) { + File musicDirectory = FileUtil.getMusicDirectory(context); + MediaStoreService mediaStore = new MediaStoreService(context); + return recursiveDelete(musicDirectory, mediaStore); + } + public static void deleteSerializedCache(Context context) { + for(File file: context.getCacheDir().listFiles()) { + if(file.getName().indexOf(".ser") != -1) { + file.delete(); + } + } + } + public static boolean deleteArtworkCache(Context context) { + File artDirectory = FileUtil.getAlbumArtDirectory(context); + return recursiveDelete(artDirectory); + } + public static boolean deleteAvatarCache(Context context) { + File artDirectory = FileUtil.getAvatarDirectory(context); + return recursiveDelete(artDirectory); + } + + public static boolean recursiveDelete(File dir) { + return recursiveDelete(dir, null); + } + public static boolean recursiveDelete(File dir, MediaStoreService mediaStore) { + if (dir != null && dir.exists()) { + File[] list = dir.listFiles(); + if(list != null) { + for(File file: list) { + if(file.isDirectory()) { + if(!recursiveDelete(file, mediaStore)) { + return false; + } + } else if(file.exists()) { + if(!file.delete()) { + return false; + } else if(mediaStore != null) { + mediaStore.deleteFromMediaStore(file); + } + } + } + } + return dir.delete(); + } + return false; + } + + public static void deleteEmptyDir(File dir) { + try { + File[] children = dir.listFiles(); + if(children == null) { + return; + } + + // No songs left in the folder + if (children.length == 1 && children[0].getPath().equals(FileUtil.getAlbumArtFile(dir).getPath())) { + Util.delete(children[0]); + children = dir.listFiles(); + } + + // Delete empty directory + if (children.length == 0) { + Util.delete(dir); + } + } catch(Exception e) { + Log.w(TAG, "Error while trying to delete empty dir", e); + } + } + + public static void unpinSong(Context context, File saveFile) { + // Don't try to unpin a song which isn't actually pinned + if(saveFile.getName().contains(".complete")) { + return; + } + + // Unpin file, rename to .complete + File completeFile = new File(saveFile.getParent(), FileUtil.getBaseName(saveFile.getName()) + + ".complete." + FileUtil.getExtension(saveFile.getName())); + + if(!saveFile.renameTo(completeFile)) { + Log.w(TAG, "Failed to upin " + saveFile + " to " + completeFile); + } else { + try { + new MediaStoreService(context).renameInMediaStore(completeFile, saveFile); + } catch(Exception e) { + Log.w(TAG, "Failed to write to media store"); + } + } + } + + public static boolean ensureDirectoryExistsAndIsReadWritable(File dir) { + if (dir == null) { + return false; + } + + if (dir.exists()) { + if (!dir.isDirectory()) { + Log.w(TAG, dir + " exists but is not a directory."); + return false; + } + } else { + if (dir.mkdirs()) { + Log.i(TAG, "Created directory " + dir); + } else { + Log.w(TAG, "Failed to create directory " + dir); + return false; + } + } + + if (!dir.canRead()) { + Log.w(TAG, "No read permission for directory " + dir); + return false; + } + + if (!dir.canWrite()) { + Log.w(TAG, "No write permission for directory " + dir); + return false; + } + return true; + } + public static boolean verifyCanWrite(File dir) { + if(ensureDirectoryExistsAndIsReadWritable(dir)) { + try { + File tmp = new File(dir, "checkWrite"); + tmp.createNewFile(); + if(tmp.exists()) { + if(tmp.delete()) { + return true; + } else { + Log.w(TAG, "Failed to delete temp file, retrying"); + + // This should never be reached since this is a file DSub created! + Thread.sleep(100L); + tmp = new File(dir, "checkWrite"); + if(tmp.delete()) { + return true; + } else { + Log.w(TAG, "Failed retry to delete temp file"); + return false; + } + } + } else { + Log.w(TAG, "Temp file does not actually exist"); + return false; + } + } catch(Exception e) { + Log.w(TAG, "Failed to create tmp file", e); + return false; + } + } else { + return false; + } + } + + /** + * Makes a given filename safe by replacing special characters like slashes ("/" and "\") + * with dashes ("-"). + * + * @param filename The filename in question. + * @return The filename with special characters replaced by hyphens. + */ + private static String fileSystemSafe(String filename) { + if (filename == null || filename.trim().length() == 0) { + return "unnamed"; + } + + for (String s : FILE_SYSTEM_UNSAFE) { + filename = filename.replace(s, "-"); + } + return filename; + } + + /** + * Makes a given filename safe by replacing special characters like colons (":") + * with dashes ("-"). + * + * @param path The path of the directory in question. + * @return The the directory name with special characters replaced by hyphens. + */ + private static String fileSystemSafeDir(String path) { + if (path == null || path.trim().length() == 0) { + return ""; + } + + for (String s : FILE_SYSTEM_UNSAFE_DIR) { + path = path.replace(s, "-"); + } + return path; + } + + /** + * Similar to {@link File#listFiles()}, but returns a sorted set. + * Never returns {@code null}, instead a warning is logged, and an empty set is returned. + */ + public static SortedSet<File> listFiles(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + Log.w(TAG, "Failed to list children for " + dir.getPath()); + return new TreeSet<File>(); + } + + return new TreeSet<File>(Arrays.asList(files)); + } + + public static SortedSet<File> listMediaFiles(File dir) { + SortedSet<File> files = listFiles(dir); + Iterator<File> iterator = files.iterator(); + while (iterator.hasNext()) { + File file = iterator.next(); + if (!file.isDirectory() && !isMediaFile(file)) { + iterator.remove(); + } + } + return files; + } + + private static boolean isMediaFile(File file) { + String extension = getExtension(file.getName()); + return MUSIC_FILE_EXTENSIONS.contains(extension) || VIDEO_FILE_EXTENSIONS.contains(extension); + } + + public static boolean isMusicFile(File file) { + String extension = getExtension(file.getName()); + return MUSIC_FILE_EXTENSIONS.contains(extension); + } + public static boolean isVideoFile(File file) { + String extension = getExtension(file.getName()); + return VIDEO_FILE_EXTENSIONS.contains(extension); + } + + public static boolean isPlaylistFile(File file) { + String extension = getExtension(file.getName()); + return PLAYLIST_FILE_EXTENSIONS.contains(extension); + } + + /** + * Returns the extension (the substring after the last dot) of the given file. The dot + * is not included in the returned extension. + * + * @param name The filename in question. + * @return The extension, or an empty string if no extension is found. + */ + public static String getExtension(String name) { + int index = name.lastIndexOf('.'); + return index == -1 ? "" : name.substring(index + 1).toLowerCase(); + } + + /** + * Returns the base name (the substring before the last dot) of the given file. The dot + * is not included in the returned basename. + * + * @param name The filename in question. + * @return The base name, or an empty string if no basename is found. + */ + public static String getBaseName(String name) { + int index = name.lastIndexOf('.'); + return index == -1 ? name : name.substring(0, index); + } + + public static Pair<Long, Long> getUsedSize(Context context, File file) { + long number = 0L; + long size = 0L; + + if(file.isFile()) { + if(isMediaFile(file)) { + return new Pair<Long, Long>(1L, file.length()); + } else { + return new Pair<Long, Long>(0L, 0L); + } + } else { + for (File child : FileUtil.listFiles(file)) { + Pair<Long, Long> pair = getUsedSize(context, child); + number += pair.getFirst(); + size += pair.getSecond(); + } + return new Pair<Long, Long>(number, size); + } + } + + public static <T extends Serializable> boolean serialize(Context context, T obj, String fileName) { + Output out = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw"); + out = new Output(new FileOutputStream(file.getFD())); + synchronized (kryo) { + kryo.writeObject(out, obj); + } + return true; + } catch (Throwable x) { + Log.w(TAG, "Failed to serialize object to " + fileName); + return false; + } finally { + Util.close(out); + } + } + + public static <T extends Serializable> T deserialize(Context context, String fileName, Class<T> tClass) { + return deserialize(context, fileName, tClass, 0); + } + + public static <T extends Serializable> T deserialize(Context context, String fileName, Class<T> tClass, int hoursOld) { + Input in = null; + try { + File file = new File(context.getCacheDir(), fileName); + if(!file.exists()) { + return null; + } + + if(hoursOld != 0) { + Date fileDate = new Date(file.lastModified()); + // Convert into hours + long age = (new Date().getTime() - fileDate.getTime()) / 1000 / 3600; + if(age > hoursOld) { + return null; + } + } + + RandomAccessFile randomFile = new RandomAccessFile(file, "r"); + + in = new Input(new FileInputStream(randomFile.getFD())); + synchronized (kryo) { + T result = kryo.readObject(in, tClass); + return result; + } + } catch(FileNotFoundException e) { + // Different error message + Log.w(TAG, "No serialization for object from " + fileName); + return null; + } catch (Throwable x) { + Log.w(TAG, "Failed to deserialize object from " + fileName); + return null; + } finally { + Util.close(in); + } + } + + public static <T extends Serializable> boolean serializeCompressed(Context context, T obj, String fileName) { + Output out = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "rw"); + out = new Output(new DeflaterOutputStream(new FileOutputStream(file.getFD()))); + synchronized (kryo) { + kryo.writeObject(out, obj); + } + return true; + } catch (Throwable x) { + Log.w(TAG, "Failed to serialize compressed object to " + fileName); + return false; + } finally { + Util.close(out); + } + } + + @TargetApi(Build.VERSION_CODES.GINGERBREAD) + public static <T extends Serializable> T deserializeCompressed(Context context, String fileName, Class<T> tClass) { + Input in = null; + try { + RandomAccessFile file = new RandomAccessFile(context.getCacheDir() + "/" + fileName, "r"); + + in = new Input(new InflaterInputStream(new FileInputStream(file.getFD()))); + synchronized (kryo) { + T result = kryo.readObject(in, tClass); + return result; + } + } catch(FileNotFoundException e) { + // Different error message + Log.w(TAG, "No serialization compressed for object from " + fileName); + return null; + } catch (Throwable x) { + Log.w(TAG, "Failed to deserialize compressed object from " + fileName); + return null; + } finally { + Util.close(in); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java b/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java new file mode 100644 index 00000000..1a0e8242 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ImageLoader.java @@ -0,0 +1,600 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Shader; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.media.RemoteControlClient; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.util.Log; +import android.support.v4.util.LruCache; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.ArtistInfo; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.ServerInfo; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; + +/** + * Asynchronous loading of images, with caching. + * <p/> + * There should normally be only one instance of this class. + * + * @author Sindre Mehus + */ +public class ImageLoader { + private static final String TAG = ImageLoader.class.getSimpleName(); + + private Context context; + private LruCache<String, Bitmap> cache; + private Handler handler; + private Bitmap nowPlaying; + private final int imageSizeDefault; + private final int imageSizeLarge; + private final int avatarSizeDefault; + private boolean clearingCache = false; + + private final static int[] COLORS = {0xFF33B5E5, 0xFFAA66CC, 0xFF99CC00, 0xFFFFBB33, 0xFFFF4444}; + + public ImageLoader(Context context) { + this.context = context; + handler = new Handler(Looper.getMainLooper()); + final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSize = maxMemory / 4; + + // Determine the density-dependent image sizes. + imageSizeDefault = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight(); + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); + avatarSizeDefault = context.getResources().getDrawable(R.drawable.ic_social_person).getIntrinsicHeight(); + + cache = new LruCache<String, Bitmap>(cacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return bitmap.getRowBytes() * bitmap.getHeight() / 1024; + } + + @Override + protected void entryRemoved(boolean evicted, String key, Bitmap oldBitmap, Bitmap newBitmap) { + if(evicted) { + if(oldBitmap != nowPlaying && key.indexOf("unknown") != 0 || clearingCache) { + if(sizeOf("", oldBitmap) > 500) { + oldBitmap.recycle(); + } + } else { + cache.put(key, oldBitmap); + } + } + } + }; + } + + public void clearCache() { + nowPlaying = null; + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + clearingCache = true; + cache.evictAll(); + clearingCache = false; + return null; + } + }.execute(); + } + + private Bitmap getUnknownImage(MusicDirectory.Entry entry, int size) { + String key; + int color; + if(entry == null) { + key = getKey("unknown", size); + color = COLORS[0]; + + return getUnknownImage(key, size, color, null, null); + } else { + key = getKey(entry.getId() + "unknown", size); + String hash; + if(entry.getAlbum() != null) { + hash = entry.getAlbum(); + } else if(entry.getArtist() != null) { + hash = entry.getArtist(); + } else { + hash = entry.getId(); + } + color = COLORS[Math.abs(hash.hashCode()) % COLORS.length]; + + return getUnknownImage(key, size, color, entry.getAlbum(), entry.getArtist()); + } + } + private Bitmap getUnknownImage(String key, int size, int color, String topText, String bottomText) { + Bitmap bitmap = cache.get(key); + if(bitmap == null) { + bitmap = createUnknownImage(size, color, topText, bottomText); + cache.put(key, bitmap); + } + + return bitmap; + } + private Bitmap createUnknownImage(int size, int primaryColor, String topText, String bottomText) { + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint color = new Paint(); + color.setColor(primaryColor); + canvas.drawRect(0, 0, size, size * 2.0f / 3.0f, color); + + color.setShader(new LinearGradient(0, 0, 0, size / 3.0f, Color.rgb(82, 82, 82), Color.BLACK, Shader.TileMode.MIRROR)); + canvas.drawRect(0, size * 2.0f / 3.0f, size, size, color); + + if(topText != null || bottomText != null) { + Paint font = new Paint(); + font.setFlags(Paint.ANTI_ALIAS_FLAG); + font.setColor(Color.WHITE); + font.setTextSize(3.0f + size * 0.07f); + + if(topText != null) { + canvas.drawText(topText, size * 0.05f, size * 0.6f, font); + } + + if(bottomText != null) { + canvas.drawText(bottomText, size * 0.05f, size * 0.8f, font); + } + } + + return bitmap; + } + + public Bitmap getCachedImage(Context context, MusicDirectory.Entry entry, boolean large) { + int size = large ? imageSizeLarge : imageSizeDefault; + if(entry == null || entry.getCoverArt() == null) { + return getUnknownImage(entry, size); + } + + Bitmap bitmap = cache.get(getKey(entry.getCoverArt(), size)); + if(bitmap == null || bitmap.isRecycled()) { + bitmap = FileUtil.getAlbumArtBitmap(context, entry, size); + String key = getKey(entry.getCoverArt(), size); + cache.put(key, bitmap); + cache.get(key); + } + + return bitmap; + } + + public SilentBackgroundTask loadImage(View view, MusicDirectory.Entry entry, boolean large, boolean crossfade) { + // TODO: If we know this a artist, try to load artist info instead + int size = large ? imageSizeLarge : imageSizeDefault; + if(entry != null && !entry.isAlbum() && ServerInfo.checkServerVersion(context, "1.11") && !Util.isOffline(context)) { + SilentBackgroundTask task = new ArtistImageTask(view.getContext(), entry, size, imageSizeLarge, large, view, crossfade); + task.execute(); + return task; + } else if(entry != null && entry.getCoverArt() == null && entry.isDirectory() && !Util.isOffline(context)) { + // Try to lookup child cover art + MusicDirectory.Entry firstChild = FileUtil.lookupChild(context, entry, true); + if(firstChild != null) { + entry.setCoverArt(firstChild.getCoverArt()); + } + } + + Bitmap bitmap; + if (entry == null || entry.getCoverArt() == null) { + bitmap = getUnknownImage(entry, size); + setImage(view, Util.createDrawableFromBitmap(context, bitmap), crossfade); + return null; + } + + bitmap = cache.get(getKey(entry.getCoverArt(), size)); + if (bitmap != null && !bitmap.isRecycled()) { + final Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(view, drawable, crossfade); + if(large) { + nowPlaying = bitmap; + } + return null; + } + + if (!large) { + setImage(view, Util.createDrawableFromBitmap(context, null), false); + } + ImageTask task = new ViewImageTask(view.getContext(), entry, size, imageSizeLarge, large, view, crossfade); + task.execute(); + return task; + } + + public SilentBackgroundTask<Void> loadImage(View view, String url, boolean large) { + Bitmap bitmap; + int size = large ? imageSizeLarge : imageSizeDefault; + if (url == null) { + String key = getKey(url + "unknown", size); + int color = COLORS[Math.abs(key.hashCode()) % COLORS.length]; + bitmap = getUnknownImage(key, size, color, null, null); + setImage(view, Util.createDrawableFromBitmap(context, bitmap), true); + return null; + } + + bitmap = cache.get(getKey(url, size)); + if (bitmap != null && !bitmap.isRecycled()) { + final Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(view, drawable, true); + return null; + } + + SilentBackgroundTask<Void> task = new ViewUrlTask(view.getContext(), view, url, size); + task.execute(); + return task; + } + + public SilentBackgroundTask<Void> loadImage(Context context, RemoteControlClient remoteControl, MusicDirectory.Entry entry) { + Bitmap bitmap; + if (entry == null || entry.getCoverArt() == null) { + bitmap = getUnknownImage(entry, imageSizeLarge); + setImage(remoteControl, Util.createDrawableFromBitmap(context, bitmap)); + return null; + } + + bitmap = cache.get(getKey(entry.getCoverArt(), imageSizeLarge)); + if (bitmap != null && !bitmap.isRecycled()) { + Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + setImage(remoteControl, drawable); + return null; + } + + setImage(remoteControl, Util.createDrawableFromBitmap(context, null)); + ImageTask task = new RemoteControlClientImageTask(context, entry, imageSizeLarge, imageSizeLarge, false, remoteControl); + task.execute(); + return task; + } + + public SilentBackgroundTask<Void> loadAvatar(Context context, ImageView view, String username) { + Bitmap bitmap = cache.get(username); + if (bitmap != null && !bitmap.isRecycled()) { + Drawable drawable = Util.createDrawableFromBitmap(this.context, bitmap); + view.setImageDrawable(drawable); + return null; + } + + SilentBackgroundTask<Void> task = new AvatarTask(context, view, username); + task.execute(); + return task; + } + + private String getKey(String coverArtId, int size) { + return coverArtId + size; + } + + private void setImage(View view, final Drawable drawable, boolean crossfade) { + if (view instanceof TextView) { + // Cross-fading is not implemented for TextView since it's not in use. It would be easy to add it, though. + TextView textView = (TextView) view; + textView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); + } else if (view instanceof ImageView) { + final ImageView imageView = (ImageView) view; + if (crossfade && drawable != null) { + Drawable existingDrawable = imageView.getDrawable(); + if (existingDrawable == null) { + Bitmap emptyImage; + if(drawable.getIntrinsicWidth() > 0 && drawable.getIntrinsicHeight() > 0) { + emptyImage = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + } else { + emptyImage = Bitmap.createBitmap(imageSizeDefault, imageSizeDefault, Bitmap.Config.ARGB_8888); + } + existingDrawable = new BitmapDrawable(context.getResources(), emptyImage); + } else if(existingDrawable instanceof TransitionDrawable) { + // This should only ever be used if user is skipping through many songs quickly + TransitionDrawable tmp = (TransitionDrawable) existingDrawable; + existingDrawable = tmp.getDrawable(tmp.getNumberOfLayers() - 1); + } + if(existingDrawable != null && drawable != null) { + Drawable[] layers = new Drawable[]{existingDrawable, drawable}; + final TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + + // Get rid of transition drawable after transition occurs + handler.postDelayed(new Runnable() { + @Override + public void run() { + // Only execute if still on same transition drawable + if (imageView.getDrawable() == transitionDrawable) { + imageView.setImageDrawable(drawable); + } + } + }, 500L); + } else { + imageView.setImageDrawable(drawable); + } + } else { + imageView.setImageDrawable(drawable); + } + } + } + + @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) + private void setImage(RemoteControlClient remoteControl, Drawable drawable) { + if(remoteControl != null && drawable != null) { + Bitmap origBitmap = ((BitmapDrawable)drawable).getBitmap(); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && origBitmap != null) { + origBitmap = origBitmap.copy(origBitmap.getConfig(), false); + } + if ( origBitmap != null && !origBitmap.isRecycled()) { + remoteControl.editMetadata(false).putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, origBitmap).apply(); + } else { + if(origBitmap != null) { + Log.e(TAG, "Tried to load a recycled bitmap."); + } + remoteControl.editMetadata(false) + .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, null) + .apply(); + } + } + } + + public abstract class ImageTask extends SilentBackgroundTask<Void> { + private final Context mContext; + private final MusicDirectory.Entry mEntry; + private final int mSize; + private final int mSaveSize; + private final boolean mIsNowPlaying; + protected Drawable mDrawable; + + public ImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying) { + super(context); + mContext = context; + mEntry = entry; + mSize = size; + mSaveSize = saveSize; + mIsNowPlaying = isNowPlaying; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getCoverArt(mContext, mEntry, mSize, null, this); + String key = getKey(mEntry.getCoverArt(), mSize); + cache.put(key, bitmap); + // Make sure key is the most recently "used" + cache.get(key); + if(mIsNowPlaying) { + nowPlaying = bitmap; + } + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + cancelled.set(true); + } + + return null; + } + } + + private class ViewImageTask extends ImageTask { + protected boolean mCrossfade; + private View mView; + + public ViewImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, View view, boolean crossfade) { + super(context, entry, size, saveSize, isNowPlaying); + + mView = view; + mCrossfade = crossfade; + } + + @Override + protected void done(Void result) { + setImage(mView, mDrawable, mCrossfade); + } + } + + private class RemoteControlClientImageTask extends ImageTask { + private RemoteControlClient mRemoteControl; + + public RemoteControlClientImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, RemoteControlClient remoteControl) { + super(context, entry, size, saveSize, isNowPlaying); + + mRemoteControl = remoteControl; + } + + @Override + protected void done(Void result) { + setImage(mRemoteControl, mDrawable); + } + } + + private class ArtistImageTask extends SilentBackgroundTask<Void> { + private final Context mContext; + private final MusicDirectory.Entry mEntry; + private final int mSize; + private final int mSaveSize; + private final boolean mIsNowPlaying; + private Drawable mDrawable; + private boolean mCrossfade; + private View mView; + + private SilentBackgroundTask subTask; + + public ArtistImageTask(Context context, MusicDirectory.Entry entry, int size, int saveSize, boolean isNowPlaying, View view, boolean crossfade) { + super(context); + mContext = context; + mEntry = entry; + mSize = size; + mSaveSize = saveSize; + mIsNowPlaying = isNowPlaying; + mView = view; + mCrossfade = crossfade; + } + + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + ArtistInfo artistInfo = musicService.getArtistInfo(mEntry.getId(), false, true, mContext, null); + String url = artistInfo.getImageUrl(); + + // Figure out whether we are going to get a artist image or the standard image + if(url != null && !"".equals(url.trim())) { + // If getting the artist image fails for any reason, retry for the standard version + subTask = new ViewUrlTask(mContext, mView, url, mSize) { + @Override + protected void failedToDownload() { + // Call loadImage so we can take advantage of all of it's logic checks + loadImage(mView, mEntry, mSize == imageSizeLarge, mCrossfade); + + // Delete subTask so it doesn't get called in done + subTask = null; + } + }; + } else { + if (mEntry != null && mEntry.getCoverArt() == null && mEntry.isDirectory() && !Util.isOffline(context)) { + // Try to lookup child cover art + MusicDirectory.Entry firstChild = FileUtil.lookupChild(context, mEntry, true); + if (firstChild != null) { + mEntry.setCoverArt(firstChild.getCoverArt()); + } + } + + if (mEntry != null && mEntry.getCoverArt() != null) { + subTask = new ViewImageTask(mContext, mEntry, mSize, mSaveSize, mIsNowPlaying, mView, mCrossfade); + } else { + // If entry is null as well, we need to just set as a blank image + Bitmap bitmap = getUnknownImage(mEntry, mSize); + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + return null; + } + } + + // Execute whichever way we decided to go + subTask.doInBackground(); + return null; + } + + @Override + public void done(Void result) { + if(subTask != null) { + subTask.done(result); + } else if(mDrawable != null) { + setImage(mView, mDrawable, mCrossfade); + } + } + } + + private class ViewUrlTask extends SilentBackgroundTask<Void> { + private final Context mContext; + private final String mUrl; + private final ImageView mView; + private Drawable mDrawable; + private int mSize; + + public ViewUrlTask(Context context, View view, String url, int size) { + super(context); + mContext = context; + mView = (ImageView) view; + mUrl = url; + mSize = size; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getBitmap(mUrl, mSize, mContext, null, this); + if(bitmap != null) { + String key = getKey(mUrl, mSize); + cache.put(key, bitmap); + // Make sure key is the most recently "used" + cache.get(key); + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } + } catch (Throwable x) { + Log.e(TAG, "Failed to download from url " + mUrl, x); + cancelled.set(true); + } + + return null; + } + + @Override + protected void done(Void result) { + if(mDrawable != null) { + mView.setImageDrawable(mDrawable); + } else { + failedToDownload(); + } + } + + protected void failedToDownload() { + + } + } + + private class AvatarTask extends SilentBackgroundTask<Void> { + private final Context mContext; + private final String mUsername; + private final ImageView mView; + private Drawable mDrawable; + + public AvatarTask(Context context, ImageView view, String username) { + super(context); + mContext = context; + mView = view; + mUsername = username; + } + + @Override + protected Void doInBackground() throws Throwable { + try { + MusicService musicService = MusicServiceFactory.getMusicService(mContext); + Bitmap bitmap = musicService.getAvatar(mUsername, avatarSizeDefault, mContext, null, this); + if(bitmap != null) { + cache.put(mUsername, bitmap); + // Make sure key is the most recently "used" + cache.get(mUsername); + + mDrawable = Util.createDrawableFromBitmap(mContext, bitmap); + } + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + cancelled.set(true); + } + + return null; + } + + @Override + protected void done(Void result) { + if(mDrawable != null) { + mView.setImageDrawable(mDrawable); + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java b/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java new file mode 100644 index 00000000..116da816 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/LoadingTask.java @@ -0,0 +1,73 @@ +package github.daneren2005.dsub.util; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.DialogInterface; + +import github.daneren2005.dsub.activity.SubsonicActivity; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class LoadingTask<T> extends BackgroundTask<T> { + + private final Activity tabActivity; + private ProgressDialog loading; + private final boolean cancellable; + + public LoadingTask(Activity activity) { + super(activity); + tabActivity = activity; + this.cancellable = true; + } + public LoadingTask(Activity activity, final boolean cancellable) { + super(activity); + tabActivity = activity; + this.cancellable = cancellable; + } + + @Override + public void execute() { + loading = ProgressDialog.show(tabActivity, "", "Loading. Please Wait...", true, cancellable, new DialogInterface.OnCancelListener() { + public void onCancel(DialogInterface dialog) { + cancel(); + } + }); + + queue.offer(task = new Task() { + @Override + public void onDone(T result) { + if(loading.isShowing()) { + loading.dismiss(); + } + done(result); + } + + @Override + public void onError(Throwable t) { + if(loading.isShowing()) { + loading.dismiss(); + } + error(t); + } + }); + } + + @Override + public boolean isCancelled() { + return (tabActivity instanceof SubsonicActivity && ((SubsonicActivity) tabActivity).isDestroyedCompat()) || cancelled.get(); + } + + @Override + public void updateProgress(final String message) { + if(!cancelled.get()) { + getHandler().post(new Runnable() { + @Override + public void run() { + loading.setMessage(message); + } + }); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java b/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java new file mode 100644 index 00000000..9aa54c4b --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/MediaRouteManager.java @@ -0,0 +1,181 @@ +/* + 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 <http://www.gnu.org/licenses/>. + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.os.Build; +import android.support.v7.media.MediaRouteProvider; +import android.support.v7.media.MediaRouteSelector; +import android.support.v7.media.MediaRouter; +import android.util.Log; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; + +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.domain.RemoteControlState; +import github.daneren2005.dsub.provider.DLNARouteProvider; +import github.daneren2005.dsub.provider.JukeboxRouteProvider; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; +import github.daneren2005.dsub.util.compat.CastCompat; + +import static android.support.v7.media.MediaRouter.RouteInfo; + +/** + * Created by owner on 2/8/14. + */ +public class MediaRouteManager extends MediaRouter.Callback { + private static final String TAG = MediaRouteManager.class.getSimpleName(); + private static boolean castAvailable = false; + + private DownloadService downloadService; + private MediaRouter router; + private MediaRouteSelector selector; + private List<MediaRouteProvider> providers = new ArrayList<MediaRouteProvider>(); + private List<MediaRouteProvider> onlineProviders = new ArrayList<MediaRouteProvider>(); + + static { + try { + CastCompat.checkAvailable(); + castAvailable = true; + } catch(Throwable t) { + castAvailable = false; + } + } + + public MediaRouteManager(DownloadService downloadService) { + this.downloadService = downloadService; + router = MediaRouter.getInstance(downloadService); + + // Check if play services is available + int result = GooglePlayServicesUtil.isGooglePlayServicesAvailable(downloadService); + if(result != ConnectionResult.SUCCESS){ + Log.w(TAG, "No play services, failed with result: " + result); + castAvailable = false; + } + + addProviders(); + buildSelector(); + } + + public void destroy() { + for(MediaRouteProvider provider: providers) { + router.removeProvider(provider); + } + } + + @Override + public void onRouteSelected(MediaRouter router, RouteInfo info) { + if(castAvailable) { + RemoteController controller = CastCompat.getController(downloadService, info); + if(controller != null) { + downloadService.setRemoteEnabled(RemoteControlState.CHROMECAST, controller); + } + } + + if(downloadService.isRemoteEnabled()) { + downloadService.registerRoute(router); + } + } + @Override + public void onRouteUnselected(MediaRouter router, RouteInfo info) { + if(downloadService.isRemoteEnabled()) { + downloadService.unregisterRoute(router); + } + + downloadService.setRemoteEnabled(RemoteControlState.LOCAL); + } + + public void setDefaultRoute() { + router.selectRoute(router.getDefaultRoute()); + } + + public void startScan() { + router.addCallback(selector, this, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN); + } + public void stopScan() { + router.removeCallback(this); + } + + public MediaRouteSelector getSelector() { + return selector; + } + + public RouteInfo getSelectedRoute() { + return router.getSelectedRoute(); + } + public RouteInfo getRouteForId(String id) { + if(id == null) { + return null; + } + + // Try to find matching id + for(RouteInfo info: router.getRoutes()) { + if(id.equals(info.getId())) { + router.selectRoute(info); + return info; + } + } + + return null; + } + public RemoteController getRemoteController(RouteInfo info) { + if(castAvailable) { + return CastCompat.getController(downloadService, info); + } else { + return null; + } + } + + public void addOnlineProviders() { + JukeboxRouteProvider jukeboxProvider = new JukeboxRouteProvider(downloadService); + router.addProvider(jukeboxProvider); + providers.add(jukeboxProvider); + onlineProviders.add(jukeboxProvider); + } + public void removeOnlineProviders() { + for(MediaRouteProvider provider: onlineProviders) { + router.removeProvider(provider); + } + } + + private void addProviders() { + if(!Util.isOffline(downloadService)) { + addOnlineProviders(); + } + + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + DLNARouteProvider dlnaProvider = new DLNARouteProvider(downloadService); + router.addProvider(dlnaProvider); + providers.add(dlnaProvider); + } + } + public void buildSelector() { + MediaRouteSelector.Builder builder = new MediaRouteSelector.Builder(); + if(UserUtil.canJukebox()) { + builder.addControlCategory(JukeboxRouteProvider.CATEGORY_JUKEBOX_ROUTE); + } + if(castAvailable) { + builder.addControlCategory(CastCompat.getCastControlCategory()); + } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + builder.addControlCategory(DLNARouteProvider.CATEGORY_DLNA); + } + selector = builder.build(); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Notifications.java b/app/src/main/java/github/daneren2005/dsub/util/Notifications.java new file mode 100644 index 00000000..d078d77e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Notifications.java @@ -0,0 +1,348 @@ +/* + 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 <http://www.gnu.org/licenses/>. + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Handler; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.RemoteViews; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.provider.DSubWidgetProvider; +import github.daneren2005.dsub.service.DownloadFile; +import github.daneren2005.dsub.service.DownloadService; + +public final class Notifications { + private static final String TAG = Notifications.class.getSimpleName(); + + // Notification IDs. + public static final int NOTIFICATION_ID_PLAYING = 100; + public static final int NOTIFICATION_ID_DOWNLOADING = 102; + public static final String NOTIFICATION_SYNC_GROUP = "github.daneren2005.dsub.sync"; + + private static boolean playShowing = false; + private static boolean downloadShowing = false; + private static boolean downloadForeground = false; + + private final static Pair<Integer, Integer> NOTIFICATION_TEXT_COLORS = new Pair<Integer, Integer>(); + + public static void showPlayingNotification(final Context context, final DownloadService downloadService, final Handler handler, MusicDirectory.Entry song) { + // Set the icon, scrolling text and timestamp + final Notification notification = new Notification(R.drawable.stat_notify_playing, song.getTitle(), System.currentTimeMillis()); + + final boolean playing = downloadService.getPlayerState() == PlayerState.STARTED; + if(playing) { + notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT; + } + boolean remote = downloadService.isRemoteEnabled(); + if (Build.VERSION.SDK_INT>= Build.VERSION_CODES.JELLY_BEAN){ + RemoteViews expandedContentView = new RemoteViews(context.getPackageName(), R.layout.notification_expanded); + setupViews(expandedContentView ,context, song, true, playing, remote); + notification.bigContentView = expandedContentView; + notification.priority = Notification.PRIORITY_HIGH; + } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + notification.visibility = Notification.VISIBILITY_PUBLIC; + } + + RemoteViews smallContentView = new RemoteViews(context.getPackageName(), R.layout.notification); + setupViews(smallContentView, context, song, false, playing, remote); + notification.contentView = smallContentView; + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD, true); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + notification.contentIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0); + + playShowing = true; + if(downloadForeground && downloadShowing) { + downloadForeground = false; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + showDownloadingNotification(context, downloadService, handler, downloadService.getCurrentDownloading(), downloadService.getBackgroundDownloads().size()); + downloadService.startForeground(NOTIFICATION_ID_PLAYING, notification); + } + }); + } else { + handler.post(new Runnable() { + @Override + public void run() { + if(playing) { + downloadService.startForeground(NOTIFICATION_ID_PLAYING, notification); + } else { + playShowing = false; + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + downloadService.stopForeground(true); + notificationManager.notify(NOTIFICATION_ID_PLAYING, notification); + } + } + }); + } + + // Update widget + DSubWidgetProvider.notifyInstances(context, downloadService, playing); + } + + private static void setupViews(RemoteViews rv, Context context, MusicDirectory.Entry song, boolean expanded, boolean playing, boolean remote){ + + // Use the same text for the ticker and the expanded notification + String title = song.getTitle(); + String arist = song.getArtist(); + String album = song.getAlbum(); + + // Set the album art. + try { + ImageLoader imageLoader = SubsonicActivity.getStaticImageLoader(context); + Bitmap bitmap = null; + if(imageLoader != null) { + bitmap = imageLoader.getCachedImage(context, song, false); + } + if (bitmap == null) { + // set default album art + rv.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } else { + rv.setImageViewBitmap(R.id.notification_image, bitmap); + } + } catch (Exception x) { + Log.w(TAG, "Failed to get notification cover art", x); + rv.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } + + // set the text for the notifications + rv.setTextViewText(R.id.notification_title, title); + rv.setTextViewText(R.id.notification_artist, arist); + rv.setTextViewText(R.id.notification_album, album); + + boolean persistent = Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_PERSISTENT_NOTIFICATION, false); + if(persistent) { + if(expanded) { + rv.setImageViewResource(R.id.control_pause, playing ? R.drawable.notification_pause : R.drawable.notification_play); + } else { + rv.setImageViewResource(R.id.control_previous, playing ? R.drawable.notification_pause : R.drawable.notification_play); + rv.setImageViewResource(R.id.control_pause, R.drawable.notification_next); + rv.setImageViewResource(R.id.control_next, R.drawable.notification_close); + } + } + + // Create actions for media buttons + PendingIntent pendingIntent; + int previous = 0, pause = 0, next = 0, close = 0; + if(persistent && !expanded) { + pause = R.id.control_previous; + next = R.id.control_pause; + close = R.id.control_next; + } else { + previous = R.id.control_previous; + pause = R.id.control_pause; + next = R.id.control_next; + } + + if((remote || persistent) && close == 0 && expanded) { + close = R.id.notification_close; + rv.setViewVisibility(close, View.VISIBLE); + } + + if(previous > 0) { + Intent prevIntent = new Intent("KEYCODE_MEDIA_PREVIOUS"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(previous, pendingIntent); + } + if(pause > 0) { + if(playing) { + Intent pauseIntent = new Intent("KEYCODE_MEDIA_PLAY_PAUSE"); + pauseIntent.setComponent(new ComponentName(context, DownloadService.class)); + pauseIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + pendingIntent = PendingIntent.getService(context, 0, pauseIntent, 0); + rv.setOnClickPendingIntent(pause, pendingIntent); + } else { + Intent prevIntent = new Intent("KEYCODE_MEDIA_START"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(pause, pendingIntent); + } + } + if(next > 0) { + Intent nextIntent = new Intent("KEYCODE_MEDIA_NEXT"); + nextIntent.setComponent(new ComponentName(context, DownloadService.class)); + nextIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)); + pendingIntent = PendingIntent.getService(context, 0, nextIntent, 0); + rv.setOnClickPendingIntent(next, pendingIntent); + } + if(close > 0) { + Intent prevIntent = new Intent("KEYCODE_MEDIA_STOP"); + prevIntent.setComponent(new ComponentName(context, DownloadService.class)); + prevIntent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_STOP)); + pendingIntent = PendingIntent.getService(context, 0, prevIntent, 0); + rv.setOnClickPendingIntent(close, pendingIntent); + } + } + + public static void hidePlayingNotification(final Context context, final DownloadService downloadService, Handler handler) { + playShowing = false; + + // Remove notification and remove the service from the foreground + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + } + }); + + // Get downloadNotification in foreground if playing + if(downloadShowing) { + showDownloadingNotification(context, downloadService, handler, downloadService.getCurrentDownloading(), downloadService.getBackgroundDownloads().size()); + } + + // Update widget + DSubWidgetProvider.notifyInstances(context, downloadService, false); + } + + public static void showDownloadingNotification(final Context context, final DownloadService downloadService, Handler handler, DownloadFile file, int size) { + Intent cancelIntent = new Intent(context, DownloadService.class); + cancelIntent.setAction(DownloadService.CANCEL_DOWNLOADS); + PendingIntent cancelPI = PendingIntent.getService(context, 0, cancelIntent, 0); + + String currentDownloading, currentSize; + if(file != null) { + currentDownloading = file.getSong().getTitle(); + currentSize = Util.formatLocalizedBytes(file.getEstimatedSize(), context); + } else { + currentDownloading = "none"; + currentSize = "0"; + } + + NotificationCompat.Builder builder; + builder = new NotificationCompat.Builder(context) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle(context.getResources().getString(R.string.download_downloading_title, size)) + .setContentText(context.getResources().getString(R.string.download_downloading_summary, currentDownloading)) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(context.getResources().getString(R.string.download_downloading_summary_expanded, currentDownloading, currentSize))) + .setProgress(10, 5, true) + .setOngoing(true) + .addAction(R.drawable.notification_close, + context.getResources().getString(R.string.common_cancel), + cancelPI); + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD, true); + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_DOWNLOAD_VIEW, true); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + builder.setContentIntent(PendingIntent.getActivity(context, 1, notificationIntent, 0)); + + final Notification notification = builder.build(); + downloadShowing = true; + if(playShowing) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(NOTIFICATION_ID_DOWNLOADING, notification); + } else { + downloadForeground = true; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.startForeground(NOTIFICATION_ID_DOWNLOADING, notification); + } + }); + } + + } + public static void hideDownloadingNotification(final Context context, final DownloadService downloadService, Handler handler) { + downloadShowing = false; + if(playShowing) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(NOTIFICATION_ID_DOWNLOADING); + } else { + downloadForeground = false; + handler.post(new Runnable() { + @Override + public void run() { + downloadService.stopForeground(true); + } + }); + } + } + + public static void showSyncNotification(final Context context, int stringId, String extra) { + if(Util.getPreferences(context).getBoolean(Constants.PREFERENCES_KEY_SYNC_NOTIFICATION, true)) { + if(extra == null) { + extra = ""; + } + + NotificationCompat.Builder builder; + builder = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.stat_notify_sync) + .setContentTitle(context.getResources().getString(stringId)) + .setContentText(extra) + .setStyle(new NotificationCompat.BigTextStyle().bigText(extra.replace(", ", "\n"))) + .setOngoing(false) + .setGroup(NOTIFICATION_SYNC_GROUP) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setAutoCancel(true); + + Intent notificationIntent = new Intent(context, SubsonicFragmentActivity.class); + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + String tab = null, type = null; + switch(stringId) { + case R.string.sync_new_albums: + type = "newest"; + break; + case R.string.sync_new_playlists: + tab = "Playlist"; + break; + case R.string.sync_new_podcasts: + tab = "Podcast"; + break; + case R.string.sync_new_starred: + type = "starred"; + break; + } + if(tab != null) { + notificationIntent.putExtra(Constants.INTENT_EXTRA_FRAGMENT_TYPE, tab); + } + if(type != null) { + notificationIntent.putExtra(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type); + } + + builder.setContentIntent(PendingIntent.getActivity(context, stringId, notificationIntent, 0)); + + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(stringId, builder.build()); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Pair.java b/app/src/main/java/github/daneren2005/dsub/util/Pair.java new file mode 100644 index 00000000..54386a62 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Pair.java @@ -0,0 +1,54 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.Serializable; + +/** + * @author Sindre Mehus + */ +public class Pair<S, T> implements Serializable { + + private S first; + private T second; + + public Pair() { + } + + public Pair(S first, T second) { + this.first = first; + this.second = second; + } + + public S getFirst() { + return first; + } + + public void setFirst(S first) { + this.first = first; + } + + public T getSecond() { + return second; + } + + public void setSecond(T second) { + this.second = second; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java b/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java new file mode 100644 index 00000000..c6d58f42 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ProgressListener.java @@ -0,0 +1,27 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +/** + * @author Sindre Mehus + */ +public interface ProgressListener { + void updateProgress(String message); + void updateProgress(int messageId); +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java b/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java new file mode 100644 index 00000000..7eb6d137 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SettingsBackupAgent.java @@ -0,0 +1,31 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus +*/ +package github.daneren2005.dsub.util; + +import android.app.backup.BackupAgentHelper; +import android.app.backup.SharedPreferencesBackupHelper; +import github.daneren2005.dsub.util.Constants; + +public class SettingsBackupAgent extends BackupAgentHelper { + public void onCreate() { + super.onCreate(); + SharedPreferencesBackupHelper helper = new SharedPreferencesBackupHelper(this, Constants.PREFERENCES_FILE_NAME); + addHelper("mypreferences", helper); + } + } diff --git a/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java b/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java new file mode 100644 index 00000000..7da35f77 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/ShufflePlayBuffer.java @@ -0,0 +1,212 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.io.File; +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.Context; +import android.content.SharedPreferences; +import android.util.Log; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.util.FileUtil; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class ShufflePlayBuffer { + + private static final String TAG = ShufflePlayBuffer.class.getSimpleName(); + private static final String CACHE_FILENAME = "shuffleBuffer.ser"; + + private ScheduledExecutorService executorService; + private Runnable runnable; + private boolean firstRun = true; + private final ArrayList<MusicDirectory.Entry> buffer = new ArrayList<MusicDirectory.Entry>(); + private int lastCount = -1; + private DownloadService context; + private boolean awaitingResults = false; + private int capacity; + private int refillThreshold; + + private SharedPreferences.OnSharedPreferenceChangeListener listener; + private int currentServer; + private String currentFolder = ""; + private String genre = ""; + private String startYear = ""; + private String endYear = ""; + + public ShufflePlayBuffer(DownloadService context) { + this.context = context; + + executorService = Executors.newSingleThreadScheduledExecutor(); + runnable = new Runnable() { + @Override + public void run() { + refill(); + } + }; + executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS); + + // Calculate out the capacity and refill threshold based on the user's random size preference + int shuffleListSize = Integer.parseInt(Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_RANDOM_SIZE, "20")); + // ex: default 20 -> 50 + capacity = shuffleListSize * 5 / 2; + capacity = Math.min(500, capacity); + + // ex: default 20 -> 40 + refillThreshold = capacity * 4 / 5; + } + + public List<MusicDirectory.Entry> get(int size) { + clearBufferIfnecessary(); + // Make sure fetcher is running if needed + restart(); + + List<MusicDirectory.Entry> result = new ArrayList<MusicDirectory.Entry>(size); + synchronized (buffer) { + boolean removed = false; + while (!buffer.isEmpty() && result.size() < size) { + result.add(buffer.remove(buffer.size() - 1)); + removed = true; + } + + // Re-cache if anything is taken out + if(removed) { + FileUtil.serialize(context, buffer, CACHE_FILENAME); + } + } + Log.i(TAG, "Taking " + result.size() + " songs from shuffle play buffer. " + buffer.size() + " remaining."); + if(result.isEmpty()) { + awaitingResults = true; + } + return result; + } + + public void shutdown() { + executorService.shutdown(); + Util.getPreferences(context).unregisterOnSharedPreferenceChangeListener(listener); + } + + private void restart() { + synchronized(buffer) { + if(buffer.size() <= refillThreshold && lastCount != 0 && executorService.isShutdown()) { + executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleWithFixedDelay(runnable, 0, 10, TimeUnit.SECONDS); + } + } + } + + private void refill() { + // Check if active server has changed. + clearBufferIfnecessary(); + + if (buffer != null && (buffer.size() > refillThreshold || (!Util.isNetworkConnected(context) && !Util.isOffline(context)) || lastCount == 0)) { + executorService.shutdown(); + return; + } + + try { + MusicService service = MusicServiceFactory.getMusicService(context); + + // Get capacity based + int n = capacity - buffer.size(); + String folder = null; + if(!Util.isTagBrowsing(context)) { + folder = Util.getSelectedMusicFolderId(context); + } + MusicDirectory songs = service.getRandomSongs(n, folder, genre, startYear, endYear, context, null); + + synchronized (buffer) { + lastCount = 0; + for(MusicDirectory.Entry entry: songs.getChildren()) { + if(!buffer.contains(entry) && entry.getRating() != 1) { + buffer.add(entry); + lastCount++; + } + } + Log.i(TAG, "Refilled shuffle play buffer with " + lastCount + " songs."); + + // Cache buffer + FileUtil.serialize(context, buffer, CACHE_FILENAME); + } + } catch (Exception x) { + // Give it one more try before quitting + if(lastCount != -2) { + lastCount = -2; + } else if(lastCount == -2) { + lastCount = 0; + } + Log.w(TAG, "Failed to refill shuffle play buffer.", x); + } + + if(awaitingResults) { + awaitingResults = false; + context.checkDownloads(); + } + } + + private void clearBufferIfnecessary() { + synchronized (buffer) { + final SharedPreferences prefs = Util.getPreferences(context); + if (currentServer != Util.getActiveServer(context) + || !Util.equals(currentFolder, Util.getSelectedMusicFolderId(context)) + || (genre != null && !genre.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, ""))) + || (startYear != null && !startYear.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, ""))) + || (endYear != null && !endYear.equals(prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, "")))) { + lastCount = -1; + currentServer = Util.getActiveServer(context); + currentFolder = Util.getSelectedMusicFolderId(context); + genre = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_GENRE, ""); + startYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_START_YEAR, ""); + endYear = prefs.getString(Constants.PREFERENCES_KEY_SHUFFLE_END_YEAR, ""); + buffer.clear(); + + if(firstRun) { + ArrayList cacheList = FileUtil.deserialize(context, CACHE_FILENAME, ArrayList.class); + if(cacheList != null) { + buffer.addAll(cacheList); + } + + listener = new SharedPreferences.OnSharedPreferenceChangeListener() { + @Override + public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { + clearBufferIfnecessary(); + restart(); + } + }; + prefs.registerOnSharedPreferenceChangeListener(listener); + firstRun = false; + } else { + // Clear cache + File file = new File(context.getCacheDir(), CACHE_FILENAME); + file.delete(); + } + } + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java new file mode 100644 index 00000000..b99b7e0e --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SilentBackgroundTask.java @@ -0,0 +1,48 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2010 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.content.Context; + +/** + * @author Sindre Mehus + */ +public abstract class SilentBackgroundTask<T> extends BackgroundTask<T> { + public SilentBackgroundTask(Context context) { + super(context); + } + + @Override + public void execute() { + queue.offer(task = new Task()); + } + + @Override + protected void done(T result) { + // Don't do anything unless overriden + } + + @Override + public void updateProgress(int messageId) { + } + + @Override + public void updateProgress(String message) { + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java b/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java new file mode 100644 index 00000000..9c0b36a9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SimpleServiceBinder.java @@ -0,0 +1,37 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.os.Binder; + +/** + * @author Sindre Mehus + */ +public class SimpleServiceBinder<S> extends Binder { + + private final S service; + + public SimpleServiceBinder(S service) { + this.service = service; + } + + public S getService() { + return service; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java b/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java new file mode 100644 index 00000000..a369715f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/SyncUtil.java @@ -0,0 +1,222 @@ +package github.daneren2005.dsub.util; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.support.v4.app.NotificationCompat; + +import java.io.File; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.activity.SubsonicFragmentActivity; + +/** + * Created by Scott on 11/24/13. + */ +public final class SyncUtil { + private static String TAG = SyncUtil.class.getSimpleName(); + private static ArrayList<SyncSet> syncedPlaylists; + private static ArrayList<SyncSet> syncedPodcasts; + private static String url; + + private static void checkRestURL(Context context) { + int instance = Util.getActiveServer(context); + String newURL = Util.getRestUrl(context, null, instance, false); + if(url == null || !url.equals(newURL)) { + syncedPlaylists = null; + syncedPodcasts = null; + url = newURL; + } + } + + // Playlist sync + public static boolean isSyncedPlaylist(Context context, String playlistId) { + checkRestURL(context); + if(syncedPlaylists == null) { + syncedPlaylists = getSyncedPlaylists(context); + } + return syncedPlaylists.contains(new SyncSet(playlistId)); + } + public static ArrayList<SyncSet> getSyncedPlaylists(Context context) { + return getSyncedPlaylists(context, Util.getActiveServer(context)); + } + public static ArrayList<SyncSet> getSyncedPlaylists(Context context, int instance) { + String syncFile = getPlaylistSyncFile(context, instance); + ArrayList<SyncSet> playlists = FileUtil.deserializeCompressed(context, syncFile, ArrayList.class); + if(playlists == null) { + playlists = new ArrayList<SyncSet>(); + + // Try to convert old style into new style + ArrayList<String> oldPlaylists = FileUtil.deserialize(context, syncFile, ArrayList.class); + // If exists, time to convert! + if(oldPlaylists != null) { + for(String id: oldPlaylists) { + playlists.add(new SyncSet(id)); + } + + FileUtil.serializeCompressed(context, playlists, syncFile); + } + } + return playlists; + } + public static void setSyncedPlaylists(Context context, int instance, ArrayList<SyncSet> playlists) { + FileUtil.serializeCompressed(context, playlists, getPlaylistSyncFile(context, instance)); + } + public static void addSyncedPlaylist(Context context, String playlistId) { + String playlistFile = getPlaylistSyncFile(context); + ArrayList<SyncSet> playlists = getSyncedPlaylists(context); + SyncSet set = new SyncSet(playlistId); + if(!playlists.contains(set)) { + playlists.add(set); + } + FileUtil.serializeCompressed(context, playlists, playlistFile); + syncedPlaylists = playlists; + } + public static void removeSyncedPlaylist(Context context, String playlistId) { + int instance = Util.getActiveServer(context); + removeSyncedPlaylist(context, playlistId, instance); + } + public static void removeSyncedPlaylist(Context context, String playlistId, int instance) { + String playlistFile = getPlaylistSyncFile(context, instance); + ArrayList<SyncSet> playlists = getSyncedPlaylists(context, instance); + SyncSet set = new SyncSet(playlistId); + if(playlists.contains(set)) { + playlists.remove(set); + FileUtil.serializeCompressed(context, playlists, playlistFile); + syncedPlaylists = playlists; + } + } + public static String getPlaylistSyncFile(Context context) { + int instance = Util.getActiveServer(context); + return getPlaylistSyncFile(context, instance); + } + public static String getPlaylistSyncFile(Context context, int instance) { + return "sync-playlist-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Podcast sync + public static boolean isSyncedPodcast(Context context, String podcastId) { + checkRestURL(context); + if(syncedPodcasts == null) { + syncedPodcasts = getSyncedPodcasts(context); + } + return syncedPodcasts.contains(new SyncSet(podcastId)); + } + public static ArrayList<SyncSet> getSyncedPodcasts(Context context) { + return getSyncedPodcasts(context, Util.getActiveServer(context)); + } + public static ArrayList<SyncSet> getSyncedPodcasts(Context context, int instance) { + ArrayList<SyncSet> podcasts = FileUtil.deserialize(context, getPodcastSyncFile(context, instance), ArrayList.class); + if(podcasts == null) { + podcasts = new ArrayList<SyncSet>(); + } + return podcasts; + } + public static void addSyncedPodcast(Context context, String podcastId, List<String> synced) { + String podcastFile = getPodcastSyncFile(context); + ArrayList<SyncSet> podcasts = getSyncedPodcasts(context); + SyncSet set = new SyncSet(podcastId, synced); + if(!podcasts.contains(set)) { + podcasts.add(set); + } + FileUtil.serialize(context, podcasts, podcastFile); + syncedPodcasts = podcasts; + } + public static void removeSyncedPodcast(Context context, String podcastId) { + removeSyncedPodcast(context, podcastId, Util.getActiveServer(context)); + } + public static void removeSyncedPodcast(Context context, String podcastId, int instance) { + String podcastFile = getPodcastSyncFile(context, instance); + ArrayList<SyncSet> podcasts = getSyncedPodcasts(context, instance); + SyncSet set = new SyncSet(podcastId); + if(podcasts.contains(set)) { + podcasts.remove(set); + FileUtil.serialize(context, podcasts, podcastFile); + syncedPodcasts = podcasts; + } + } + public static String getPodcastSyncFile(Context context) { + int instance = Util.getActiveServer(context); + return getPodcastSyncFile(context, instance); + } + public static String getPodcastSyncFile(Context context, int instance) { + return "sync-podcast-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Starred + public static ArrayList<String> getSyncedStarred(Context context, int instance) { + ArrayList<String> list = FileUtil.deserializeCompressed(context, getStarredSyncFile(context, instance), ArrayList.class); + if(list == null) { + list = new ArrayList<String>(); + } + return list; + } + public static void setSyncedStarred(ArrayList<String> syncedList, Context context, int instance) { + FileUtil.serializeCompressed(context, syncedList, SyncUtil.getStarredSyncFile(context, instance)); + } + public static String getStarredSyncFile(Context context, int instance) { + return "sync-starred-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + // Most Recently Added + public static ArrayList<String> getSyncedMostRecent(Context context, int instance) { + ArrayList<String> list = FileUtil.deserialize(context, getMostRecentSyncFile(context, instance), ArrayList.class); + if(list == null) { + list = new ArrayList<String>(); + } + return list; + } + public static void removeMostRecentSyncFiles(Context context) { + int total = Util.getServerCount(context); + for(int i = 0; i < total; i++) { + File file = new File(context.getCacheDir(), getMostRecentSyncFile(context, i)); + file.delete(); + } + } + public static String getMostRecentSyncFile(Context context, int instance) { + return "sync-most_recent-" + (Util.getRestUrl(context, null, instance, false)).hashCode() + ".ser"; + } + + public static String joinNames(List<String> names) { + StringBuilder builder = new StringBuilder(); + for (String val : names) { + builder.append(val).append(", "); + } + builder.setLength(builder.length() - 2); + return builder.toString(); + } + + public static class SyncSet implements Serializable { + public String id; + public List<String> synced; + + protected SyncSet() { + + } + public SyncSet(String id) { + this.id = id; + } + public SyncSet(String id, List<String> synced) { + this.id = id; + this.synced = synced; + } + + @Override + public boolean equals(Object obj) { + if(obj instanceof SyncSet) { + return this.id.equals(((SyncSet)obj).id); + } else { + return false; + } + } + + @Override + public int hashCode() { + return id.hashCode(); + } + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java b/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java new file mode 100644 index 00000000..759e893a --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/TabBackgroundTask.java @@ -0,0 +1,51 @@ +package github.daneren2005.dsub.util; + +import github.daneren2005.dsub.fragments.SubsonicFragment; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class TabBackgroundTask<T> extends BackgroundTask<T> { + + private final SubsonicFragment tabFragment; + + public TabBackgroundTask(SubsonicFragment fragment) { + super(fragment.getActivity()); + tabFragment = fragment; + } + + @Override + public void execute() { + tabFragment.setProgressVisible(true); + + queue.offer(task = new Task() { + @Override + public void onDone(T result) { + tabFragment.setProgressVisible(false); + done(result); + } + + @Override + public void onError(Throwable t) { + tabFragment.setProgressVisible(false); + error(t); + } + }); + } + + @Override + public boolean isCancelled() { + return !tabFragment.isAdded() || cancelled.get(); + } + + @Override + public void updateProgress(final String message) { + getHandler().post(new Runnable() { + @Override + public void run() { + tabFragment.updateProgress(message); + } + }); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java b/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java new file mode 100644 index 00000000..8b7df783 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/TimeLimitedCache.java @@ -0,0 +1,55 @@ +/* + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import java.lang.ref.SoftReference; +import java.util.concurrent.TimeUnit; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class TimeLimitedCache<T> { + + private SoftReference<T> value; + private final long ttlMillis; + private long expires; + + public TimeLimitedCache(long ttl, TimeUnit timeUnit) { + this.ttlMillis = TimeUnit.MILLISECONDS.convert(ttl, timeUnit); + } + + public T get() { + return System.currentTimeMillis() < expires ? value.get() : null; + } + + public void set(T value) { + set(value, ttlMillis, TimeUnit.MILLISECONDS); + } + + public void set(T value, long ttl, TimeUnit timeUnit) { + this.value = new SoftReference<T>(value); + expires = System.currentTimeMillis() + timeUnit.toMillis(ttl); + } + + public void clear() { + expires = 0L; + value = null; + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java b/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java new file mode 100644 index 00000000..29618424 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/UserUtil.java @@ -0,0 +1,452 @@ +/* + 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 <http://www.gnu.org/licenses/>. + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.support.v7.app.ActionBarActivity; +import android.util.Log; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.User; +import github.daneren2005.dsub.fragments.SubsonicFragment; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.MusicService; +import github.daneren2005.dsub.service.MusicServiceFactory; +import github.daneren2005.dsub.service.OfflineException; +import github.daneren2005.dsub.service.ServerTooOldException; +import github.daneren2005.dsub.adapter.SettingsAdapter; + +public final class UserUtil { + private static final String TAG = UserUtil.class.getSimpleName(); + private static final long MIN_VERIFY_DURATION = 1000L * 60L * 60L; + + private static int instance = -1; + private static User currentUser; + private static long lastVerifiedTime = 0; + + + public static void refreshCurrentUser(Context context, boolean forceRefresh) { + refreshCurrentUser(context, forceRefresh, false); + } + public static void refreshCurrentUser(Context context, boolean forceRefresh, boolean unAuth) { + currentUser = null; + if(unAuth) { + lastVerifiedTime = 0; + } + seedCurrentUser(context, forceRefresh); + } + + public static void seedCurrentUser(Context context) { + seedCurrentUser(context, false); + } + public static void seedCurrentUser(final Context context, final boolean refresh) { + // Only try to seed if online + if(Util.isOffline(context)) { + currentUser = null; + return; + } + + final int instance = Util.getActiveServer(context); + if(UserUtil.instance == instance && currentUser != null) { + return; + } else { + UserUtil.instance = instance; + } + + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + currentUser = MusicServiceFactory.getMusicService(context).getUser(refresh, getCurrentUsername(context, instance), context, null); + + // If running, redo cast selector + DownloadService downloadService = DownloadService.getInstance(); + if(downloadService != null) { + downloadService.userSettingsChanged(); + } + + return null; + } + + @Override + protected void done(Void result) { + if(context instanceof ActionBarActivity) { + ((ActionBarActivity) context).supportInvalidateOptionsMenu(); + } + } + + @Override + protected void error(Throwable error) { + // Don't do anything, supposed to be background pull + Log.e(TAG, "Failed to seed user information"); + } + }.execute(); + } + + public static User getCurrentUser() { + return currentUser; + } + + public static String getCurrentUsername(Context context, int instance) { + SharedPreferences prefs = Util.getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + } + + public static String getCurrentUsername(Context context) { + return getCurrentUsername(context, Util.getActiveServer(context)); + } + + public static boolean isCurrentAdmin() { + return isCurrentRole(User.ADMIN); + } + + public static boolean canPodcast() { + return isCurrentRole(User.PODCAST); + } + public static boolean canShare() { + return isCurrentRole(User.SHARE); + } + public static boolean canJukebox() { + return isCurrentRole(User.JUKEBOX); + } + public static boolean canScrobble() { + return isCurrentRole(User.SCROBBLING, true); + } + + public static boolean isCurrentRole(String role) { + return isCurrentRole(role, false); + } + public static boolean isCurrentRole(String role, boolean defaultValue) { + if(currentUser == null) { + return defaultValue; + } + + for(User.Setting setting: currentUser.getSettings()) { + if(setting.getName().equals(role)) { + return setting.getValue() == true; + } + } + + return defaultValue; + } + + public static void confirmCredentials(final Activity context, final Runnable onSuccess) { + final long currentTime = System.currentTimeMillis(); + // If already ran this check within last x time, just go ahead and auth + if((currentTime - lastVerifiedTime) < MIN_VERIFY_DURATION) { + onSuccess.run(); + } else { + View layout = context.getLayoutInflater().inflate(R.layout.confirm_password, null); + final TextView passwordView = (TextView) layout.findViewById(R.id.password); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_confirm_password) + .setView(layout) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + String password = passwordView.getText().toString(); + + SharedPreferences prefs = Util.getPreferences(context); + String correctPassword = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + Util.getActiveServer(context), null); + + if(password != null && password.equals(correctPassword)) { + lastVerifiedTime = currentTime; + onSuccess.run(); + } else { + Util.toast(context, R.string.admin_confirm_password_bad); + } + } + }) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + } + + public static void changePassword(final Activity context, final User user) { + View layout = context.getLayoutInflater().inflate(R.layout.change_password, null); + final TextView passwordView = (TextView) layout.findViewById(R.id.new_password); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_change_password) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String password = passwordView.getText().toString(); + // Don't allow blank passwords + if ("".equals(password)) { + Util.toast(context, R.string.admin_change_password_invalid); + return; + } + + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.changePassword(user.getUsername(), password, context, null); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_change_password_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_change_password_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } + + public static void updateSettings(final Context context, final User user) { + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.updateUser(user, context, null); + user.setSettings(user.getSettings()); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_update_permissions_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_update_permissions_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + } + + public static void changeEmail(final Activity context, final User user) { + View layout = context.getLayoutInflater().inflate(R.layout.change_email, null); + final TextView emailView = (TextView) layout.findViewById(R.id.new_email); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.admin_change_email) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String email = emailView.getText().toString(); + // Don't allow blank emails + if ("".equals(email)) { + Util.toast(context, R.string.admin_change_email_invalid); + return; + } + + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.changeEmail(user.getUsername(), email, context, null); + user.setEmail(email); + return null; + } + + @Override + protected void done(Void v) { + Util.toast(context, context.getResources().getString(R.string.admin_change_email_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_change_email_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } + + public static void deleteUser(final Context context, final User user, final ArrayAdapter adapter) { + Util.confirmDialog(context, R.string.common_delete, user.getUsername(), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.deleteUser(user.getUsername(), context, null); + return null; + } + + @Override + protected void done(Void v) { + if(adapter != null) { + adapter.remove(user); + adapter.notifyDataSetChanged(); + } + + Util.toast(context, context.getResources().getString(R.string.admin_delete_user_success, user.getUsername())); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_delete_user_error, user.getUsername()); + } + + Util.toast(context, msg); + } + }.execute(); + } + }); + } + + public static void addNewUser(final Activity context, final SubsonicFragment fragment) { + final User user = new User(); + for(String role: User.ROLES) { + if(role.equals(User.SETTINGS) || role.equals(User.STREAM)) { + user.addSetting(role, true); + } else { + user.addSetting(role, false); + } + } + + View layout = context.getLayoutInflater().inflate(R.layout.create_user, null); + final TextView usernameView = (TextView) layout.findViewById(R.id.username); + final TextView emailView = (TextView) layout.findViewById(R.id.email); + final TextView passwordView = (TextView) layout.findViewById(R.id.password); + final ListView listView = (ListView) layout.findViewById(R.id.settings_list); + listView.setAdapter(new SettingsAdapter(context, user, true)); + + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.menu_add_user) + .setView(layout) + .setPositiveButton(R.string.common_save, null) + .setNegativeButton(R.string.common_cancel, null) + .setCancelable(true); + + final AlertDialog dialog = builder.create(); + dialog.show(); + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + final String username = usernameView.getText().toString(); + // Don't allow blank emails + if ("".equals(username)) { + Util.toast(context, R.string.admin_change_username_invalid); + return; + } + + final String email = emailView.getText().toString(); + // Don't allow blank emails + if ("".equals(email)) { + Util.toast(context, R.string.admin_change_email_invalid); + return; + } + + final String password = passwordView.getText().toString(); + if ("".equals(password)) { + Util.toast(context, R.string.admin_change_password_invalid); + return; + } + + user.setUsername(username); + user.setEmail(email); + user.setPassword(password); + + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + MusicService musicService = MusicServiceFactory.getMusicService(context); + musicService.createUser(user, context, null); + return null; + } + + @Override + protected void done(Void v) { + fragment.onRefresh(); + Util.toast(context, context.getResources().getString(R.string.admin_create_user_success)); + } + + @Override + protected void error(Throwable error) { + String msg; + if (error instanceof OfflineException || error instanceof ServerTooOldException) { + msg = getErrorMessage(error); + } else { + msg = context.getResources().getString(R.string.admin_create_user_error); + } + + Util.toast(context, msg); + } + }.execute(); + + dialog.dismiss(); + } + }); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/Util.java b/app/src/main/java/github/daneren2005/dsub/util/Util.java new file mode 100644 index 00000000..75d8d5dd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/Util.java @@ -0,0 +1,1339 @@ +/* + 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 + 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 <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package github.daneren2005.dsub.util; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.media.AudioManager.OnAudioFocusChangeListener; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Environment; +import android.text.Html; +import android.text.SpannableString; +import android.text.method.LinkMovementMethod; +import android.text.util.Linkify; +import android.util.Log; +import android.view.Gravity; +import android.widget.TextView; +import android.widget.Toast; +import github.daneren2005.dsub.R; +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.domain.PlayerState; +import github.daneren2005.dsub.domain.RepeatMode; +import github.daneren2005.dsub.receiver.MediaButtonIntentReceiver; +import github.daneren2005.dsub.service.DownloadService; + +import org.apache.http.HttpEntity; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.security.MessageDigest; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.Locale; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public final class Util { + private static final String TAG = Util.class.getSimpleName(); + + private static final DecimalFormat GIGA_BYTE_FORMAT = new DecimalFormat("0.00 GB"); + private static final DecimalFormat MEGA_BYTE_FORMAT = new DecimalFormat("0.00 MB"); + private static final DecimalFormat KILO_BYTE_FORMAT = new DecimalFormat("0 KB"); + + private static DecimalFormat GIGA_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat MEGA_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat KILO_BYTE_LOCALIZED_FORMAT = null; + private static DecimalFormat BYTE_LOCALIZED_FORMAT = null; + private static SimpleDateFormat DATE_FORMAT_SHORT = new SimpleDateFormat("MMM d h:mm a"); + private static SimpleDateFormat DATE_FORMAT_LONG = new SimpleDateFormat("MMM d, yyyy h:mm a"); + private static int CURRENT_YEAR = new Date().getYear(); + + public static final String EVENT_META_CHANGED = "github.daneren2005.dsub.EVENT_META_CHANGED"; + public static final String EVENT_PLAYSTATE_CHANGED = "github.daneren2005.dsub.EVENT_PLAYSTATE_CHANGED"; + + public static final String AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged"; + public static final String AVRCP_METADATA_CHANGED = "com.android.music.metachanged"; + + private static OnAudioFocusChangeListener focusListener; + private static boolean pauseFocus = false; + private static boolean lowerFocus = false; + + // Used by hexEncode() + private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + + private static Toast toast; + + private Util() { + } + + public static boolean isOffline(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_OFFLINE, false); + } + + public static void setOffline(Context context, boolean offline) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_OFFLINE, offline); + editor.commit(); + } + + public static boolean isScreenLitOnDownload(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, false); + } + + public static RepeatMode getRepeatMode(Context context) { + SharedPreferences prefs = getPreferences(context); + return RepeatMode.valueOf(prefs.getString(Constants.PREFERENCES_KEY_REPEAT_MODE, RepeatMode.OFF.name())); + } + + public static void setRepeatMode(Context context, RepeatMode repeatMode) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name()); + editor.commit(); + } + + public static boolean isScrobblingEnabled(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, true) && (isOffline(context) || UserUtil.canScrobble()); + } + + public static void setActiveServer(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, instance); + editor.commit(); + } + + public static int getActiveServer(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_OFFLINE, false) ? 0 : prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + } + + public static int getServerCount(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getInt(Constants.PREFERENCES_KEY_SERVER_COUNT, 1); + } + + public static void removeInstanceName(Context context, int instance, int activeInstance) { + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + + int newInstance = instance + 1; + + // Get what the +1 server details are + String server = prefs.getString(Constants.PREFERENCES_KEY_SERVER_KEY + newInstance, null); + String serverName = prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + newInstance, null); + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + newInstance, null); + String userName = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + newInstance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + newInstance, null); + String musicFolderId = prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + newInstance, null); + + // Store the +1 server details in the to be deleted instance + editor.putString(Constants.PREFERENCES_KEY_SERVER_KEY + instance, server); + editor.putString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, serverName); + editor.putString(Constants.PREFERENCES_KEY_SERVER_URL + instance, serverUrl); + editor.putString(Constants.PREFERENCES_KEY_USERNAME + instance, userName); + editor.putString(Constants.PREFERENCES_KEY_PASSWORD + instance, password); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, musicFolderId); + + // Delete the +1 server instance + // Calling method will loop up to fill this in if +2 server exists + editor.putString(Constants.PREFERENCES_KEY_SERVER_KEY + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_SERVER_NAME + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_SERVER_URL + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_USERNAME + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_PASSWORD + newInstance, null); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + newInstance, null); + editor.commit(); + + if (instance == activeInstance) { + if(instance != 1) { + Util.setActiveServer(context, 1); + } else { + Util.setOffline(context, true); + } + } else if (newInstance == activeInstance) { + Util.setActiveServer(context, instance); + } + } + + public static String getServerName(Context context) { + SharedPreferences prefs = getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null); + } + public static String getServerName(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null); + } + + public static void setSelectedMusicFolderId(Context context, String musicFolderId) { + int instance = getActiveServer(context); + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, musicFolderId); + editor.commit(); + } + + public static String getSelectedMusicFolderId(Context context) { + return getSelectedMusicFolderId(context, getActiveServer(context)); + } + public static String getSelectedMusicFolderId(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, null); + } + + public static boolean getAlbumListsPerFolder(Context context) { + return getAlbumListsPerFolder(context, getActiveServer(context)); + } + public static boolean getAlbumListsPerFolder(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_ALBUMS_PER_FOLDER + instance, false); + } + public static void setAlbumListsPerFolder(Context context, boolean perFolder) { + int instance = getActiveServer(context); + SharedPreferences prefs = getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(Constants.PREFERENCES_KEY_ALBUMS_PER_FOLDER + instance, perFolder); + editor.commit(); + } + + public static String getTheme(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_THEME, null); + } + public static void setTheme(Context context, String theme) { + SharedPreferences.Editor editor = getPreferences(context).edit(); + editor.putString(Constants.PREFERENCES_KEY_THEME, theme); + editor.commit(); + } + + public static void applyTheme(Context context, String theme) { + if ("dark".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Dark); + } else if ("black".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Black); + } else if ("holo".equals(theme)) { + context.setTheme(R.style.Theme_DSub_Holo); + } else { + context.setTheme(R.style.Theme_DSub_Light); + } + + SharedPreferences prefs = Util.getPreferences(context); + if(prefs.getBoolean(Constants.PREFERENCES_KEY_OVERRIDE_SYSTEM_LANGUAGE, false)) { + Configuration config = new Configuration(); + config.locale = Locale.ENGLISH; + context.getResources().updateConfiguration(config, context.getResources().getDisplayMetrics()); + } + } + + public static boolean getDisplayTrack(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_DISPLAY_TRACK, false); + } + + public static int getMaxBitrate(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 0; + } + + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + SharedPreferences prefs = getPreferences(context); + return Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, "0")); + } + + public static int getMaxVideoBitrate(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 0; + } + + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + SharedPreferences prefs = getPreferences(context); + return Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_VIDEO_BITRATE_MOBILE, "0")); + } + + public static int getPreloadCount(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + if (networkInfo == null) { + return 3; + } + + SharedPreferences prefs = getPreferences(context); + boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + int preloadCount = Integer.parseInt(prefs.getString(wifi ? Constants.PREFERENCES_KEY_PRELOAD_COUNT_WIFI : Constants.PREFERENCES_KEY_PRELOAD_COUNT_MOBILE, "-1")); + return preloadCount == -1 ? Integer.MAX_VALUE : preloadCount; + } + + public static int getCacheSizeMB(Context context) { + SharedPreferences prefs = getPreferences(context); + int cacheSize = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_CACHE_SIZE, "-1")); + return cacheSize == -1 ? Integer.MAX_VALUE : cacheSize; + } + + public static String getRestUrl(Context context, String method) { + return getRestUrl(context, method, true); + } + public static String getRestUrl(Context context, String method, boolean allowAltAddress) { + SharedPreferences prefs = getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + return getRestUrl(context, method, prefs, instance, allowAltAddress); + } + public static String getRestUrl(Context context, String method, int instance) { + return getRestUrl(context, method, instance, true); + } + public static String getRestUrl(Context context, String method, int instance, boolean allowAltAddress) { + SharedPreferences prefs = getPreferences(context); + return getRestUrl(context, method, prefs, instance, allowAltAddress); + } + public static String getRestUrl(Context context, String method, SharedPreferences prefs, int instance) { + return getRestUrl(context, method, prefs, instance, true); + } + public static String getRestUrl(Context context, String method, SharedPreferences prefs, int instance, boolean allowAltAddress) { + StringBuilder builder = new StringBuilder(); + + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + if(allowAltAddress && Util.isWifiConnected(context)) { + String SSID = prefs.getString(Constants.PREFERENCES_KEY_SERVER_LOCAL_NETWORK_SSID + instance, ""); + String currentSSID = Util.getSSID(context); + + String[] ssidParts = SSID.split(","); + if("".equals(SSID) || SSID.equals(currentSSID) || Arrays.asList(ssidParts).contains(currentSSID)) { + String internalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance, null); + if(internalUrl != null && !"".equals(internalUrl) && !"http://".equals(internalUrl)) { + serverUrl = internalUrl; + } + } + } + + String username = prefs.getString(Constants.PREFERENCES_KEY_USERNAME + instance, null); + String password = prefs.getString(Constants.PREFERENCES_KEY_PASSWORD + instance, null); + + // Slightly obfuscate password + password = "enc:" + Util.utf8HexEncode(password); + + builder.append(serverUrl); + if (builder.charAt(builder.length() - 1) != '/') { + builder.append("/"); + } + builder.append("rest/").append(method).append(".view"); + builder.append("?u=").append(username); + builder.append("&p=").append(password); + builder.append("&v=").append(Constants.REST_PROTOCOL_VERSION); + builder.append("&c=").append(Constants.REST_CLIENT_ID); + + return builder.toString(); + } + + public static String replaceInternalUrl(Context context, String url) { + // Only change to internal when using https + if(url.indexOf("https") != -1) { + SharedPreferences prefs = Util.getPreferences(context); + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String internalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_INTERNAL_URL + instance, null); + if(internalUrl != null && !"".equals(internalUrl)) { + String externalUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + url = url.replace(internalUrl, externalUrl); + } + } + + // Use separate profile for Chromecast so users can do ogg on phone, mp3 for CC + return url.replace("c=" + Constants.REST_CLIENT_ID, "c=" + Constants.CHROMECAST_CLIENT_ID); + } + + public static boolean isTagBrowsing(Context context) { + return isTagBrowsing(context, Util.getActiveServer(context)); + } + public static boolean isTagBrowsing(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_BROWSE_TAGS + instance, false); + } + + public static boolean isSyncEnabled(Context context, int instance) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SERVER_SYNC + instance, true); + } + + public static String getParentFromEntry(Context context, MusicDirectory.Entry entry) { + if(Util.isTagBrowsing(context)) { + if(!entry.isDirectory()) { + return entry.getAlbumId(); + } else if(entry.isAlbum()) { + return entry.getArtistId(); + } else { + return null; + } + } else { + return entry.getParent(); + } + } + + public static String openToTab(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_OPEN_TO_TAB, null); + } + + public static boolean disableExitPrompt(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_DISABLE_EXIT_PROMPT, false); + } + + public static String getVideoPlayerType(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_VIDEO_PLAYER, "raw"); + } + + public static SharedPreferences getPreferences(Context context) { + return context.getSharedPreferences(Constants.PREFERENCES_FILE_NAME, 0); + } + public static SharedPreferences getOfflineSync(Context context) { + return context.getSharedPreferences(Constants.OFFLINE_SYNC_NAME, 0); + } + + public static String getSyncDefault(Context context) { + SharedPreferences prefs = Util.getOfflineSync(context); + return prefs.getString(Constants.OFFLINE_SYNC_DEFAULT, null); + } + public static void setSyncDefault(Context context, String defaultValue) { + SharedPreferences.Editor editor = Util.getOfflineSync(context).edit(); + editor.putString(Constants.OFFLINE_SYNC_DEFAULT, defaultValue); + editor.commit(); + } + + public static String getCacheName(Context context, String name, String id) { + return getCacheName(context, getActiveServer(context), name, id); + } + public static String getCacheName(Context context, int instance, String name, String id) { + String s = getRestUrl(context, null, instance, false) + id; + return name + "-" + s.hashCode() + ".ser"; + } + public static String getCacheName(Context context, String name) { + return getCacheName(context, getActiveServer(context), name); + } + public static String getCacheName(Context context, int instance, String name) { + String s = getRestUrl(context, null, instance, false); + return name + "-" + s.hashCode() + ".ser"; + } + + public static int offlineScrobblesCount(Context context) { + SharedPreferences offline = getOfflineSync(context); + return offline.getInt(Constants.OFFLINE_SCROBBLE_COUNT, 0); + } + public static int offlineStarsCount(Context context) { + SharedPreferences offline = getOfflineSync(context); + return offline.getInt(Constants.OFFLINE_STAR_COUNT, 0); + } + + public static String parseOfflineIDSearch(Context context, String id, String cacheLocation) { + // Try to get this info based off of tags first + String name = parseOfflineIDSearch(id); + if(name != null) { + return name; + } + + // Otherwise go nuts trying to parse from file structure + name = id.replace(cacheLocation, ""); + if(name.startsWith("/")) { + name = name.substring(1); + } + name = name.replace(".complete", "").replace(".partial", ""); + int index = name.lastIndexOf("."); + name = index == -1 ? name : name.substring(0, index); + String[] details = name.split("/"); + + String title = details[details.length - 1]; + if(index == -1) { + if(details.length > 1) { + String artist = "artist:\"" + details[details.length - 2] + "\""; + String simpleArtist = "artist:\"" + title + "\""; + title = "album:\"" + title + "\""; + if(details[details.length - 1].equals(details[details.length - 2])) { + name = title; + } else { + name = "(" + artist + " AND " + title + ")" + " OR " + simpleArtist; + } + } else { + name = "artist:\"" + title + "\" OR album:\"" + title + "\""; + } + } else { + String artist; + if(details.length > 2) { + artist = "artist:\"" + details[details.length - 3] + "\""; + } else { + artist = "(artist:\"" + details[0] + "\" OR album:\"" + details[0] + "\")"; + } + title = "title:\"" + title.substring(title.indexOf('-') + 1) + "\""; + name = artist + " AND " + title; + } + + return name; + } + + public static String parseOfflineIDSearch(String id) { + MusicDirectory.Entry entry = new MusicDirectory.Entry(); + File file = new File(id); + + if(file.exists()) { + entry.loadMetadata(file); + + if(entry.getArtist() != null) { + String title = file.getName(); + title = title.replace(".complete", "").replace(".partial", ""); + int index = title.lastIndexOf("."); + title = index == -1 ? title : title.substring(0, index); + title = title.substring(title.indexOf('-') + 1); + + String query = "artist:\"" + entry.getArtist() + "\"" + + " AND title:\"" + title + "\""; + + return query; + } else { + return null; + } + } else { + return null; + } + } + + public static String getContentType(HttpEntity entity) { + if (entity == null || entity.getContentType() == null) { + return null; + } + return entity.getContentType().getValue(); + } + + public static int getRemainingTrialDays(Context context) { + SharedPreferences prefs = getPreferences(context); + long installTime = prefs.getLong(Constants.PREFERENCES_KEY_INSTALL_TIME, 0L); + + if (installTime == 0L) { + installTime = System.currentTimeMillis(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(Constants.PREFERENCES_KEY_INSTALL_TIME, installTime); + editor.commit(); + } + + long now = System.currentTimeMillis(); + long millisPerDay = 24L * 60L * 60L * 1000L; + int daysSinceInstall = (int) ((now - installTime) / millisPerDay); + return Math.max(0, Constants.FREE_TRIAL_DAYS - daysSinceInstall); + } + + public static boolean isCastProxy(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_CAST_PROXY, false); + } + + public static boolean isFirstLevelArtist(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true); + } + public static void toggleFirstLevelArtist(Context context) { + SharedPreferences prefs = Util.getPreferences(context); + SharedPreferences.Editor editor = prefs.edit(); + + if(prefs.getBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true)) { + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), false); + } else { + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_LEVEL_ARTIST + getActiveServer(context), true); + } + + editor.commit(); + } + + public static boolean shouldStartOnHeadphones(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_START_ON_HEADPHONES, false); + } + + /** + * Get the contents of an <code>InputStream</code> as a <code>byte[]</code>. + * <p/> + * This method buffers the input internally, so there is no need to use a + * <code>BufferedInputStream</code>. + * + * @param input the <code>InputStream</code> to read from + * @return the requested byte array + * @throws NullPointerException if the input is null + * @throws IOException if an I/O error occurs + */ + public static byte[] toByteArray(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + copy(input, output); + return output.toByteArray(); + } + + public static long copy(InputStream input, OutputStream output) + throws IOException { + byte[] buffer = new byte[1024 * 4]; + long count = 0; + int n; + while (-1 != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } + + public static void renameFile(File from, File to) throws IOException { + if(!from.renameTo(to)) { + Log.i(TAG, "Failed to rename " + from + " to " + to); + } + } + + public static void close(Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (Throwable x) { + // Ignored + } + } + + public static boolean delete(File file) { + if (file != null && file.exists()) { + if (!file.delete()) { + Log.w(TAG, "Failed to delete file " + file); + return false; + } + Log.i(TAG, "Deleted file " + file); + } + return true; + } + + public static void toast(Context context, int messageId) { + toast(context, messageId, true); + } + + public static void toast(Context context, int messageId, boolean shortDuration) { + toast(context, context.getString(messageId), shortDuration); + } + + public static void toast(Context context, String message) { + toast(context, message, true); + } + + public static void toast(Context context, String message, boolean shortDuration) { + if (toast == null) { + toast = Toast.makeText(context, message, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); + toast.setGravity(Gravity.CENTER, 0, 0); + } else { + toast.setText(message); + toast.setDuration(shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); + } + toast.show(); + } + + public static void confirmDialog(Context context, int action, int subject, DialogInterface.OnClickListener onClick) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), context.getResources().getString(subject), onClick, null); + } + public static void confirmDialog(Context context, int action, int subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), context.getResources().getString(subject), onClick, onCancel); + } + public static void confirmDialog(Context context, int action, String subject, DialogInterface.OnClickListener onClick) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), subject, onClick, null); + } + public static void confirmDialog(Context context, int action, String subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + Util.confirmDialog(context, context.getResources().getString(action).toLowerCase(), subject, onClick, onCancel); + } + public static void confirmDialog(Context context, String action, String subject, DialogInterface.OnClickListener onClick, DialogInterface.OnClickListener onCancel) { + new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.common_confirm) + .setMessage(context.getResources().getString(R.string.common_confirm_message, action, subject)) + .setPositiveButton(R.string.common_ok, onClick) + .setNegativeButton(R.string.common_cancel, onCancel) + .show(); + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * <ul> + * <li><code>format(918)</code> returns <em>"918 B"</em>.</li> + * <li><code>format(98765)</code> returns <em>"96 KB"</em>.</li> + * <li><code>format(1238476)</code> returns <em>"1.2 MB"</em>.</li> + * </ul> + * This method assumes that 1 KB is 1024 bytes. + * To get a localized string, please use formatLocalizedBytes instead. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + public static synchronized String formatBytes(long byteCount) { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + NumberFormat gigaByteFormat = GIGA_BYTE_FORMAT; + return gigaByteFormat.format((double) byteCount / (1024 * 1024 * 1024)); + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + NumberFormat megaByteFormat = MEGA_BYTE_FORMAT; + return megaByteFormat.format((double) byteCount / (1024 * 1024)); + } + + // More than 1 KB? + if (byteCount >= 1024) { + NumberFormat kiloByteFormat = KILO_BYTE_FORMAT; + return kiloByteFormat.format((double) byteCount / 1024); + } + + return byteCount + " B"; + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * <ul> + * <li><code>format(918)</code> returns <em>"918 B"</em>.</li> + * <li><code>format(98765)</code> returns <em>"96 KB"</em>.</li> + * <li><code>format(1238476)</code> returns <em>"1.2 MB"</em>.</li> + * </ul> + * This method assumes that 1 KB is 1024 bytes. + * This version of the method returns a localized string. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + public static synchronized String formatLocalizedBytes(long byteCount, Context context) { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + if (GIGA_BYTE_LOCALIZED_FORMAT == null) { + GIGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_gigabyte)); + } + + return GIGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + if (MEGA_BYTE_LOCALIZED_FORMAT == null) { + MEGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_megabyte)); + } + + return MEGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024)); + } + + // More than 1 KB? + if (byteCount >= 1024) { + if (KILO_BYTE_LOCALIZED_FORMAT == null) { + KILO_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_kilobyte)); + } + + return KILO_BYTE_LOCALIZED_FORMAT.format((double) byteCount / 1024); + } + + if (BYTE_LOCALIZED_FORMAT == null) { + BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_byte)); + } + + return BYTE_LOCALIZED_FORMAT.format((double) byteCount); + } + + public static String formatDuration(Integer seconds) { + if (seconds == null) { + return null; + } + + int hours = seconds / 3600; + int minutes = (seconds / 60) % 60; + int secs = seconds % 60; + + StringBuilder builder = new StringBuilder(7); + if(hours > 0) { + builder.append(hours).append(":"); + if(minutes < 10) { + builder.append("0"); + } + } + builder.append(minutes).append(":"); + if (secs < 10) { + builder.append("0"); + } + builder.append(secs); + return builder.toString(); + } + + public static String formatDate(Date date) { + if(date == null) { + return "Never"; + } else { + if(date.getYear() != CURRENT_YEAR) { + return DATE_FORMAT_LONG.format(date); + } else { + return DATE_FORMAT_SHORT.format(date); + } + } + } + + public static boolean equals(Object object1, Object object2) { + if (object1 == object2) { + return true; + } + if (object1 == null || object2 == null) { + return false; + } + return object1.equals(object2); + + } + + /** + * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. + * + * @param s The string to encode. + * @return The encoded string. + */ + public static String utf8HexEncode(String s) { + if (s == null) { + return null; + } + byte[] utf8; + try { + utf8 = s.getBytes(Constants.UTF_8); + } catch (UnsupportedEncodingException x) { + throw new RuntimeException(x); + } + return hexEncode(utf8); + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data Bytes to convert to hexadecimal characters. + * @return A string containing hexadecimal characters. + */ + public static String hexEncode(byte[] data) { + int length = data.length; + char[] out = new char[length << 1]; + // two characters form the hex value. + for (int i = 0, j = 0; i < length; i++) { + out[j++] = HEX_DIGITS[(0xF0 & data[i]) >>> 4]; + out[j++] = HEX_DIGITS[0x0F & data[i]]; + } + return new String(out); + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param s Data to digest. + * @return MD5 digest as a hex string. + */ + public static String md5Hex(String s) { + if (s == null) { + return null; + } + + try { + MessageDigest md5 = MessageDigest.getInstance("MD5"); + return hexEncode(md5.digest(s.getBytes(Constants.UTF_8))); + } catch (Exception x) { + throw new RuntimeException(x.getMessage(), x); + } + } + + public static boolean isNullOrWhiteSpace(String string) { + return string == null || "".equals(string) || "".equals(string.trim()); + } + + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + + // Calculate ratios of height and width to requested height and + // width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will + // guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + public static int getScaledHeight(double height, double width, int newWidth) { + // Try to keep correct aspect ratio of the original image, do not force a square + double aspectRatio = height / width; + + // Assume the size given refers to the width of the image, so calculate the new height using + // the previously determined aspect ratio + return (int) Math.round(newWidth * aspectRatio); + } + + public static int getScaledHeight(Bitmap bitmap, int width) { + return Util.getScaledHeight((double) bitmap.getHeight(), (double) bitmap.getWidth(), width); + } + + public static int getStringDistance(CharSequence s, CharSequence t) { + if (s == null || t == null) { + throw new IllegalArgumentException("Strings must not be null"); + } + + if(t.toString().toLowerCase().indexOf(s.toString().toLowerCase()) != -1) { + return 1; + } + + int n = s.length(); + int m = t.length(); + + if (n == 0) { + return m; + } else if (m == 0) { + return n; + } + + if (n > m) { + final CharSequence tmp = s; + s = t; + t = tmp; + n = m; + m = t.length(); + } + + int p[] = new int[n + 1]; + int d[] = new int[n + 1]; + int _d[]; + + int i; + int j; + char t_j; + int cost; + + for (i = 0; i <= n; i++) { + p[i] = i; + } + + for (j = 1; j <= m; j++) { + t_j = t.charAt(j - 1); + d[0] = j; + + for (i = 1; i <= n; i++) { + cost = s.charAt(i - 1) == t_j ? 0 : 1; + d[i] = Math.min(Math.min(d[i - 1] + 1, p[i] + 1), p[i - 1] + cost); + } + + _d = p; + p = d; + d = _d; + } + + return p[n]; + } + + public static boolean isNetworkConnected(Context context) { + return isNetworkConnected(context, false); + } + public static boolean isNetworkConnected(Context context, boolean streaming) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + boolean connected = networkInfo != null && networkInfo.isConnected(); + + if(streaming) { + boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + boolean wifiRequired = isWifiRequiredForDownload(context); + + return connected && (!wifiRequired || wifiConnected); + } else { + return connected; + } + } + public static boolean isWifiConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + boolean connected = networkInfo != null && networkInfo.isConnected(); + return connected && (networkInfo.getType() == ConnectivityManager.TYPE_WIFI); + } + public static String getSSID(Context context) { + if (isWifiConnected(context)) { + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + if (wifiManager.getConnectionInfo() != null && wifiManager.getConnectionInfo().getSSID() != null) { + return wifiManager.getConnectionInfo().getSSID().replace("\"", ""); + } + return null; + } + return null; + } + + public static boolean isExternalStoragePresent() { + return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); + } + + private static boolean isWifiRequiredForDownload(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, false); + } + + public static void info(Context context, int titleId, int messageId) { + info(context, titleId, messageId, true); + } + public static void info(Context context, int titleId, String message) { + info(context, titleId, message, true); + } + public static void info(Context context, String title, String message) { + info(context, title, message, true); + } + public static void info(Context context, int titleId, int messageId, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, titleId, messageId, linkify); + } + public static void info(Context context, int titleId, String message, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, titleId, message, linkify); + } + public static void info(Context context, String title, String message, boolean linkify) { + showDialog(context, android.R.drawable.ic_dialog_info, title, message, linkify); + } + + private static void showDialog(Context context, int icon, int titleId, int messageId) { + showDialog(context, icon, titleId, messageId, true); + } + private static void showDialog(Context context, int icon, int titleId, String message) { + showDialog(context, icon, titleId, message, true); + } + private static void showDialog(Context context, int icon, String title, String message) { + showDialog(context, icon, title, message, true); + } + private static void showDialog(Context context, int icon, int titleId, int messageId, boolean linkify) { + showDialog(context, icon, context.getResources().getString(titleId), context.getResources().getString(messageId), linkify); + } + private static void showDialog(Context context, int icon, int titleId, String message, boolean linkify) { + showDialog(context, icon, context.getResources().getString(titleId), message, linkify); + } + private static void showDialog(Context context, int icon, String title, String message, boolean linkify) { + SpannableString ss = new SpannableString(message); + if(linkify) { + Linkify.addLinks(ss, Linkify.ALL); + } + + AlertDialog dialog = new AlertDialog.Builder(context) + .setIcon(icon) + .setTitle(title) + .setMessage(ss) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }) + .show(); + + ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + } + public static void showHTMLDialog(Context context, int title, int message) { + showHTMLDialog(context, title, context.getResources().getString(message)); + } + public static void showHTMLDialog(Context context, int title, String message) { + AlertDialog dialog = new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_info) + .setTitle(title) + .setMessage(Html.fromHtml(message)) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }) + .show(); + + ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + } + + public static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException x) { + Log.w(TAG, "Interrupted from sleep.", x); + } + } + + public static void startActivityWithoutTransition(Activity currentActivity, Class<? extends Activity> newActivitiy) { + startActivityWithoutTransition(currentActivity, new Intent(currentActivity, newActivitiy)); + } + + public static void startActivityWithoutTransition(Activity currentActivity, Intent intent) { + currentActivity.startActivity(intent); + disablePendingTransition(currentActivity); + } + + public static void disablePendingTransition(Activity activity) { + + // Activity.overridePendingTransition() was introduced in Android 2.0. Use reflection to maintain + // compatibility with 1.5. + try { + Method method = Activity.class.getMethod("overridePendingTransition", int.class, int.class); + method.invoke(activity, 0, 0); + } catch (Throwable x) { + // Ignored + } + } + + public static Drawable createDrawableFromBitmap(Context context, Bitmap bitmap) { + // BitmapDrawable(Resources, Bitmap) was introduced in Android 1.6. Use reflection to maintain + // compatibility with 1.5. + try { + Constructor<BitmapDrawable> constructor = BitmapDrawable.class.getConstructor(Resources.class, Bitmap.class); + return constructor.newInstance(context.getResources(), bitmap); + } catch (Throwable x) { + return new BitmapDrawable(bitmap); + } + } + + public static int getAttribute(Context context, int attr) { + int res; + int[] attrs = new int[] {attr}; + TypedArray typedArray = context.obtainStyledAttributes(attrs); + res = typedArray.getResourceId(0, 0); + typedArray.recycle(); + return res; + } + + public static void registerMediaButtonEventReceiver(Context context) { + + // Only do it if enabled in the settings. + SharedPreferences prefs = getPreferences(context); + boolean enabled = prefs.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true); + + if (enabled) { + + // AudioManager.registerMediaButtonEventReceiver() was introduced in Android 2.2. + // Use reflection to maintain compatibility with 1.5. + try { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName()); + Method method = AudioManager.class.getMethod("registerMediaButtonEventReceiver", ComponentName.class); + method.invoke(audioManager, componentName); + } catch (Throwable x) { + // Ignored. + } + } + } + + public static void unregisterMediaButtonEventReceiver(Context context) { + // AudioManager.unregisterMediaButtonEventReceiver() was introduced in Android 2.2. + // Use reflection to maintain compatibility with 1.5. + try { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + ComponentName componentName = new ComponentName(context.getPackageName(), MediaButtonIntentReceiver.class.getName()); + Method method = AudioManager.class.getMethod("unregisterMediaButtonEventReceiver", ComponentName.class); + method.invoke(audioManager, componentName); + } catch (Throwable x) { + // Ignored. + } + } + + @TargetApi(8) + public static void requestAudioFocus(final Context context) { + if (Build.VERSION.SDK_INT >= 8 && focusListener == null) { + final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.requestAudioFocus(focusListener = new OnAudioFocusChangeListener() { + public void onAudioFocusChange(int focusChange) { + DownloadService downloadService = (DownloadService)context; + if((focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) && !downloadService.isRemoteEnabled()) { + if(downloadService.getPlayerState() == PlayerState.STARTED) { + Log.i(TAG, "Temporary loss of focus"); + SharedPreferences prefs = getPreferences(context); + int lossPref = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_TEMP_LOSS, "1")); + if(lossPref == 2 || (lossPref == 1 && focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)) { + lowerFocus = true; + downloadService.setVolume(0.1f); + } else if(lossPref == 0 || (lossPref == 1 && focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)) { + pauseFocus = true; + downloadService.pause(true); + } + } + } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { + if(pauseFocus) { + pauseFocus = false; + downloadService.start(); + } else if(lowerFocus) { + lowerFocus = false; + downloadService.setVolume(1.0f); + } + } else if(focusChange == AudioManager.AUDIOFOCUS_LOSS && !downloadService.isRemoteEnabled()) { + Log.i(TAG, "Permanently lost focus"); + focusListener = null; + downloadService.pause(); + audioManager.abandonAudioFocus(this); + } + } + }, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + } + + public static void abandonAudioFocus(Context context) { + if(focusListener != null) { + final AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.abandonAudioFocus(focusListener); + focusListener = null; + } + } + + /** + * <p>Broadcasts the given song info as the new song being played.</p> + */ + public static void broadcastNewTrackInfo(Context context, MusicDirectory.Entry song) { + DownloadService downloadService = (DownloadService)context; + Intent intent = new Intent(EVENT_META_CHANGED); + Intent avrcpIntent = new Intent(AVRCP_METADATA_CHANGED); + + if (song != null) { + intent.putExtra("title", song.getTitle()); + intent.putExtra("artist", song.getArtist()); + intent.putExtra("album", song.getAlbum()); + + File albumArtFile = FileUtil.getAlbumArtFile(context, song); + intent.putExtra("coverart", albumArtFile.getAbsolutePath()); + avrcpIntent.putExtra("playing", true); + } else { + intent.putExtra("title", ""); + intent.putExtra("artist", ""); + intent.putExtra("album", ""); + intent.putExtra("coverart", ""); + avrcpIntent.putExtra("playing", false); + } + addTrackInfo(context, song, avrcpIntent); + + context.sendBroadcast(intent); + context.sendBroadcast(avrcpIntent); + } + + /** + * <p>Broadcasts the given player state as the one being set.</p> + */ + public static void broadcastPlaybackStatusChange(Context context, MusicDirectory.Entry song, PlayerState state) { + Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED); + Intent avrcpIntent = new Intent(AVRCP_PLAYSTATE_CHANGED); + + switch (state) { + case STARTED: + intent.putExtra("state", "play"); + avrcpIntent.putExtra("playing", true); + break; + case STOPPED: + intent.putExtra("state", "stop"); + avrcpIntent.putExtra("playing", false); + break; + case PAUSED: + intent.putExtra("state", "pause"); + avrcpIntent.putExtra("playing", false); + break; + case PREPARED: + // Only send quick pause event for samsung devices, causes issues for others + if(Build.MANUFACTURER.toLowerCase().indexOf("samsung") != -1) { + avrcpIntent.putExtra("playing", false); + } else { + return; // Don't broadcast anything + } + break; + case COMPLETED: + intent.putExtra("state", "complete"); + avrcpIntent.putExtra("playing", false); + break; + default: + return; // No need to broadcast. + } + addTrackInfo(context, song, avrcpIntent); + + if(state != PlayerState.PREPARED) { + context.sendBroadcast(intent); + } + context.sendBroadcast(avrcpIntent); + } + + private static void addTrackInfo(Context context, MusicDirectory.Entry song, Intent intent) { + if (song != null) { + DownloadService downloadService = (DownloadService)context; + File albumArtFile = FileUtil.getAlbumArtFile(context, song); + + intent.putExtra("track", song.getTitle()); + intent.putExtra("artist", song.getArtist()); + intent.putExtra("album", song.getAlbum()); + intent.putExtra("ListSize", (long) downloadService.getSongs().size()); + intent.putExtra("id", (long) downloadService.getCurrentPlayingIndex() + 1); + intent.putExtra("duration", (long) downloadService.getPlayerDuration()); + intent.putExtra("position", (long) downloadService.getPlayerPosition()); + intent.putExtra("coverart", albumArtFile.getAbsolutePath()); + } else { + intent.putExtra("track", ""); + intent.putExtra("artist", ""); + intent.putExtra("album", ""); + intent.putExtra("ListSize", (long) 0); + intent.putExtra("id", (long) 0); + intent.putExtra("duration", (long) 0); + intent.putExtra("position", (long) 0); + intent.putExtra("coverart", ""); + } + } + + public static WifiManager.WifiLock createWifiLock(Context context, String tag) { + WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + int lockType = WifiManager.WIFI_MODE_FULL; + if (Build.VERSION.SDK_INT >= 12) { + lockType = 3; + } + return wm.createWifiLock(lockType, tag); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java b/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java new file mode 100644 index 00000000..ab64bca9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/CastCompat.java @@ -0,0 +1,57 @@ +/* + 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 <http://www.gnu.org/licenses/>. + Copyright 2014 (C) Scott Jackson +*/ + +package github.daneren2005.dsub.util.compat; + +import android.support.v7.media.MediaRouter; + +import com.google.android.gms.cast.CastDevice; +import com.google.android.gms.cast.CastMediaControlIntent; + +import github.daneren2005.dsub.service.ChromeCastController; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.service.RemoteController; + +/** + * Created by owner on 2/9/14. + */ +public final class CastCompat { + public static final String APPLICATION_ID = "5F85EBEB"; + + static { + try { + Class.forName("com.google.android.gms.cast.CastDevice"); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + public static void checkAvailable() throws Throwable { + // Calling here forces class initialization. + } + + public static RemoteController getController(DownloadService downloadService, MediaRouter.RouteInfo info) { + CastDevice device = CastDevice.getFromBundle(info.getExtras()); + if(device != null) { + return new ChromeCastController(downloadService, device); + } else { + return null; + } + } + + public static String getCastControlCategory() { + return CastMediaControlIntent.categoryForCast(APPLICATION_ID); + } +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java new file mode 100644 index 00000000..320092e9 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientBase.java @@ -0,0 +1,43 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory.Entry; +import android.content.ComponentName; +import android.content.Context; +import android.support.v7.media.MediaRouter; +import android.util.Log; + +public class RemoteControlClientBase extends RemoteControlClientHelper { + + private static final String TAG = RemoteControlClientBase.class.getSimpleName(); + + @Override + public void register(Context context, ComponentName mediaButtonReceiverComponent) { + + } + + @Override + public void unregister(Context context) { + + } + + @Override + public void setPlaybackState(int state) { + + } + + @Override + public void updateMetadata(Context context, Entry currentSong) { + + } + + @Override + public void registerRoute(MediaRouter router) { + + } + + @Override + public void unregisterRoute(MediaRouter router) { + + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java new file mode 100644 index 00000000..93075a28 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientHelper.java @@ -0,0 +1,32 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import android.content.ComponentName; +import android.content.Context; +import android.support.v7.media.MediaRouter; +import android.os.Build; + +public abstract class RemoteControlClientHelper { + + public static RemoteControlClientHelper createInstance() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return new RemoteControlClientBase(); + } else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + return new RemoteControlClientJB(); + } else { + return new RemoteControlClientICS(); + } + } + + protected RemoteControlClientHelper() { + // Avoid instantiation + } + + public abstract void register(final Context context, final ComponentName mediaButtonReceiverComponent); + public abstract void unregister(final Context context); + public abstract void setPlaybackState(final int state); + public abstract void updateMetadata(final Context context, final MusicDirectory.Entry currentSong); + public abstract void registerRoute(MediaRouter router); + public abstract void unregisterRoute(MediaRouter router); + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java new file mode 100644 index 00000000..50283da6 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientICS.java @@ -0,0 +1,104 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.ImageLoader; +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaMetadataRetriever; +import android.media.RemoteControlClient; +import android.support.v7.media.MediaRouter; + +import github.daneren2005.dsub.activity.SubsonicActivity; + +@TargetApi(14) +public class RemoteControlClientICS extends RemoteControlClientHelper { + private static String TAG = RemoteControlClientICS.class.getSimpleName(); + + protected RemoteControlClient mRemoteControl; + protected ImageLoader imageLoader; + protected DownloadService downloadService; + + public void register(final Context context, final ComponentName mediaButtonReceiverComponent) { + downloadService = (DownloadService) context; + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + + // build the PendingIntent for the remote control client + Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + mediaButtonIntent.setComponent(mediaButtonReceiverComponent); + PendingIntent mediaPendingIntent = PendingIntent.getBroadcast(context.getApplicationContext(), 0, mediaButtonIntent, 0); + + // create and register the remote control client + mRemoteControl = new RemoteControlClient(mediaPendingIntent); + audioManager.registerRemoteControlClient(mRemoteControl); + + mRemoteControl.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED); + mRemoteControl.setTransportControlFlags(getTransportFlags()); + imageLoader = SubsonicActivity.getStaticImageLoader(context); + } + + public void unregister(final Context context) { + if (mRemoteControl != null) { + AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + audioManager.unregisterRemoteControlClient(mRemoteControl); + } + } + + public void setPlaybackState(final int state) { + mRemoteControl.setPlaybackState(state); + } + + public void updateMetadata(final Context context, final MusicDirectory.Entry currentSong) { + if(imageLoader == null) { + imageLoader = SubsonicActivity.getStaticImageLoader(context); + } + + // Update the remote controls + RemoteControlClient.MetadataEditor editor = mRemoteControl.editMetadata(true); + updateMetadata(currentSong, editor); + editor.apply(); + if (currentSong == null || imageLoader == null) { + mRemoteControl.editMetadata(true) + .putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, null) + .apply(); + } else { + imageLoader.loadImage(context, mRemoteControl, currentSong); + } + } + + @Override + public void registerRoute(MediaRouter router) { + router.addRemoteControlClient(mRemoteControl); + } + + @Override + public void unregisterRoute(MediaRouter router) { + router.removeRemoteControlClient(mRemoteControl); + } + + protected void updateMetadata(final MusicDirectory.Entry currentSong, final RemoteControlClient.MetadataEditor editor) { + editor.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, (currentSong == null) ? null : currentSong.getArtist()) + .putString(MediaMetadataRetriever.METADATA_KEY_ALBUM, (currentSong == null) ? null : currentSong.getAlbum()) + .putString(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, (currentSong == null) ? null : currentSong.getArtist()) + .putString(MediaMetadataRetriever.METADATA_KEY_TITLE, (currentSong) == null ? null : currentSong.getTitle()) + .putString(MediaMetadataRetriever.METADATA_KEY_GENRE, (currentSong) == null ? null : currentSong.getGenre()) + .putLong(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, (currentSong == null) ? + 0 : ((currentSong.getTrack() == null) ? 0 : currentSong.getTrack())) + .putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, (currentSong == null) ? + 0 : ((currentSong.getDuration() == null) ? 0 : (currentSong.getDuration() * 1000))); + } + + protected int getTransportFlags() { + return RemoteControlClient.FLAG_KEY_MEDIA_PLAY | + RemoteControlClient.FLAG_KEY_MEDIA_PAUSE | + RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE | + RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS | + RemoteControlClient.FLAG_KEY_MEDIA_NEXT | + RemoteControlClient.FLAG_KEY_MEDIA_STOP; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java new file mode 100644 index 00000000..c27df2ba --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/compat/RemoteControlClientJB.java @@ -0,0 +1,58 @@ +package github.daneren2005.dsub.util.compat; + +import github.daneren2005.dsub.domain.MusicDirectory; +import github.daneren2005.dsub.util.ImageLoader; +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.MediaMetadataRetriever; +import android.media.RemoteControlClient; +import github.daneren2005.dsub.activity.SubsonicActivity; +import github.daneren2005.dsub.service.DownloadService; +import github.daneren2005.dsub.util.SilentBackgroundTask; + +@TargetApi(18) +public class RemoteControlClientJB extends RemoteControlClientICS { + @Override + public void register(final Context context, final ComponentName mediaButtonReceiverComponent) { + super.register(context, mediaButtonReceiverComponent); + + mRemoteControl.setOnGetPlaybackPositionListener(new RemoteControlClient.OnGetPlaybackPositionListener() { + @Override + public long onGetPlaybackPosition() { + return downloadService.getPlayerPosition(); + } + }); + mRemoteControl.setPlaybackPositionUpdateListener(new RemoteControlClient.OnPlaybackPositionUpdateListener() { + @Override + public void onPlaybackPositionUpdate(final long newPosition) { + new SilentBackgroundTask<Void>(context) { + @Override + protected Void doInBackground() throws Throwable { + downloadService.seekTo((int) newPosition); + return null; + } + }.execute(); + setPlaybackState(RemoteControlClient.PLAYSTATE_PLAYING); + } + }); + } + + @Override + public void setPlaybackState(final int state) { + long position = -1; + if(state == RemoteControlClient.PLAYSTATE_PLAYING || state == RemoteControlClient.PLAYSTATE_PAUSED) { + position = downloadService.getPlayerPosition(); + } + mRemoteControl.setPlaybackState(state, position, 1.0f); + } + + @Override + protected int getTransportFlags() { + return super.getTransportFlags() | RemoteControlClient.FLAG_KEY_MEDIA_POSITION_UPDATE; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java b/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java new file mode 100644 index 00000000..aa0a2e25 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/Bastp.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +package github.daneren2005.dsub.util.tags; + +import java.io.RandomAccessFile; +import java.io.IOException; +import java.util.HashMap; + + +public class Bastp { + + public Bastp() { + } + + public HashMap getTags(String fname) { + HashMap tags = new HashMap(); + try { + RandomAccessFile ra = new RandomAccessFile(fname, "r"); + tags = getTags(ra); + ra.close(); + } + catch(Exception e) { + /* we dont' care much: SOMETHING went wrong. d'oh! */ + } + + return tags; + } + + public HashMap getTags(RandomAccessFile s) { + HashMap tags = new HashMap(); + byte[] file_ff = new byte[4]; + + try { + s.read(file_ff); + String magic = new String(file_ff); + if(magic.equals("fLaC")) { + tags = (new FlacFile()).getTags(s); + } + else if(magic.equals("OggS")) { + tags = (new OggFile()).getTags(s); + } + else if(file_ff[0] == -1 && file_ff[1] == -5) { /* aka 0xfffb in real languages */ + tags = (new LameHeader()).getTags(s); + } + else if(magic.substring(0,3).equals("ID3")) { + tags = (new ID3v2File()).getTags(s); + if(tags.containsKey("_hdrlen")) { + Long hlen = Long.parseLong( tags.get("_hdrlen").toString(), 10 ); + HashMap lameInfo = (new LameHeader()).parseLameHeader(s, hlen); + /* add gain tags if not already present */ + inheritTag("REPLAYGAIN_TRACK_GAIN", lameInfo, tags); + inheritTag("REPLAYGAIN_ALBUM_GAIN", lameInfo, tags); + } + } + tags.put("_magic", magic); + } + catch (IOException e) { + } + return tags; + } + + private void inheritTag(String key, HashMap from, HashMap to) { + if(!to.containsKey(key) && from.containsKey(key)) { + to.put(key, from.get(key)); + } + } + +} + diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java b/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java new file mode 100644 index 00000000..7ff517fd --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/BastpUtil.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package github.daneren2005.dsub.util.tags; + +import android.support.v4.util.LruCache; +import java.util.HashMap; +import java.util.Vector; + +public final class BastpUtil { + private static final RGLruCache rgCache = new RGLruCache(16); + + /** Returns the ReplayGain values of 'path' as <track,album> + */ + public static float[] getReplayGainValues(String path) { + float[] cached = rgCache.get(path); + + if(cached == null) { + cached = getReplayGainValuesFromFile(path); + rgCache.put(path, cached); + } + return cached; + } + + + + /** Parse given file and return track,album replay gain values + */ + private static float[] getReplayGainValuesFromFile(String path) { + String[] keys = { "REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN" }; + float[] adjust= { 0f , 0f }; + HashMap tags = (new Bastp()).getTags(path); + + for (int i=0; i<keys.length; i++) { + String curKey = keys[i]; + if(tags.containsKey(curKey)) { + String rg_raw = (String)((Vector)tags.get(curKey)).get(0); + String rg_numonly = ""; + float rg_float = 0f; + try { + String nums = rg_raw.replaceAll("[^0-9.-]",""); + rg_float = Float.parseFloat(nums); + } catch(Exception e) {} + adjust[i] = rg_float; + } + } + return adjust; + } + + /** LRU cache for ReplayGain values + */ + private static class RGLruCache extends LruCache<String, float[]> { + public RGLruCache(int size) { + super(size); + } + } + +} + diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java b/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java new file mode 100644 index 00000000..51344d90 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/Common.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Vector; + +public class Common { + private static final long MAX_PKT_SIZE = 524288; + + public void xdie(String reason) throws IOException { + throw new IOException(reason); + } + + /* + ** Returns a 32bit int from given byte offset in LE + */ + public int b2le32(byte[] b, int off) { + int r = 0; + for(int i=0; i<4; i++) { + r |= ( b2u(b[off+i]) << (8*i) ); + } + return r; + } + + public int b2be32(byte[] b, int off) { + return swap32(b2le32(b, off)); + } + + public int swap32(int i) { + return((i&0xff)<<24)+((i&0xff00)<<8)+((i&0xff0000)>>8)+((i>>24)&0xff); + } + + /* + ** convert 'byte' value into unsigned int + */ + public int b2u(byte x) { + return (x & 0xFF); + } + + /* + ** Printout debug message to STDOUT + */ + public void debug(String s) { + System.out.println("DBUG "+s); + } + + public HashMap parse_vorbis_comment(RandomAccessFile s, long offset, long payload_len) throws IOException { + HashMap tags = new HashMap(); + int comments = 0; // number of found comments + int xoff = 0; // offset within 'scratch' + int can_read = (int)(payload_len > MAX_PKT_SIZE ? MAX_PKT_SIZE : payload_len); + byte[] scratch = new byte[can_read]; + + // seek to given position and slurp in the payload + s.seek(offset); + s.read(scratch); + + // skip vendor string in format: [LEN][VENDOR_STRING] + xoff += 4 + b2le32(scratch, xoff); // 4 = LEN = 32bit int + comments = b2le32(scratch, xoff); + xoff += 4; + + // debug("comments count = "+comments); + for(int i=0; i<comments; i++) { + + int clen = (int)b2le32(scratch, xoff); + xoff += 4+clen; + + if(xoff > scratch.length) + xdie("string out of bounds"); + + String tag_raw = new String(scratch, xoff-clen, clen); + String[] tag_vec = tag_raw.split("=",2); + String tag_key = tag_vec[0].toUpperCase(); + + addTagEntry(tags, tag_key, tag_vec[1]); + } + return tags; + } + + public void addTagEntry(HashMap tags, String key, String value) { + if(tags.containsKey(key)) { + ((Vector)tags.get(key)).add(value); // just add to existing vector + } + else { + Vector vx = new Vector(); + vx.add(value); + tags.put(key, vx); + } + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java b/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java new file mode 100644 index 00000000..de3584d1 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/FlacFile.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Enumeration; + + +public class FlacFile extends Common { + private static final int FLAC_TYPE_COMMENT = 4; // ID of 'VorbisComment's + + public FlacFile() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + int xoff = 4; // skip file magic + int retry = 64; + int r[]; + HashMap tags = new HashMap(); + + for(; retry > 0; retry--) { + r = parse_metadata_block(s, xoff); + + if(r[2] == FLAC_TYPE_COMMENT) { + tags = parse_vorbis_comment(s, xoff+r[0], r[1]); + break; + } + + if(r[3] != 0) + break; // eof reached + + // else: calculate next offset + xoff += r[0] + r[1]; + } + return tags; + } + + /* Parses the metadata block at 'offset' and returns + ** [header_size, payload_size, type, stop_after] + */ + private int[] parse_metadata_block(RandomAccessFile s, long offset) throws IOException { + int[] result = new int[4]; + byte[] mb_head = new byte[4]; + int stop_after = 0; + int block_type = 0; + int block_size = 0; + + s.seek(offset); + + if( s.read(mb_head) != 4 ) + xdie("failed to read metadata block header"); + + block_size = b2be32(mb_head,0); // read whole header as 32 big endian + block_type = (block_size >> 24) & 127; // BIT 1-7 are the type + stop_after = (((block_size >> 24) & 128) > 0 ? 1 : 0 ); // BIT 0 indicates the last-block flag + block_size = (block_size & 0x00FFFFFF); // byte 1-7 are the size + + // debug("size="+block_size+", type="+block_type+", is_last="+stop_after); + + result[0] = 4; // hardcoded - only returned to be consistent with OGG parser + result[1] = block_size; + result[2] = block_type; + result[3] = stop_after; + + return result; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java b/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java new file mode 100644 index 00000000..ea61f36c --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/ID3v2File.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; + + +public class ID3v2File extends Common { + private static int ID3_ENC_LATIN = 0x00; + private static int ID3_ENC_UTF16LE = 0x01; + private static int ID3_ENC_UTF16BE = 0x02; + private static int ID3_ENC_UTF8 = 0x03; + + public ID3v2File() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + HashMap tags = new HashMap(); + + final int v2hdr_len = 10; + byte[] v2hdr = new byte[v2hdr_len]; + + // read the whole 10 byte header into memory + s.seek(0); + s.read(v2hdr); + + int id3v = ((b2be32(v2hdr,0))) & 0xFF; // swapped ID3\04 -> ver. ist the first byte + int v3len = ((b2be32(v2hdr,6))); // total size EXCLUDING the this 10 byte header + v3len = ((v3len & 0x7f000000) >> 3) | // for some funky reason, this is encoded as 7*4 bits + ((v3len & 0x007f0000) >> 2) | + ((v3len & 0x00007f00) >> 1) | + ((v3len & 0x0000007f) >> 0) ; + + // debug(">> tag version ID3v2."+id3v); + // debug(">> LEN= "+v3len+" // "+v3len); + + // we should already be at the first frame + // so we can start the parsing right now + tags = parse_v3_frames(s, v3len); + tags.put("_hdrlen", v3len+v2hdr_len); + return tags; + } + + /* Parses all ID3v2 frames at the current position up until payload_len + ** bytes were read + */ + public HashMap parse_v3_frames(RandomAccessFile s, long payload_len) throws IOException { + HashMap tags = new HashMap(); + byte[] frame = new byte[10]; // a frame header is always 10 bytes + long bread = 0; // total amount of read bytes + + while(bread < payload_len) { + bread += s.read(frame); + String framename = new String(frame, 0, 4); + int slen = b2be32(frame, 4); + + /* Abort on silly sizes */ + if(slen < 1 || slen > 524288) + break; + + byte[] xpl = new byte[slen]; + bread += s.read(xpl); + + if(framename.substring(0,1).equals("T")) { + String[] nmzInfo = normalizeTaginfo(framename, xpl); + + for(int i = 0; i < nmzInfo.length; i += 2) { + String oggKey = nmzInfo[i]; + String decPld = nmzInfo[i + 1]; + + if (oggKey.length() > 0 && !tags.containsKey(oggKey)) { + addTagEntry(tags, oggKey, decPld); + } + } + } + else if(framename.equals("RVA2")) { + // + } + + } + return tags; + } + + /* Converts ID3v2 sillyframes to OggNames */ + private String[] normalizeTaginfo(String k, byte[] v) { + String[] rv = new String[] {"",""}; + HashMap lu = new HashMap<String, String>(); + lu.put("TIT2", "TITLE"); + lu.put("TALB", "ALBUM"); + lu.put("TPE1", "ARTIST"); + + if(lu.containsKey(k)) { + /* A normal, known key: translate into Ogg-Frame name */ + rv[0] = (String)lu.get(k); + rv[1] = getDecodedString(v); + } + else if(k.equals("TXXX")) { + /* A freestyle field, ieks! */ + String txData[] = getDecodedString(v).split(Character.toString('\0'), 2); + /* Check if we got replaygain info in key\0value style */ + if(txData.length == 2) { + if(txData[0].matches("^(?i)REPLAYGAIN_(ALBUM|TRACK)_GAIN$")) { + rv[0] = txData[0].toUpperCase(); /* some tagwriters use lowercase for this */ + rv[1] = txData[1]; + } else { + // Check for replaygain tags just thrown randomly in field + int nextStartIndex = 1; + int startName = txData[1].toLowerCase(Locale.US).indexOf("replaygain_"); + ArrayList<String> parts = new ArrayList<String>(); + while(startName != -1) { + int endName = txData[1].indexOf((char) 0, startName); + if(endName != -1) { + parts.add(txData[1].substring(startName, endName).toUpperCase()); + int endValue = txData[1].indexOf((char) 0, endName + 1); + if(endValue != -1) { + parts.add(txData[1].substring(endName + 1, endValue)); + nextStartIndex = endValue + 1; + } + } + + startName = txData[1].toLowerCase(Locale.US).indexOf("replaygain_", nextStartIndex); + } + + if(parts.size() > 0) { + rv = new String[parts.size()]; + rv = parts.toArray(rv); + } + } + } + } + + return rv; + } + + /* Converts a raw byte-stream text into a java String */ + private String getDecodedString(byte[] raw) { + int encid = raw[0] & 0xFF; + int len = raw.length; + String v = ""; + try { + if(encid == ID3_ENC_LATIN) { + v = new String(raw, 1, len-1, "ISO-8859-1"); + } + else if (encid == ID3_ENC_UTF8) { + v = new String(raw, 1, len-1, "UTF-8"); + } + else if (encid == ID3_ENC_UTF16LE) { + v = new String(raw, 3, len-3, "UTF-16LE"); + } + else if (encid == ID3_ENC_UTF16BE) { + v = new String(raw, 3, len-3, "UTF-16BE"); + } + } catch(Exception e) {} + return v; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java b/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java new file mode 100644 index 00000000..720ee87f --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/LameHeader.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package github.daneren2005.dsub.util.tags; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; +import java.util.Enumeration; + + +public class LameHeader extends Common { + + public LameHeader() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + return parseLameHeader(s, 0); + } + + public HashMap parseLameHeader(RandomAccessFile s, long offset) throws IOException { + HashMap tags = new HashMap(); + byte[] chunk = new byte[4]; + + s.seek(offset + 0x24); + s.read(chunk); + + String lameMark = new String(chunk, 0, chunk.length, "ISO-8859-1"); + + if(lameMark.equals("Info") || lameMark.equals("Xing")) { + s.seek(offset+0xAB); + s.read(chunk); + + int raw = b2be32(chunk, 0); + int gtrk_raw = raw >> 16; /* first 16 bits are the raw track gain value */ + int galb_raw = raw & 0xFFFF; /* the rest is for the album gain value */ + + float gtrk_val = (float)(gtrk_raw & 0x01FF)/10; + float galb_val = (float)(galb_raw & 0x01FF)/10; + + gtrk_val = ((gtrk_raw&0x0200)!=0 ? -1*gtrk_val : gtrk_val); + galb_val = ((galb_raw&0x0200)!=0 ? -1*galb_val : galb_val); + + if( (gtrk_raw&0xE000) == 0x2000 ) { + addTagEntry(tags, "REPLAYGAIN_TRACK_GAIN", gtrk_val+" dB"); + } + if( (gtrk_raw&0xE000) == 0x4000 ) { + addTagEntry(tags, "REPLAYGAIN_ALBUM_GAIN", galb_val+" dB"); + } + + } + + return tags; + } + +} diff --git a/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java b/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java new file mode 100644 index 00000000..d0b31671 --- /dev/null +++ b/app/src/main/java/github/daneren2005/dsub/util/tags/OggFile.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2013 Adrian Ulrich <adrian@blinkenlights.ch> + * + * This program 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. + * + * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + */ + +package github.daneren2005.dsub.util.tags; + + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.HashMap; + + +public class OggFile extends Common { + + private static final int OGG_PAGE_SIZE = 27; // Static size of an OGG Page + private static final int OGG_TYPE_COMMENT = 3; // ID of 'VorbisComment's + + public OggFile() { + } + + public HashMap getTags(RandomAccessFile s) throws IOException { + long offset = 0; + int retry = 64; + HashMap tags = new HashMap(); + + for( ; retry > 0 ; retry-- ) { + long res[] = parse_ogg_page(s, offset); + if(res[2] == OGG_TYPE_COMMENT) { + tags = parse_ogg_vorbis_comment(s, offset+res[0], res[1]); + break; + } + offset += res[0] + res[1]; + } + return tags; + } + + + /* Parses the ogg page at offset 'offset' and returns + ** [header_size, payload_size, type] + */ + private long[] parse_ogg_page(RandomAccessFile s, long offset) throws IOException { + long[] result = new long[3]; // [header_size, payload_size] + byte[] p_header = new byte[OGG_PAGE_SIZE]; // buffer for the page header + byte[] scratch; + int bread = 0; // number of bytes read + int psize = 0; // payload-size + int nsegs = 0; // Number of segments + + s.seek(offset); + bread = s.read(p_header); + if(bread != OGG_PAGE_SIZE) + xdie("Unable to read() OGG_PAGE_HEADER"); + if((new String(p_header, 0, 5)).equals("OggS\0") != true) + xdie("Invalid magic - not an ogg file?"); + + nsegs = b2u(p_header[26]); + // debug("> file seg: "+nsegs); + if(nsegs > 0) { + scratch = new byte[nsegs]; + bread = s.read(scratch); + if(bread != nsegs) + xdie("Failed to read segtable"); + + for(int i=0; i<nsegs; i++) { + psize += b2u(scratch[i]); + } + } + + // populate result array + result[0] = (s.getFilePointer() - offset); + result[1] = psize; + result[2] = -1; + + /* next byte is most likely the type -> pre-read */ + if(psize >= 1 && s.read(p_header, 0, 1) == 1) { + result[2] = b2u(p_header[0]); + } + + return result; + } + + /* In 'vorbiscomment' field is prefixed with \3vorbis in OGG files + ** we check that this marker is present and call the generic comment + ** parset with the correct offset (+7) */ + private HashMap parse_ogg_vorbis_comment(RandomAccessFile s, long offset, long pl_len) throws IOException { + final int pfx_len = 7; + byte[] pfx = new byte[pfx_len]; + + if(pl_len < pfx_len) + xdie("ogg vorbis comment field is too short!"); + + s.seek(offset); + s.read(pfx); + + if( (new String(pfx, 0, pfx_len)).equals("\3vorbis") == false ) + xdie("Damaged packet found!"); + + return parse_vorbis_comment(s, offset+pfx_len, pl_len-pfx_len); + } + +}; |