diff options
author | Scott Jackson <daneren2005@gmail.com> | 2012-07-02 21:24:02 -0700 |
---|---|---|
committer | Scott Jackson <daneren2005@gmail.com> | 2012-07-02 21:24:02 -0700 |
commit | a1a18f77a50804e0127dfa4b0f5240c49c541184 (patch) | |
tree | 19a38880afe505beddb5590379a8134d7730a277 /subsonic-android/src/net/sourceforge/subsonic/androidapp/util | |
parent | b61d787706979e7e20f4c3c4f93c1f129d92273f (diff) | |
download | dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.tar.gz dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.tar.bz2 dsub-a1a18f77a50804e0127dfa4b0f5240c49c541184.zip |
Initial Commit
Diffstat (limited to 'subsonic-android/src/net/sourceforge/subsonic/androidapp/util')
26 files changed, 3691 insertions, 0 deletions
diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java new file mode 100644 index 00000000..a4dd3acd --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/AlbumView.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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; + +/** + * Used to display albums in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class AlbumView extends LinearLayout { + + private TextView titleView; + private TextView artistView; + private View coverArtView; + + public AlbumView(Context context) { + super(context); + LayoutInflater.from(context).inflate(R.layout.album_list_item, this, true); + + titleView = (TextView) findViewById(R.id.album_title); + artistView = (TextView) findViewById(R.id.album_artist); + coverArtView = findViewById(R.id.album_coverart); + } + + public void setAlbum(MusicDirectory.Entry album, ImageLoader imageLoader) { + titleView.setText(album.getTitle()); + artistView.setText(album.getArtist()); + artistView.setVisibility(album.getArtist() == null ? View.GONE : View.VISIBLE); + imageLoader.loadImage(coverArtView, album, false, true); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java new file mode 100644 index 00000000..98ed3c9b --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java @@ -0,0 +1,78 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import net.sourceforge.subsonic.androidapp.domain.Artist; +import net.sourceforge.subsonic.androidapp.R; +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; +import android.content.Context; + +import java.util.List; +import java.util.Set; +import java.util.LinkedHashSet; +import java.util.ArrayList; + +/** + * @author Sindre Mehus +*/ +public class ArtistAdapter extends ArrayAdapter<Artist> implements SectionIndexer { + + // Both arrays are indexed by section ID. + private final Object[] sections; + private final Integer[] positions; + + public ArtistAdapter(Context context, List<Artist> artists) { + super(context, R.layout.artist_list_item, artists); + + Set<String> sectionSet = new LinkedHashSet<String>(30); + List<Integer> positionList = new ArrayList<Integer>(30); + for (int i = 0; i < artists.size(); i++) { + Artist artist = artists.get(i); + String index = artist.getIndex(); + if (!sectionSet.contains(index)) { + sectionSet.add(index); + positionList.add(i); + } + } + sections = sectionSet.toArray(new Object[sectionSet.size()]); + positions = positionList.toArray(new Integer[positionList.size()]); + } + + @Override + public Object[] getSections() { + return sections; + } + + @Override + public int getPositionForSection(int section) { + section = Math.min(section, positions.length - 1); + return positions[section]; + } + + @Override + public int getSectionForPosition(int pos) { + for (int i = 0; i < sections.length - 1; i++) { + if (pos < positions[i + 1]) { + return i; + } + } + return sections.length - 1; + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java new file mode 100644 index 00000000..1db2fdc1 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java @@ -0,0 +1,96 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import java.io.FileNotFoundException; +import java.io.IOException; + +import org.xmlpull.v1.XmlPullParserException; + +import android.app.Activity; +import android.os.Handler; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.R; + +/** + * @author Sindre Mehus + */ +public abstract class BackgroundTask<T> implements ProgressListener { + + private static final String TAG = BackgroundTask.class.getSimpleName(); + private final Activity activity; + private final Handler handler; + + public BackgroundTask(Activity activity) { + this.activity = activity; + handler = new Handler(); + } + + protected Activity getActivity() { + return activity; + } + + 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); + new ErrorDialog(activity, getErrorMessage(error), true); + } + + protected String getErrorMessage(Throwable error) { + + if (error instanceof IOException && !Util.isNetworkConnected(activity)) { + return activity.getResources().getString(R.string.background_task_no_network); + } + + if (error instanceof FileNotFoundException) { + return activity.getResources().getString(R.string.background_task_not_found); + } + + if (error instanceof IOException) { + return activity.getResources().getString(R.string.background_task_network_error); + } + + if (error instanceof XmlPullParserException) { + return activity.getResources().getString(R.string.background_task_parse_error); + } + + String message = error.getMessage(); + if (message != null) { + return message; + } + return error.getClass().getSimpleName(); + } + + @Override + public abstract void updateProgress(final String message); + + @Override + public void updateProgress(int messageId) { + updateProgress(activity.getResources().getString(messageId)); + } +}
\ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java new file mode 100644 index 00000000..46459571 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java @@ -0,0 +1,171 @@ +package net.sourceforge.subsonic.androidapp.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 net.sourceforge.subsonic.androidapp.service.DownloadFile; +import net.sourceforge.subsonic.androidapp.service.DownloadService; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class CacheCleaner { + + private static final String TAG = CacheCleaner.class.getSimpleName(); + private static final double MAX_FILE_SYSTEM_USAGE = 0.95; + + private final Context context; + private final DownloadService downloadService; + + public CacheCleaner(Context context, DownloadService downloadService) { + this.context = context; + this.downloadService = downloadService; + } + + public void clean() { + + Log.i(TAG, "Starting cache cleaning."); + + if (downloadService == null) { + Log.e(TAG, "DownloadService not set. Aborting cache cleaning."); + return; + } + + try { + + List<File> files = new ArrayList<File>(); + List<File> dirs = new ArrayList<File>(); + + findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, dirs); + sortByAscendingModificationTime(files); + + Set<File> undeletable = findUndeletableFiles(); + + deleteFiles(files, undeletable); + deleteEmptyDirs(dirs, undeletable); + Log.i(TAG, "Completed cache cleaning."); + + } catch (RuntimeException x) { + Log.e(TAG, "Error in cache cleaning.", x); + } + } + + private void deleteEmptyDirs(List<File> dirs, Set<File> undeletable) { + for (File dir : dirs) { + if (undeletable.contains(dir)) { + continue; + } + + File[] children = dir.listFiles(); + + // Delete empty directory and associated album artwork. + if (children.length == 0) { + Util.delete(dir); + Util.delete(FileUtil.getAlbumArtFile(dir)); + } + } + } + + private void deleteFiles(List<File> files, Set<File> undeletable) { + + if (files.isEmpty()) { + return; + } + + long cacheSizeBytes = Util.getCacheSizeMB(context) * 1024L * 1024L; + + long bytesUsedBySubsonic = 0L; + for (File file : files) { + 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 = Math.round(MAX_FILE_SYSTEM_USAGE * (double) bytesTotalFs); + + 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)); + + long bytesDeleted = 0L; + for (File file : files) { + + if (file.getName().equals(Constants.ALBUM_ART_FILE)) { + // Move artwork to new folder. + file.renameTo(FileUtil.getAlbumArtFile(file.getParentFile())); + + } else if (bytesToDelete > bytesDeleted || file.getName().endsWith(".partial") || file.getName().contains(".partial.")) { + if (!undeletable.contains(file)) { + long size = file.length(); + if (Util.delete(file)) { + bytesDeleted += size; + } + } + } + } + + Log.i(TAG, "Deleted : " + Util.formatBytes(bytesDeleted)); + Log.i(TAG, "Cache size after : " + Util.formatBytes(bytesUsedBySubsonic - bytesDeleted)); + } + + private void findCandidatesForDeletion(File file, List<File> files, List<File> dirs) { + if (file.isFile()) { + String name = file.getName(); + boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete."); + boolean isAlbumArtFile = name.equals(Constants.ALBUM_ART_FILE); + if (isCacheFile || isAlbumArtFile) { + files.add(file); + } + } else { + // Depth-first + for (File child : FileUtil.listFiles(file)) { + findCandidatesForDeletion(child, files, 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; + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java new file mode 100644 index 00000000..9c8b06e1 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java @@ -0,0 +1,87 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import android.util.Log; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class CancellableTask { + + private static final String TAG = CancellableTask.class.getSimpleName(); + + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final AtomicReference<Thread> thread = new AtomicReference<Thread>(); + private final AtomicReference<OnCancelListener> cancelListener = new AtomicReference<OnCancelListener>(); + + public void cancel() { + Log.d(TAG, "Cancelling " + CancellableTask.this); + cancelled.set(true); + + OnCancelListener listener = cancelListener.get(); + if (listener != null) { + try { + listener.onCancel(); + } catch (Throwable x) { + Log.w(TAG, "Error when invoking OnCancelListener.", x); + } + } + } + + public boolean isCancelled() { + return cancelled.get(); + } + + public void setOnCancelListener(OnCancelListener listener) { + cancelListener.set(listener); + } + + public boolean isRunning() { + return running.get(); + } + + public abstract void execute(); + + public void start() { + thread.set(new Thread() { + @Override + public void run() { + running.set(true); + Log.d(TAG, "Starting thread for " + CancellableTask.this); + try { + execute(); + } finally { + running.set(false); + Log.d(TAG, "Stopping thread for " + CancellableTask.this); + } + } + }); + thread.get().start(); + } + + public static interface OnCancelListener { + void onCancel(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java new file mode 100644 index 00000000..bebe49ce --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Constants.java @@ -0,0 +1,91 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.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 = "android"; + + // 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_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_ERROR = "subsonic.error"; + 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_ALBUM_LIST_TYPE = "subsonic.albumlisttype"; + 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_NAME_REFRESH = "subsonic.refresh"; + public static final String INTENT_EXTRA_REQUEST_SEARCH = "subsonic.requestsearch"; + public static final String INTENT_EXTRA_NAME_EXIT = "subsonic.exit" ; + + // Notification IDs. + public static final int NOTIFICATION_ID_PLAYING = 100; + public static final int NOTIFICATION_ID_ERROR = 101; + + // Preferences keys. + 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_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_MAX_BITRATE_WIFI = "maxBitrateWifi"; + public static final String PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile"; + 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 = "preloadCount"; + 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"; + + // Name of the preferences file. + public static final String PREFERENCES_FILE_NAME = "net.sourceforge.subsonic.androidapp_preferences"; + + // 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 = "folder.jpeg"; + + private Constants() { + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java new file mode 100644 index 00000000..1b4d72cf --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java @@ -0,0 +1,71 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2010 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.util; + +import java.util.List; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import net.sourceforge.subsonic.androidapp.activity.SubsonicTabActivity; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; + +/** + * @author Sindre Mehus + */ +public class EntryAdapter extends ArrayAdapter<MusicDirectory.Entry> { + + private final SubsonicTabActivity activity; + private final ImageLoader imageLoader; + private final boolean checkable; + + public EntryAdapter(SubsonicTabActivity activity, ImageLoader imageLoader, List<MusicDirectory.Entry> entries, boolean checkable) { + super(activity, android.R.layout.simple_list_item_1, entries); + this.activity = activity; + this.imageLoader = imageLoader; + this.checkable = checkable; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + MusicDirectory.Entry entry = getItem(position); + + if (entry.isDirectory()) { + AlbumView view; + // TODO: Reuse AlbumView objects once cover art loading is working. +// if (convertView != null && convertView instanceof AlbumView) { +// view = (AlbumView) convertView; +// } else { + view = new AlbumView(activity); +// } + view.setAlbum(entry, imageLoader); + return view; + + } else { + SongView view; + if (convertView != null && convertView instanceof SongView) { + view = (SongView) convertView; + } else { + view = new SongView(activity); + } + view.setSong(entry, checkable); + return view; + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java new file mode 100644 index 00000000..b1c51573 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java @@ -0,0 +1,61 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import net.sourceforge.subsonic.androidapp.R; + +/** + * @author Sindre Mehus + */ +public class ErrorDialog { + + public ErrorDialog(Activity activity, int messageId, boolean finishActivityOnCancel) { + this(activity, activity.getResources().getString(messageId), finishActivityOnCancel); + } + + public ErrorDialog(final Activity activity, String message, final boolean finishActivityOnClose) { + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setTitle(R.string.error_label); + builder.setMessage(message); + builder.setCancelable(true); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + if (finishActivityOnClose) { + activity.finish(); + } + } + }); + builder.setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + if (finishActivityOnClose) { + activity.finish(); + } + } + }); + + builder.create().show(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java new file mode 100644 index 00000000..6cdd6fb1 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java @@ -0,0 +1,301 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Arrays; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.Iterator; +import java.util.List; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Environment; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; + +/** + * @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 File DEFAULT_MUSIC_DIR = createDirectory("music"); + + 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.getTranscodedSuffix() != null) { + fileName.append(song.getTranscodedSuffix()); + } else { + fileName.append(song.getSuffix()); + } + + return new File(dir, fileName.toString()); + } + + public static File getAlbumArtFile(Context context, MusicDirectory.Entry entry) { + File albumDir = getAlbumDirectory(context, entry); + return getAlbumArtFile(albumDir); + } + + public static File getAlbumArtFile(File albumDir) { + File albumArtDir = getAlbumArtDirectory(); + return new File(albumArtDir, Util.md5Hex(albumDir.getPath()) + ".jpeg"); + } + + public static Bitmap getAlbumArtBitmap(Context context, MusicDirectory.Entry entry, int size) { + File albumArtFile = getAlbumArtFile(context, entry); + if (albumArtFile.exists()) { + Bitmap bitmap = BitmapFactory.decodeFile(albumArtFile.getPath()); + return bitmap == null ? null : Bitmap.createScaledBitmap(bitmap, size, size, true); + } + return null; + } + + public static File getAlbumArtDirectory() { + File albumArtDir = new File(getSubsonicDirectory(), "artwork"); + ensureDirectoryExistsAndIsReadWritable(albumArtDir); + ensureDirectoryExistsAndIsReadWritable(new File(albumArtDir, ".nomedia")); + return albumArtDir; + } + + private static File getAlbumDirectory(Context context, MusicDirectory.Entry entry) { + File dir; + if (entry.getPath() != null) { + File f = new File(fileSystemSafeDir(entry.getPath())); + dir = new File(getMusicDirectory(context).getPath() + "/" + (entry.isDirectory() ? f.getPath() : f.getParent())); + } else { + String artist = fileSystemSafe(entry.getArtist()); + String album = fileSystemSafe(entry.getAlbum()); + dir = new File(getMusicDirectory(context).getPath() + "/" + artist + "/" + album); + } + 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(String name) { + File dir = new File(getSubsonicDirectory(), name); + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "Failed to create " + name); + } + return dir; + } + + public static File getSubsonicDirectory() { + return new File(Environment.getExternalStorageDirectory(), "subsonic"); + } + + public static File getDefaultMusicDirectory() { + return DEFAULT_MUSIC_DIR; + } + + public static File getMusicDirectory(Context context) { + String path = Util.getPreferences(context).getString(Constants.PREFERENCES_KEY_CACHE_LOCATION, DEFAULT_MUSIC_DIR.getPath()); + File dir = new File(path); + return ensureDirectoryExistsAndIsReadWritable(dir) ? dir : getDefaultMusicDirectory(); + } + + 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; + } + + /** + * 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> listMusicFiles(File dir) { + SortedSet<File> files = listFiles(dir); + Iterator<File> iterator = files.iterator(); + while (iterator.hasNext()) { + File file = iterator.next(); + if (!file.isDirectory() && !isMusicFile(file)) { + iterator.remove(); + } + } + return files; + } + + private static boolean isMusicFile(File file) { + String extension = getExtension(file.getName()); + return MUSIC_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 <T extends Serializable> boolean serialize(Context context, T obj, String fileName) { + File file = new File(context.getCacheDir(), fileName); + ObjectOutputStream out = null; + try { + out = new ObjectOutputStream(new FileOutputStream(file)); + out.writeObject(obj); + Log.i(TAG, "Serialized object to " + file); + return true; + } catch (Throwable x) { + Log.w(TAG, "Failed to serialize object to " + file); + return false; + } finally { + Util.close(out); + } + } + + public static <T extends Serializable> T deserialize(Context context, String fileName) { + File file = new File(context.getCacheDir(), fileName); + if (!file.exists() || !file.isFile()) { + return null; + } + + ObjectInputStream in = null; + try { + in = new ObjectInputStream(new FileInputStream(file)); + T result = (T) in.readObject(); + Log.i(TAG, "Deserialized object from " + file); + return result; + } catch (Throwable x) { + Log.w(TAG, "Failed to deserialize object from " + file, x); + return null; + } finally { + Util.close(in); + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java new file mode 100644 index 00000000..6a79a0a0 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java @@ -0,0 +1,141 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ProgressBar; +import net.sourceforge.subsonic.androidapp.R; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class HorizontalSlider extends ProgressBar { + + private final Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.slider_knob); + private boolean slidingEnabled; + private OnSliderChangeListener listener; + private static final int PADDING = 2; + private boolean sliding; + private int sliderPosition; + private int startPosition; + + public interface OnSliderChangeListener { + void onSliderChanged(View view, int position, boolean inProgress); + } + + public HorizontalSlider(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public HorizontalSlider(Context context, AttributeSet attrs) { + super(context, attrs, android.R.attr.progressBarStyleHorizontal); + } + + public HorizontalSlider(Context context) { + super(context); + } + + public void setSlidingEnabled(boolean slidingEnabled) { + if (this.slidingEnabled != slidingEnabled) { + this.slidingEnabled = slidingEnabled; + invalidate(); + } + } + + public boolean isSlidingEnabled() { + return slidingEnabled; + } + + public void setOnSliderChangeListener(OnSliderChangeListener listener) { + this.listener = listener; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + int max = getMax(); + if (!slidingEnabled || max == 0) { + return; + } + + int paddingLeft = getPaddingLeft(); + int paddingRight = getPaddingRight(); + int paddingTop = getPaddingTop(); + int paddingBottom = getPaddingBottom(); + + int w = getWidth() - paddingLeft - paddingRight; + int h = getHeight() - paddingTop - paddingBottom; + int position = sliding ? sliderPosition : getProgress(); + + int bitmapWidth = bitmap.getWidth(); + int bitmapHeight = bitmap.getWidth(); + float x = paddingLeft + w * ((float) position / max) - bitmapWidth / 2.0F; + x = Math.max(x, paddingLeft); + x = Math.min(x, paddingLeft + w - bitmapWidth); + float y = paddingTop + h / 2.0F - bitmapHeight / 2.0F; + + canvas.drawBitmap(bitmap, x, y, null); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!slidingEnabled) { + return false; + } + + int action = event.getAction(); + + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) { + + if (action == MotionEvent.ACTION_DOWN) { + sliding = true; + startPosition = getProgress(); + } + + float x = event.getX() - PADDING; + float width = getWidth() - 2 * PADDING; + sliderPosition = Math.round((float) getMax() * (x / width)); + sliderPosition = Math.max(sliderPosition, 0); + + setProgress(Math.min(startPosition, sliderPosition)); + setSecondaryProgress(Math.max(startPosition, sliderPosition)); + if (listener != null) { + listener.onSliderChanged(this, sliderPosition, true); + } + + } else if (action == MotionEvent.ACTION_UP) { + sliding = false; + setProgress(sliderPosition); + setSecondaryProgress(0); + if (listener != null) { + listener.onSliderChanged(this, sliderPosition, false); + } + } + + return true; + } +}
\ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java new file mode 100644 index 00000000..5cbd8c9f --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java @@ -0,0 +1,252 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Matrix; +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.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.service.MusicService; +import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Asynchronous loading of images, with caching. + * <p/> + * There should normally be only one instance of this class. + * + * @author Sindre Mehus + */ +public class ImageLoader implements Runnable { + + private static final String TAG = ImageLoader.class.getSimpleName(); + private static final int CONCURRENCY = 5; + + private final LRUCache<String, Drawable> cache = new LRUCache<String, Drawable>(100); + private final BlockingQueue<Task> queue; + private final int imageSizeDefault; + private final int imageSizeLarge; + private Drawable largeUnknownImage; + + public ImageLoader(Context context) { + queue = new LinkedBlockingQueue<Task>(500); + + // Determine the density-dependent image sizes. + imageSizeDefault = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight(); + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + imageSizeLarge = (int) Math.round(Math.min(metrics.widthPixels, metrics.heightPixels) * 0.6); + + for (int i = 0; i < CONCURRENCY; i++) { + new Thread(this, "ImageLoader").start(); + } + + createLargeUnknownImage(context); + } + + private void createLargeUnknownImage(Context context) { + BitmapDrawable drawable = (BitmapDrawable) context.getResources().getDrawable(R.drawable.unknown_album_large); + Bitmap bitmap = Bitmap.createScaledBitmap(drawable.getBitmap(), imageSizeLarge, imageSizeLarge, true); + bitmap = createReflection(bitmap); + largeUnknownImage = Util.createDrawableFromBitmap(context, bitmap); + } + + public void loadImage(View view, MusicDirectory.Entry entry, boolean large, boolean crossfade) { + if (entry == null || entry.getCoverArt() == null) { + setUnknownImage(view, large); + return; + } + + int size = large ? imageSizeLarge : imageSizeDefault; + Drawable drawable = cache.get(getKey(entry.getCoverArt(), size)); + if (drawable != null) { + setImage(view, drawable, large); + return; + } + + if (!large) { + setUnknownImage(view, large); + } + queue.offer(new Task(view, entry, size, large, large, crossfade)); + } + + private String getKey(String coverArtId, int size) { + return coverArtId + size; + } + + private void setImage(View view, 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) { + ImageView imageView = (ImageView) view; + if (crossfade) { + + Drawable existingDrawable = imageView.getDrawable(); + if (existingDrawable == null) { + Bitmap emptyImage = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + existingDrawable = new BitmapDrawable(emptyImage); + } + + Drawable[] layers = new Drawable[]{existingDrawable, drawable}; + + TransitionDrawable transitionDrawable = new TransitionDrawable(layers); + imageView.setImageDrawable(transitionDrawable); + transitionDrawable.startTransition(250); + } else { + imageView.setImageDrawable(drawable); + } + } + } + + private void setUnknownImage(View view, boolean large) { + if (large) { + setImage(view, largeUnknownImage, false); + } else { + if (view instanceof TextView) { + ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(R.drawable.unknown_album, 0, 0, 0); + } else if (view instanceof ImageView) { + ((ImageView) view).setImageResource(R.drawable.unknown_album); + } + } + } + + public void clear() { + queue.clear(); + } + + @Override + public void run() { + while (true) { + try { + Task task = queue.take(); + task.execute(); + } catch (Throwable x) { + Log.e(TAG, "Unexpected exception in ImageLoader.", x); + } + } + } + + private Bitmap createReflection(Bitmap originalImage) { + + int width = originalImage.getWidth(); + int height = originalImage.getHeight(); + + // The gap we want between the reflection and the original image + final int reflectionGap = 4; + + // This will not scale but will flip on the Y axis + Matrix matrix = new Matrix(); + matrix.preScale(1, -1); + + // Create a Bitmap with the flip matix applied to it. + // We only want the bottom half of the image + Bitmap reflectionImage = Bitmap.createBitmap(originalImage, 0, height / 2, width, height / 2, matrix, false); + + // Create a new bitmap with same width but taller to fit reflection + Bitmap bitmapWithReflection = Bitmap.createBitmap(width, (height + height / 2), Bitmap.Config.ARGB_8888); + + // Create a new Canvas with the bitmap that's big enough for + // the image plus gap plus reflection + Canvas canvas = new Canvas(bitmapWithReflection); + + // Draw in the original image + canvas.drawBitmap(originalImage, 0, 0, null); + + // Draw in the gap + Paint defaultPaint = new Paint(); + canvas.drawRect(0, height, width, height + reflectionGap, defaultPaint); + + // Draw in the reflection + canvas.drawBitmap(reflectionImage, 0, height + reflectionGap, null); + + // Create a shader that is a linear gradient that covers the reflection + Paint paint = new Paint(); + LinearGradient shader = new LinearGradient(0, originalImage.getHeight(), 0, + bitmapWithReflection.getHeight() + reflectionGap, 0x70000000, 0xff000000, + Shader.TileMode.CLAMP); + + // Set the paint to use this shader (linear gradient) + paint.setShader(shader); + + // Draw a rectangle using the paint with our linear gradient + canvas.drawRect(0, height, width, bitmapWithReflection.getHeight() + reflectionGap, paint); + + return bitmapWithReflection; + } + + private class Task { + private final View view; + private final MusicDirectory.Entry entry; + private final Handler handler; + private final int size; + private final boolean reflection; + private final boolean saveToFile; + private final boolean crossfade; + + public Task(View view, MusicDirectory.Entry entry, int size, boolean reflection, boolean saveToFile, boolean crossfade) { + this.view = view; + this.entry = entry; + this.size = size; + this.reflection = reflection; + this.saveToFile = saveToFile; + this.crossfade = crossfade; + handler = new Handler(); + } + + public void execute() { + try { + MusicService musicService = MusicServiceFactory.getMusicService(view.getContext()); + Bitmap bitmap = musicService.getCoverArt(view.getContext(), entry, size, saveToFile, null); + + if (reflection) { + bitmap = createReflection(bitmap); + } + + final Drawable drawable = Util.createDrawableFromBitmap(view.getContext(), bitmap); + cache.put(getKey(entry.getCoverArt(), size), drawable); + + handler.post(new Runnable() { + @Override + public void run() { + setImage(view, drawable, crossfade); + } + }); + } catch (Throwable x) { + Log.e(TAG, "Failed to download album art.", x); + } + } + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java new file mode 100644 index 00000000..f6145fb7 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java @@ -0,0 +1,102 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import java.lang.ref.SoftReference; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Sindre Mehus + */ +public class LRUCache<K,V>{ + + private final int capacity; + private final Map<K, TimestampedValue> map; + + public LRUCache(int capacity) { + map = new HashMap<K, TimestampedValue>(capacity); + this.capacity = capacity; + } + + public synchronized V get(K key) { + TimestampedValue value = map.get(key); + + V result = null; + if (value != null) { + value.updateTimestamp(); + result = value.getValue(); + } + + return result; + } + + public synchronized void put(K key, V value) { + if (map.size() >= capacity) { + removeOldest(); + } + map.put(key, new TimestampedValue(value)); + } + + public void clear() { + map.clear(); + } + + private void removeOldest() { + K oldestKey = null; + long oldestTimestamp = Long.MAX_VALUE; + + for (Map.Entry<K, TimestampedValue> entry : map.entrySet()) { + K key = entry.getKey(); + TimestampedValue value = entry.getValue(); + if (value.getTimestamp() < oldestTimestamp) { + oldestTimestamp = value.getTimestamp(); + oldestKey = key; + } + } + + if (oldestKey != null) { + map.remove(oldestKey); + } + } + + private final class TimestampedValue { + + private final SoftReference<V> value; + private long timestamp; + + public TimestampedValue(V value) { + this.value = new SoftReference<V>(value); + updateTimestamp(); + } + + public V getValue() { + return value.get(); + } + + public long getTimestamp() { + return timestamp; + } + + public void updateTimestamp() { + timestamp = System.currentTimeMillis(); + } + } + +}
\ No newline at end of file diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java new file mode 100644 index 00000000..97dbc125 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java @@ -0,0 +1,290 @@ +/*** + Copyright (c) 2008-2009 CommonsWare, LLC + Portions (c) 2009 Google, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +package net.sourceforge.subsonic.androidapp.util; + +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ListAdapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Arrays; + +/** + * Adapter that merges multiple child adapters and views + * into a single contiguous whole. + * <p/> + * Adapters used as pieces within MergeAdapter must + * have view type IDs monotonically increasing from 0. Ideally, + * adapters also have distinct ranges for their row ids, as + * returned by getItemId(). + */ +public class MergeAdapter extends BaseAdapter { + + private final CascadeDataSetObserver observer = new CascadeDataSetObserver(); + private final ArrayList<ListAdapter> pieces = new ArrayList<ListAdapter>(); + + /** + * Stock constructor, simply chaining to the superclass. + */ + public MergeAdapter() { + super(); + } + + /** + * Adds a new adapter to the roster of things to appear + * in the aggregate list. + * + * @param adapter Source for row views for this section + */ + public void addAdapter(ListAdapter adapter) { + pieces.add(adapter); + adapter.registerDataSetObserver(observer); + } + + public void removeAdapter(ListAdapter adapter) { + adapter.unregisterDataSetObserver(observer); + pieces.remove(adapter); + } + + /** + * Adds a new View to the roster of things to appear + * in the aggregate list. + * + * @param view Single view to add + */ + public ListAdapter addView(View view) { + return addView(view, false); + } + + /** + * Adds a new View to the roster of things to appear + * in the aggregate list. + * + * @param view Single view to add + * @param enabled false if views are disabled, true if enabled + */ + public ListAdapter addView(View view, boolean enabled) { + return addViews(Arrays.asList(view), enabled); + } + + /** + * Adds a list of views to the roster of things to appear + * in the aggregate list. + * + * @param views List of views to add + */ + public ListAdapter addViews(List<View> views) { + return addViews(views, false); + } + + /** + * Adds a list of views to the roster of things to appear + * in the aggregate list. + * + * @param views List of views to add + * @param enabled false if views are disabled, true if enabled + */ + public ListAdapter addViews(List<View> views, boolean enabled) { + ListAdapter adapter = enabled ? new EnabledSackAdapter(views) : new SackOfViewsAdapter(views); + addAdapter(adapter); + return adapter; + } + + /** + * Get the data item associated with the specified + * position in the data set. + * + * @param position Position of the item whose data we want + */ + @Override + public Object getItem(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.getItem(position)); + } + + position -= size; + } + + return (null); + } + + /** + * How many items are in the data set represented by this + * Adapter. + */ + @Override + public int getCount() { + int total = 0; + + for (ListAdapter piece : pieces) { + total += piece.getCount(); + } + + return (total); + } + + /** + * Returns the number of types of Views that will be + * created by getView(). + */ + @Override + public int getViewTypeCount() { + int total = 0; + + for (ListAdapter piece : pieces) { + total += piece.getViewTypeCount(); + } + + return (Math.max(total, 1)); // needed for setListAdapter() before content add' + } + + /** + * Get the type of View that will be created by getView() + * for the specified item. + * + * @param position Position of the item whose data we want + */ + @Override + public int getItemViewType(int position) { + int typeOffset = 0; + int result = -1; + + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + result = typeOffset + piece.getItemViewType(position); + break; + } + + position -= size; + typeOffset += piece.getViewTypeCount(); + } + + return (result); + } + + /** + * Are all items in this ListAdapter enabled? If yes it + * means all items are selectable and clickable. + */ + @Override + public boolean areAllItemsEnabled() { + return (false); + } + + /** + * Returns true if the item at the specified position is + * not a separator. + * + * @param position Position of the item whose data we want + */ + @Override + public boolean isEnabled(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.isEnabled(position)); + } + + position -= size; + } + + return (false); + } + + /** + * Get a View that displays the data at the specified + * position in the data set. + * + * @param position Position of the item whose data we want + * @param convertView View to recycle, if not null + * @param parent ViewGroup containing the returned View + */ + @Override + public View getView(int position, View convertView, + ViewGroup parent) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + + return (piece.getView(position, convertView, parent)); + } + + position -= size; + } + + return (null); + } + + /** + * Get the row id associated with the specified position + * in the list. + * + * @param position Position of the item whose data we want + */ + @Override + public long getItemId(int position) { + for (ListAdapter piece : pieces) { + int size = piece.getCount(); + + if (position < size) { + return (piece.getItemId(position)); + } + + position -= size; + } + + return (-1); + } + + private static class EnabledSackAdapter extends SackOfViewsAdapter { + public EnabledSackAdapter(List<View> views) { + super(views); + } + + @Override + public boolean areAllItemsEnabled() { + return (true); + } + + @Override + public boolean isEnabled(int position) { + return (true); + } + } + + private class CascadeDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + notifyDataSetInvalidated(); + } + } +} + diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java new file mode 100644 index 00000000..15e2add2 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java @@ -0,0 +1,139 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.util.Log; +import net.sourceforge.subsonic.androidapp.R; + +/** + * @author Sindre Mehus + */ +public abstract class ModalBackgroundTask<T> extends BackgroundTask<T> { + + private static final String TAG = ModalBackgroundTask.class.getSimpleName(); + + private final AlertDialog progressDialog; + private Thread thread; + private final boolean finishActivityOnCancel; + private boolean cancelled; + + public ModalBackgroundTask(Activity activity, boolean finishActivityOnCancel) { + super(activity); + this.finishActivityOnCancel = finishActivityOnCancel; + progressDialog = createProgressDialog(); + } + + public ModalBackgroundTask(Activity activity) { + this(activity, true); + } + + private AlertDialog createProgressDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(android.R.drawable.ic_dialog_info); + builder.setTitle(R.string.background_task_wait); + builder.setMessage(R.string.background_task_loading); + builder.setCancelable(true); + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + cancel(); + } + }); + builder.setPositiveButton(R.string.common_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + cancel(); + } + }); + + return builder.create(); + } + + public void execute() { + cancelled = false; + progressDialog.show(); + + thread = new Thread() { + @Override + public void run() { + try { + final T result = doInBackground(); + if (cancelled) { + progressDialog.dismiss(); + return; + } + + getHandler().post(new Runnable() { + @Override + public void run() { + progressDialog.dismiss(); + done(result); + } + }); + + } catch (final Throwable t) { + if (cancelled) { + return; + } + getHandler().post(new Runnable() { + @Override + public void run() { + progressDialog.dismiss(); + error(t); + } + }); + } + } + }; + thread.start(); + } + + protected void cancel() { + cancelled = true; + if (thread != null) { + thread.interrupt(); + } + + if (finishActivityOnCancel) { + getActivity().finish(); + } + } + + protected boolean isCancelled() { + return cancelled; + } + + protected void error(Throwable error) { + Log.w(TAG, "Got exception: " + error, error); + new ErrorDialog(getActivity(), getErrorMessage(error), finishActivityOnCancel); + } + + @Override + public void updateProgress(final String message) { + getHandler().post(new Runnable() { + @Override + public void run() { + progressDialog.setMessage(message); + } + }); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java new file mode 100644 index 00000000..94f217ff --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java @@ -0,0 +1,53 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ViewFlipper; + +/** + * Work-around for Android Issue 6191 (http://code.google.com/p/android/issues/detail?id=6191) + * + * @author Sindre Mehus + * @version $Id$ + */ +public class MyViewFlipper extends ViewFlipper { + + public MyViewFlipper(Context context) { + super(context); + } + + public MyViewFlipper(Context context, AttributeSet attrs) { + super(context, attrs); + } + + + @Override + protected void onDetachedFromWindow() { + try { + super.onDetachedFromWindow(); + } + catch (IllegalArgumentException e) { + // Call stopFlipping() in order to kick off updateRunning() + stopFlipping(); + } + } +} + diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Pair.java new file mode 100644 index 00000000..73dc3224 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/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 net.sourceforge.subsonic.androidapp.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/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java new file mode 100644 index 00000000..16028c12 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/PlaylistAdapter.java @@ -0,0 +1,99 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.widget.ArrayAdapter; +import android.widget.SectionIndexer; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.Playlist; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** +* @author Sindre Mehus +* @version $Id$ +*/ +public class PlaylistAdapter extends ArrayAdapter<Playlist> implements SectionIndexer { + + // Both arrays are indexed by section ID. + private final Object[] sections; + private final Integer[] positions; + + /** + * Note: playlists must be sorted alphabetically. + */ + public PlaylistAdapter(Context context, List<Playlist> playlists) { + super(context, R.layout.playlist_list_item, playlists); + + Set<String> sectionSet = new LinkedHashSet<String>(30); + List<Integer> positionList = new ArrayList<Integer>(30); + for (int i = 0; i < playlists.size(); i++) { + Playlist playlist = playlists.get(i); + if (playlist.getName().length() > 0) { + String index = playlist.getName().substring(0, 1).toUpperCase(); + if (!sectionSet.contains(index)) { + sectionSet.add(index); + positionList.add(i); + } + } + } + sections = sectionSet.toArray(new Object[sectionSet.size()]); + positions = positionList.toArray(new Integer[positionList.size()]); + } + + @Override + public Object[] getSections() { + return sections; + } + + @Override + public int getPositionForSection(int section) { + section = Math.min(section, positions.length - 1); + return positions[section]; + } + + @Override + public int getSectionForPosition(int pos) { + for (int i = 0; i < sections.length - 1; i++) { + if (pos < positions[i + 1]) { + return i; + } + } + return sections.length - 1; + } + + public static class PlaylistComparator implements Comparator<Playlist> { + @Override + public int compare(Playlist playlist1, Playlist playlist2) { + return playlist1.getName().compareToIgnoreCase(playlist2.getName()); + } + + public static List<Playlist> sort(List<Playlist> playlists) { + Collections.sort(playlists, new PlaylistComparator()); + return playlists; + } + + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java new file mode 100644 index 00000000..0d2924f7 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/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 net.sourceforge.subsonic.androidapp.util; + +/** + * @author Sindre Mehus + */ +public interface ProgressListener { + void updateProgress(String message); + void updateProgress(int messageId); +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java new file mode 100644 index 00000000..ca825e55 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java @@ -0,0 +1,181 @@ +/*** + Copyright (c) 2008-2009 CommonsWare, LLC + Portions (c) 2009 Google, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain + a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package net.sourceforge.subsonic.androidapp.util; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ListView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adapter that simply returns row views from a list. + * <p/> + * If you supply a size, you must implement newView(), to + * create a required view. The adapter will then cache these + * views. + * <p/> + * If you supply a list of views in the constructor, that + * list will be used directly. If any elements in the list + * are null, then newView() will be called just for those + * slots. + * <p/> + * Subclasses may also wish to override areAllItemsEnabled() + * (default: false) and isEnabled() (default: false), if some + * of their rows should be selectable. + * <p/> + * It is assumed each view is unique, and therefore will not + * get recycled. + * <p/> + * Note that this adapter is not designed for long lists. It + * is more for screens that should behave like a list. This + * is particularly useful if you combine this with other + * adapters (e.g., SectionedAdapter) that might have an + * arbitrary number of rows, so it all appears seamless. + */ +public class SackOfViewsAdapter extends BaseAdapter { + private List<View> views = null; + + /** + * Constructor creating an empty list of views, but with + * a specified count. Subclasses must override newView(). + */ + public SackOfViewsAdapter(int count) { + super(); + + views = new ArrayList<View>(count); + + for (int i = 0; i < count; i++) { + views.add(null); + } + } + + /** + * Constructor wrapping a supplied list of views. + * Subclasses must override newView() if any of the elements + * in the list are null. + */ + public SackOfViewsAdapter(List<View> views) { + for (View view : views) { + view.setLayoutParams(new ListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + } + this.views = views; + } + + /** + * Get the data item associated with the specified + * position in the data set. + * + * @param position Position of the item whose data we want + */ + @Override + public Object getItem(int position) { + return (views.get(position)); + } + + /** + * How many items are in the data set represented by this + * Adapter. + */ + @Override + public int getCount() { + return (views.size()); + } + + /** + * Returns the number of types of Views that will be + * created by getView(). + */ + @Override + public int getViewTypeCount() { + return (getCount()); + } + + /** + * Get the type of View that will be created by getView() + * for the specified item. + * + * @param position Position of the item whose data we want + */ + @Override + public int getItemViewType(int position) { + return (position); + } + + /** + * Are all items in this ListAdapter enabled? If yes it + * means all items are selectable and clickable. + */ + @Override + public boolean areAllItemsEnabled() { + return (false); + } + + /** + * Returns true if the item at the specified position is + * not a separator. + * + * @param position Position of the item whose data we want + */ + @Override + public boolean isEnabled(int position) { + return (false); + } + + /** + * Get a View that displays the data at the specified + * position in the data set. + * + * @param position Position of the item whose data we want + * @param convertView View to recycle, if not null + * @param parent ViewGroup containing the returned View + */ + @Override + public View getView(int position, View convertView, + ViewGroup parent) { + View result = views.get(position); + + if (result == null) { + result = newView(position, parent); + views.set(position, result); + } + + return (result); + } + + /** + * Get the row id associated with the specified position + * in the list. + * + * @param position Position of the item whose data we want + */ + @Override + public long getItemId(int position) { + return (position); + } + + /** + * Create a new View to go into the list at the specified + * position. + * + * @param position Position of the item whose data we want + * @param parent ViewGroup containing the returned View + */ + protected View newView(int position, ViewGroup parent) { + throw new RuntimeException("You must override newView()!"); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java new file mode 100644 index 00000000..825fcc44 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java @@ -0,0 +1,109 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2009 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.util; + +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.util.Log; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.service.MusicService; +import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public class ShufflePlayBuffer { + + private static final String TAG = ShufflePlayBuffer.class.getSimpleName(); + private static final int CAPACITY = 50; + private static final int REFILL_THRESHOLD = 40; + + private final ScheduledExecutorService executorService; + private final List<MusicDirectory.Entry> buffer = new ArrayList<MusicDirectory.Entry>(); + private Context context; + private int currentServer; + + public ShufflePlayBuffer(Context context) { + this.context = context; + executorService = Executors.newSingleThreadScheduledExecutor(); + Runnable runnable = new Runnable() { + @Override + public void run() { + refill(); + } + }; + executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS); + } + + public List<MusicDirectory.Entry> get(int size) { + clearBufferIfnecessary(); + + 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 shuffle play buffer. " + buffer.size() + " remaining."); + return result; + } + + public void shutdown() { + executorService.shutdown(); + } + + private void refill() { + + // Check if active server has changed. + clearBufferIfnecessary(); + + if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected(context) && !Util.isOffline(context))) { + return; + } + + try { + MusicService service = MusicServiceFactory.getMusicService(context); + int n = CAPACITY - buffer.size(); + MusicDirectory songs = service.getRandomSongs(n, context, null); + + synchronized (buffer) { + buffer.addAll(songs.getChildren()); + Log.i(TAG, "Refilled shuffle play buffer with " + songs.getChildren().size() + " songs."); + } + } catch (Exception x) { + Log.w(TAG, "Failed to refill shuffle play buffer.", x); + } + } + + private void clearBufferIfnecessary() { + synchronized (buffer) { + if (currentServer != Util.getActiveServer(context)) { + currentServer = Util.getActiveServer(context); + buffer.clear(); + } + } + } + +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java new file mode 100644 index 00000000..7aa85d7c --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java @@ -0,0 +1,67 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see <http://www.gnu.org/licenses/>. + + Copyright 2010 (C) Sindre Mehus + */ +package net.sourceforge.subsonic.androidapp.util; + +import android.app.Activity; + +/** + * @author Sindre Mehus + */ +public abstract class SilentBackgroundTask<T> extends BackgroundTask<T> { + + public SilentBackgroundTask(Activity activity) { + super(activity); + } + + @Override + public void execute() { + Thread thread = new Thread() { + @Override + public void run() { + try { + final T result = doInBackground(); + + getHandler().post(new Runnable() { + @Override + public void run() { + done(result); + } + }); + + } catch (final Throwable t) { + getHandler().post(new Runnable() { + @Override + public void run() { + error(t); + } + }); + } + } + }; + thread.start(); + } + + @Override + public void updateProgress(int messageId) { + } + + @Override + public void updateProgress(String message) { + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java new file mode 100644 index 00000000..9ddf9903 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/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 net.sourceforge.subsonic.androidapp.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/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java new file mode 100644 index 00000000..22902a11 --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/SongView.java @@ -0,0 +1,178 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.content.Context; +import android.os.Handler; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Checkable; +import android.widget.CheckedTextView; +import android.widget.LinearLayout; +import android.widget.TextView; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.service.DownloadService; +import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl; +import net.sourceforge.subsonic.androidapp.service.DownloadFile; + +import java.io.File; +import java.util.WeakHashMap; + +/** + * Used to display songs in a {@code ListView}. + * + * @author Sindre Mehus + */ +public class SongView extends LinearLayout implements Checkable { + + private static final String TAG = SongView.class.getSimpleName(); + private static final WeakHashMap<SongView, ?> INSTANCES = new WeakHashMap<SongView, Object>(); + private static Handler handler; + + private CheckedTextView checkedTextView; + private TextView titleTextView; + private TextView artistTextView; + private TextView durationTextView; + private TextView statusTextView; + private MusicDirectory.Entry song; + + public SongView(Context context) { + super(context); + LayoutInflater.from(context).inflate(R.layout.song_list_item, this, true); + + checkedTextView = (CheckedTextView) findViewById(R.id.song_check); + titleTextView = (TextView) findViewById(R.id.song_title); + artistTextView = (TextView) findViewById(R.id.song_artist); + durationTextView = (TextView) findViewById(R.id.song_duration); + statusTextView = (TextView) findViewById(R.id.song_status); + + INSTANCES.put(this, null); + int instanceCount = INSTANCES.size(); + if (instanceCount > 50) { + Log.w(TAG, instanceCount + " live SongView instances"); + } + startUpdater(); + } + + public void setSong(MusicDirectory.Entry song, boolean checkable) { + this.song = song; + StringBuilder artist = new StringBuilder(40); + + String bitRate = null; + if (song.getBitRate() != null) { + bitRate = String.format(getContext().getString(R.string.song_details_kbps), song.getBitRate()); + } + + String fileFormat = null; + if (song.getTranscodedSuffix() != null && !song.getTranscodedSuffix().equals(song.getSuffix())) { + fileFormat = String.format("%s > %s", song.getSuffix(), song.getTranscodedSuffix()); + } else { + fileFormat = song.getSuffix(); + } + + artist.append(song.getArtist()).append(" (") + .append(String.format(getContext().getString(R.string.song_details_all), bitRate == null ? "" : bitRate, fileFormat)) + .append(")"); + + titleTextView.setText(song.getTitle()); + artistTextView.setText(artist); + durationTextView.setText(Util.formatDuration(song.getDuration())); + checkedTextView.setVisibility(checkable && !song.isVideo() ? View.VISIBLE : View.GONE); + + update(); + } + + private void update() { + DownloadService downloadService = DownloadServiceImpl.getInstance(); + if (downloadService == null) { + return; + } + + DownloadFile downloadFile = downloadService.forSong(song); + File completeFile = downloadFile.getCompleteFile(); + File partialFile = downloadFile.getPartialFile(); + + int leftImage = 0; + int rightImage = 0; + + if (completeFile.exists()) { + leftImage = downloadFile.isSaved() ? R.drawable.saved : R.drawable.downloaded; + } + + if (downloadFile.isDownloading() && !downloadFile.isDownloadCancelled() && partialFile.exists()) { + statusTextView.setText(Util.formatLocalizedBytes(partialFile.length(), getContext())); + rightImage = R.drawable.downloading; + } else { + statusTextView.setText(null); + } + statusTextView.setCompoundDrawablesWithIntrinsicBounds(leftImage, 0, rightImage, 0); + + boolean playing = downloadService.getCurrentPlaying() == downloadFile; + if (playing) { + titleTextView.setCompoundDrawablesWithIntrinsicBounds(R.drawable.stat_notify_playing, 0, 0, 0); + } else { + titleTextView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + } + } + + private static synchronized void startUpdater() { + if (handler != null) { + return; + } + + handler = new Handler(); + Runnable runnable = new Runnable() { + @Override + public void run() { + updateAll(); + handler.postDelayed(this, 1000L); + } + }; + handler.postDelayed(runnable, 1000L); + } + + private static void updateAll() { + try { + for (SongView view : INSTANCES.keySet()) { + if (view.isShown()) { + view.update(); + } + } + } catch (Throwable x) { + Log.w(TAG, "Error when updating song views.", x); + } + } + + @Override + public void setChecked(boolean b) { + checkedTextView.setChecked(b); + } + + @Override + public boolean isChecked() { + return checkedTextView.isChecked(); + } + + @Override + public void toggle() { + checkedTextView.toggle(); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java new file mode 100644 index 00000000..033a51ad --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java @@ -0,0 +1,67 @@ +package net.sourceforge.subsonic.androidapp.util; + +import net.sourceforge.subsonic.androidapp.activity.SubsonicTabActivity; + +/** + * @author Sindre Mehus + * @version $Id$ + */ +public abstract class TabActivityBackgroundTask<T> extends BackgroundTask<T> { + + private final SubsonicTabActivity tabActivity; + + public TabActivityBackgroundTask(SubsonicTabActivity activity) { + super(activity); + tabActivity = activity; + } + + @Override + public void execute() { + tabActivity.setProgressVisible(true); + + new Thread() { + @Override + public void run() { + try { + final T result = doInBackground(); + if (isCancelled()) { + return; + } + + getHandler().post(new Runnable() { + @Override + public void run() { + tabActivity.setProgressVisible(false); + done(result); + } + }); + } catch (final Throwable t) { + if (isCancelled()) { + return; + } + getHandler().post(new Runnable() { + @Override + public void run() { + tabActivity.setProgressVisible(false); + error(t); + } + }); + } + } + }.start(); + } + + private boolean isCancelled() { + return tabActivity.isDestroyed(); + } + + @Override + public void updateProgress(final String message) { + getHandler().post(new Runnable() { + @Override + public void run() { + tabActivity.updateProgress(message); + } + }); + } +} diff --git a/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java new file mode 100644 index 00000000..5df5901e --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/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 net.sourceforge.subsonic.androidapp.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/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java new file mode 100644 index 00000000..9a8c692d --- /dev/null +++ b/subsonic-android/src/net/sourceforge/subsonic/androidapp/util/Util.java @@ -0,0 +1,829 @@ +/* + 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 net.sourceforge.subsonic.androidapp.util; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Environment; +import android.os.Handler; +import android.util.Log; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.RemoteViews; +import android.widget.TextView; +import android.widget.Toast; +import net.sourceforge.subsonic.androidapp.R; +import net.sourceforge.subsonic.androidapp.activity.DownloadActivity; +import net.sourceforge.subsonic.androidapp.domain.MusicDirectory; +import net.sourceforge.subsonic.androidapp.domain.PlayerState; +import net.sourceforge.subsonic.androidapp.domain.RepeatMode; +import net.sourceforge.subsonic.androidapp.domain.Version; +import net.sourceforge.subsonic.androidapp.provider.SubsonicAppWidgetProvider; +import net.sourceforge.subsonic.androidapp.receiver.MediaButtonIntentReceiver; +import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl; +import org.apache.http.HttpEntity; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +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.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @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; + + public static final String EVENT_META_CHANGED = "net.sourceforge.subsonic.androidapp.EVENT_META_CHANGED"; + public static final String EVENT_PLAYSTATE_CHANGED = "net.sourceforge.subsonic.androidapp.EVENT_PLAYSTATE_CHANGED"; + + private static final Map<Integer, Version> SERVER_REST_VERSIONS = new ConcurrentHashMap<Integer, Version>(); + + // 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 final static Pair<Integer, Integer> NOTIFICATION_TEXT_COLORS = new Pair<Integer, Integer>(); + private static Toast toast; + + private Util() { + } + + public static boolean isOffline(Context context) { + return getActiveServer(context) == 0; + } + + 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) { + if (isOffline(context)) { + return false; + } + SharedPreferences prefs = getPreferences(context); + return prefs.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false); + } + + 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.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + } + + public static String getServerName(Context context, int instance) { + if (instance == 0) { + return context.getResources().getString(R.string.main_offline); + } + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_SERVER_NAME + instance, null); + } + + public static void setServerRestVersion(Context context, Version version) { + SERVER_REST_VERSIONS.put(getActiveServer(context), version); + } + + public static Version getServerRestVersion(Context context) { + return SERVER_REST_VERSIONS.get(getActiveServer(context)); + } + + 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) { + SharedPreferences prefs = getPreferences(context); + int instance = getActiveServer(context); + return prefs.getString(Constants.PREFERENCES_KEY_MUSIC_FOLDER_ID + instance, null); + } + + public static String getTheme(Context context) { + SharedPreferences prefs = getPreferences(context); + return prefs.getString(Constants.PREFERENCES_KEY_THEME, null); + } + + 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 getPreloadCount(Context context) { + SharedPreferences prefs = getPreferences(context); + int preloadCount = Integer.parseInt(prefs.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-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) { + StringBuilder builder = new StringBuilder(); + + SharedPreferences prefs = getPreferences(context); + + int instance = prefs.getInt(Constants.PREFERENCES_KEY_SERVER_INSTANCE, 1); + String serverUrl = prefs.getString(Constants.PREFERENCES_KEY_SERVER_URL + instance, null); + 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 SharedPreferences getPreferences(Context context) { + return context.getSharedPreferences(Constants.PREFERENCES_FILE_NAME, 0); + } + + 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); + } + + /** + * 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 atomicCopy(File from, File to) throws IOException { + FileInputStream in = null; + FileOutputStream out = null; + File tmp = null; + try { + tmp = new File(to.getPath() + ".tmp"); + in = new FileInputStream(from); + out = new FileOutputStream(tmp); + in.getChannel().transferTo(0, from.length(), out.getChannel()); + out.close(); + if (!tmp.renameTo(to)) { + throw new IOException("Failed to rename " + tmp + " to " + to); + } + Log.i(TAG, "Copied " + from + " to " + to); + } catch (IOException x) { + close(out); + delete(to); + throw x; + } finally { + close(in); + close(out); + delete(tmp); + } + } + + 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(); + } + + /** + * 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 minutes = seconds / 60; + int secs = seconds % 60; + + StringBuilder builder = new StringBuilder(6); + builder.append(minutes).append(":"); + if (secs < 10) { + builder.append("0"); + } + builder.append(secs); + return builder.toString(); + } + + 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 isNetworkConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = manager.getActiveNetworkInfo(); + boolean connected = networkInfo != null && networkInfo.isConnected(); + + boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI; + boolean wifiRequired = isWifiRequiredForDownload(context); + + return connected && (!wifiRequired || wifiConnected); + } + + 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) { + showDialog(context, android.R.drawable.ic_dialog_info, titleId, messageId); + } + + private static void showDialog(Context context, int icon, int titleId, int messageId) { + new AlertDialog.Builder(context) + .setIcon(icon) + .setTitle(titleId) + .setMessage(messageId) + .setPositiveButton(R.string.common_ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int i) { + dialog.dismiss(); + } + }) + .show(); + } + + public static void showPlayingNotification(final Context context, final DownloadServiceImpl downloadService, Handler handler, MusicDirectory.Entry song) { + + // Use the same text for the ticker and the expanded notification + String title = song.getTitle(); + String text = song.getArtist(); + + // Set the icon, scrolling text and timestamp + final Notification notification = new Notification(R.drawable.stat_notify_playing, title, System.currentTimeMillis()); + notification.flags |= Notification.FLAG_NO_CLEAR | Notification.FLAG_ONGOING_EVENT; + + RemoteViews contentView = new RemoteViews(context.getPackageName(), R.layout.notification); + + // Set the album art. + try { + int size = context.getResources().getDrawable(R.drawable.unknown_album).getIntrinsicHeight(); + Bitmap bitmap = FileUtil.getAlbumArtBitmap(context, song, size); + if (bitmap == null) { + // set default album art + contentView.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } else { + contentView.setImageViewBitmap(R.id.notification_image, bitmap); + } + } catch (Exception x) { + Log.w(TAG, "Failed to get notification cover art", x); + contentView.setImageViewResource(R.id.notification_image, R.drawable.unknown_album); + } + + // set the text for the notifications + contentView.setTextViewText(R.id.notification_title, title); + contentView.setTextViewText(R.id.notification_artist, text); + + Pair<Integer, Integer> colors = getNotificationTextColors(context); + if (colors.getFirst() != null) { + contentView.setTextColor(R.id.notification_title, colors.getFirst()); + } + if (colors.getSecond() != null) { + contentView.setTextColor(R.id.notification_artist, colors.getSecond()); + } + + notification.contentView = contentView; + + Intent notificationIntent = new Intent(context, DownloadActivity.class); + notification.contentIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0); + + // Send the notification and put the service in the foreground. + handler.post(new Runnable() { + @Override + public void run() { + startForeground(downloadService, Constants.NOTIFICATION_ID_PLAYING, notification); + } + }); + + // Update widget + SubsonicAppWidgetProvider.getInstance().notifyChange(context, downloadService, true); + } + + public static void hidePlayingNotification(final Context context, final DownloadServiceImpl downloadService, Handler handler) { + + // Remove notification and remove the service from the foreground + handler.post(new Runnable() { + @Override + public void run() { + stopForeground(downloadService, true); + } + }); + + // Update widget + SubsonicAppWidgetProvider.getInstance().notifyChange(context, downloadService, false); + } + + 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 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. + } + } + + private static void startForeground(Service service, int notificationId, Notification notification) { + // Service.startForeground() was introduced in Android 2.0. + // Use reflection to maintain compatibility with 1.5. + try { + Method method = Service.class.getMethod("startForeground", int.class, Notification.class); + method.invoke(service, notificationId, notification); + Log.i(TAG, "Successfully invoked Service.startForeground()"); + } catch (Throwable x) { + NotificationManager notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(Constants.NOTIFICATION_ID_PLAYING, notification); + Log.i(TAG, "Service.startForeground() not available. Using work-around."); + } + } + + private static void stopForeground(Service service, boolean removeNotification) { + // Service.stopForeground() was introduced in Android 2.0. + // Use reflection to maintain compatibility with 1.5. + try { + Method method = Service.class.getMethod("stopForeground", boolean.class); + method.invoke(service, removeNotification); + Log.i(TAG, "Successfully invoked Service.stopForeground()"); + } catch (Throwable x) { + NotificationManager notificationManager = (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(Constants.NOTIFICATION_ID_PLAYING); + Log.i(TAG, "Service.stopForeground() not available. Using work-around."); + } + } + + /** + * <p>Broadcasts the given song info as the new song being played.</p> + */ + public static void broadcastNewTrackInfo(Context context, MusicDirectory.Entry song) { + Intent intent = new Intent(EVENT_META_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()); + } else { + intent.putExtra("title", ""); + intent.putExtra("artist", ""); + intent.putExtra("album", ""); + intent.putExtra("coverart", ""); + } + + context.sendBroadcast(intent); + } + + /** + * <p>Broadcasts the given player state as the one being set.</p> + */ + public static void broadcastPlaybackStatusChange(Context context, PlayerState state) { + Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED); + + switch (state) { + case STARTED: + intent.putExtra("state", "play"); + break; + case STOPPED: + intent.putExtra("state", "stop"); + break; + case PAUSED: + intent.putExtra("state", "pause"); + break; + case COMPLETED: + intent.putExtra("state", "complete"); + break; + default: + return; // No need to broadcast. + } + + context.sendBroadcast(intent); + } + + /** + * Resolves the default text color for notifications. + * + * Based on http://stackoverflow.com/questions/4867338/custom-notification-layouts-and-text-colors/7320604#7320604 + */ + private static Pair<Integer, Integer> getNotificationTextColors(Context context) { + if (NOTIFICATION_TEXT_COLORS.getFirst() == null && NOTIFICATION_TEXT_COLORS.getSecond() == null) { + try { + Notification notification = new Notification(); + String title = "title"; + String content = "content"; + notification.setLatestEventInfo(context, title, content, null); + LinearLayout group = new LinearLayout(context); + ViewGroup event = (ViewGroup) notification.contentView.apply(context, group); + findNotificationTextColors(event, title, content); + group.removeAllViews(); + } catch (Exception x) { + Log.w(TAG, "Failed to resolve notification text colors.", x); + } + } + return NOTIFICATION_TEXT_COLORS; + } + + private static void findNotificationTextColors(ViewGroup group, String title, String content) { + for (int i = 0; i < group.getChildCount(); i++) { + if (group.getChildAt(i) instanceof TextView) { + TextView textView = (TextView) group.getChildAt(i); + String text = textView.getText().toString(); + if (title.equals(text)) { + NOTIFICATION_TEXT_COLORS.setFirst(textView.getTextColors().getDefaultColor()); + } + else if (content.equals(text)) { + NOTIFICATION_TEXT_COLORS.setSecond(textView.getTextColors().getDefaultColor()); + } + } + else if (group.getChildAt(i) instanceof ViewGroup) + findNotificationTextColors((ViewGroup) group.getChildAt(i), title, content); + } + } +} |