commit c0b807de17b21903158a2fec3e43e305e3b81c50
Author: haxzamatic
Date: Sun Feb 26 15:25:13 2012 -0500
Sindre Mehus
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 00000000..2566593c
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ant.properties b/ant.properties
new file mode 100644
index 00000000..fb0849b1
--- /dev/null
+++ b/ant.properties
@@ -0,0 +1,20 @@
+# This file is used to override default values used by the Ant build system.
+#
+# This file must be checked in Version Control Systems, as it is
+# integral to the build system of your project.
+
+# This file is only used by the Ant script.
+
+# You can use this to override default values such as
+# 'source.dir' for the location of your java source folder and
+# 'out.dir' for the location of your output folder.
+
+# You can also use it define how the release builds are signed by declaring
+# the following properties:
+# 'key.store' for the location of your keystore and
+# 'key.alias' for the name of the key to use.
+# The password will be asked during the build when you use the 'release' target.
+
+key.store=subsonic.keystore
+key.alias=subsonic
+
diff --git a/assets/fonts/Storopia.ttf b/assets/fonts/Storopia.ttf
new file mode 100644
index 00000000..cbdc4c1f
Binary files /dev/null and b/assets/fonts/Storopia.ttf differ
diff --git a/assets/html/en/index.html b/assets/html/en/index.html
new file mode 100644
index 00000000..6462ef1f
--- /dev/null
+++ b/assets/html/en/index.html
@@ -0,0 +1,98 @@
+
+
+ Subsonic Help
+
+
+
+
+
+
+
Welcome to Subsonic
+
+
+ With Subsonic you can easily stream or download music from your home computer to your Android phone
+ (and do lots of other cool stuff too).
+
+
+
+ To install the Subsonic server software on your computer, please visit subsonic.org.
+ It's available for Windows, Mac, Linux and Unix.
+
+
+
+ By default, this program is configured to use the Subsonic demo server. Once you've set up your own
+ server, please go to Settings and change the configuration so that it connects to your own computer.
+
+
+
+ You can use this program freely for 30 days. After that you will have to make a donation to the Subsonic project.
+ As a donor you get the following benefits:
+
+
+
Unlimited streaming and download to any number of iPhone and Android phones.
+
Video streaming.
+
A personal web address for your Subsonic server (yourname.subsonic.org).
+
No ads in the Subsonic web interface.
+
Free access to new premium features.
+
+
+
+ The suggested donation amount is €20, but you can give any amount you like.
+
+
+
+ Click one of the buttons to go to PayPal where you can pay by credit card or by using your PayPal account.
+ Once the donation is processed, you will receive a license key by email.
+
+
+
+
+
+
+
+
+
+
+
€10
+
+
+
+
+
+
+
+
+
+
€20
+
+
+
+
+
+
+
+
+
+
€25
+
+
+
+
+
+
+
+
+
+
€30
+
+
+
+
+
+
+
+ For more information, please visit subsonic.org
+
+ Avec Subsonic, vous pouvez facilement écouter ou télécharger de la musique à partir de votre ordinateur personnel sur votre appareil Android
+ (et faire plein d'autres trucs cools aussi).
+
+
+
+ Pour installer le serveur Subsonic sur votre ordinateur, veuillez visiter subsonic.org.
+ Celui-ci est disponible pour Windows, Mac, Linux et Unix.
+
+
+
+ Par défaut, cette application est configuré pour utiliser le serveur démo Subsonic.
+ Après avoir configuré votre serveur personnel, veuillez accéder aux Paramètres et modifier la configuration
+ afin de vous connecter à votre propre ordinateur.
+
+
+
+ Vous pouvez utiliser cette application gratuitement pendant 30 jours.
+ Ensuite, vous devrez effectuer un don au projet Subsonic.
+ En tant que donateur, vous obtiendrez les bénéfices suivants:
+
+
+
Écoute et téléchargement illimités vers autant de iPhones et d'appareils Android que désiré.
+
Écoute de vidéos.
+
Une adresse web personnalisée pour votre serveur Subsonic (votrenom.subsonic.org).
+
Aucune publicité dans l'interface web de Subsonic.
+
Accès gratuit aux nouvelles fonctionnalités avancées.
+
+
+
+ Le montant suggéré pour le don est de 20€, mais n'importe quel montant fera l'affaire.
+
+
+
+ Cliquez l'un des boutons suivants pour accéder à PayPal, d'où vous pourrez payer soit par carte de crédit ou en utilisant votre compte PayPal.
+ Une fois le don reçu et traité, vous recevrez votre clé d'activation par courriel.
+
+
+
+
+
+
+
+
+
+
+
10€
+
+
+
+
+
+
+
+
+
+
20€
+
+
+
+
+
+
+
+
+
+
25€
+
+
+
+
+
+
+
+
+
+
30€
+
+
+
+
+
+
+
+ Pour plus d'information, veuillez visiter subsonic.org
+
+
+
+
diff --git a/assets/html/img/paypal.gif b/assets/html/img/paypal.gif
new file mode 100644
index 00000000..d017250a
Binary files /dev/null and b/assets/html/img/paypal.gif differ
diff --git a/assets/html/img/subsonic.png b/assets/html/img/subsonic.png
new file mode 100644
index 00000000..38c521c5
Binary files /dev/null and b/assets/html/img/subsonic.png differ
diff --git a/assets/html/style.css b/assets/html/style.css
new file mode 100644
index 00000000..9c1d55f2
--- /dev/null
+++ b/assets/html/style.css
@@ -0,0 +1,11 @@
+/*
+* Taken from http://yui.yahooapis.com/2.8.0r4/build/fonts/fonts.css
+*/
+body {
+ font: 13px / 1.231 arial, helvetica, clean, sans-serif;
+}
+
+table {
+ font-size:inherit;
+ font:100%;
+}
\ No newline at end of file
diff --git a/build.xml b/build.xml
new file mode 100644
index 00000000..02e7d6ab
--- /dev/null
+++ b/build.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/debug.keystore b/debug.keystore
new file mode 100644
index 00000000..1cb2c3d1
Binary files /dev/null and b/debug.keystore differ
diff --git a/local.properties b/local.properties
new file mode 100644
index 00000000..d80ed913
--- /dev/null
+++ b/local.properties
@@ -0,0 +1,10 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must *NOT* be checked in Version Control Systems,
+# as it contains information specific to your local configuration.
+
+# location of the SDK. This is only used by Ant
+# For customization when using a Version Control System, please read the
+# header note.
+sdk.dir=c:/progs/android-sdk-windows
diff --git a/patches/jukebox-patch.txt b/patches/jukebox-patch.txt
new file mode 100644
index 00000000..e796d88f
--- /dev/null
+++ b/patches/jukebox-patch.txt
@@ -0,0 +1,1234 @@
+Index: AndroidManifest.xml
+===================================================================
+--- AndroidManifest.xml (revision 2441)
++++ AndroidManifest.xml (working copy)
+@@ -114,7 +114,8 @@
+ a:authorities="net.sourceforge.subsonic.androidapp.provider.SearchSuggestionProvider"/>
+
+
++ a:value="net.sourceforge.subsonic.androidapp.activity.QueryReceiverActivity"/>
++
+
+
+
+Index: res/drawable/menu_jukebox.png
+===================================================================
+Cannot display: file marked as a binary type.
+svn:mime-type = application/octet-stream
+
+Property changes on: res/drawable/menu_jukebox.png
+___________________________________________________________________
+Added: svn:mime-type
+ + application/octet-stream
+
+Index: res/drawable-hdpi-v4/menu_jukebox.png
+===================================================================
+Cannot display: file marked as a binary type.
+svn:mime-type = application/octet-stream
+
+Property changes on: res/drawable-hdpi-v4/menu_jukebox.png
+___________________________________________________________________
+Added: svn:mime-type
+ + application/octet-stream
+
+Index: res/layout/button_bar.xml
+===================================================================
+--- res/layout/button_bar.xml (revision 2441)
++++ res/layout/button_bar.xml (working copy)
+@@ -47,6 +47,14 @@
+ a:layout_weight="1"
+ a:layout_width="0dp"
+ a:layout_height="wrap_content"/>
++
++
+
+
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
++
+\ No newline at end of file
+Index: res/menu/jukebox_context.xml
+===================================================================
+--- res/menu/jukebox_context.xml (revision 0)
++++ res/menu/jukebox_context.xml (revision 0)
+@@ -0,0 +1,8 @@
++
++
+\ No newline at end of file
+Index: res/menu/select_album_context.xml
+===================================================================
+--- res/menu/select_album_context.xml (revision 2441)
++++ res/menu/select_album_context.xml (working copy)
+@@ -15,5 +15,6 @@
+ a:id="@+id/album_menu_pin"
+ a:title="@string/common.pin"
+ />
++
+
+
+Index: res/menu/select_artist_context.xml
+===================================================================
+--- res/menu/select_artist_context.xml (revision 2441)
++++ res/menu/select_artist_context.xml (working copy)
+@@ -15,5 +15,6 @@
+ a:id="@+id/artist_menu_pin"
+ a:title="@string/common.pin"
+ />
++
+
+
+Index: res/menu/select_song_context.xml
+===================================================================
+--- res/menu/select_song_context.xml (revision 2441)
++++ res/menu/select_song_context.xml (working copy)
+@@ -15,5 +15,10 @@
+ a:id="@+id/song_menu_play_last"
+ a:title="@string/common.play_last"
+ />
++
++
+
+
+Index: res/values/strings.xml
+===================================================================
+--- res/values/strings.xml (revision 2441)
++++ res/values/strings.xml (working copy)
+@@ -226,6 +226,14 @@
+
+ One day left of trial period
+ %d days left of trial period
+-
++
++ Jukebox
++ Start
++ Stop
++ Next
++ Previous
++ Clear
++ Remove
++ Add to Jukebox
+
+
+Index: src/net/sourceforge/subsonic/androidapp/activity/JukeboxActivity.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/activity/JukeboxActivity.java (revision 0)
++++ src/net/sourceforge/subsonic/androidapp/activity/JukeboxActivity.java (revision 0)
+@@ -0,0 +1,235 @@
++/*
++ This file is part of Subsonic.
++
++ Subsonic is free software: you can redistribute it and/or modify
++ it under the terms of the GNU General Public License as published by
++ the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ Subsonic is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU General Public License for more details.
++
++ You should have received a copy of the GNU General Public License
++ along with Subsonic. If not, see .
++
++ Copyright 2009 (C) Sindre Mehus
++ */
++
++package net.sourceforge.subsonic.androidapp.activity;
++
++import java.util.List;
++
++import net.sourceforge.subsonic.androidapp.R;
++import net.sourceforge.subsonic.androidapp.domain.Jukebox;
++import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
++import net.sourceforge.subsonic.androidapp.domain.MusicDirectory.Entry;
++import net.sourceforge.subsonic.androidapp.service.MusicService;
++import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
++import net.sourceforge.subsonic.androidapp.util.BackgroundTask;
++import net.sourceforge.subsonic.androidapp.util.JukeboxSongView;
++import net.sourceforge.subsonic.androidapp.util.ProgressListener;
++import net.sourceforge.subsonic.androidapp.util.TabActivityBackgroundTask;
++import net.sourceforge.subsonic.androidapp.util.Util;
++import android.os.Bundle;
++import android.view.ContextMenu;
++import android.view.MenuInflater;
++import android.view.MenuItem;
++import android.view.View;
++import android.view.View.OnClickListener;
++import android.view.ViewGroup;
++import android.widget.AdapterView;
++import android.widget.AdapterView.OnItemClickListener;
++import android.widget.ArrayAdapter;
++import android.widget.ImageButton;
++import android.widget.ListView;
++import android.widget.SeekBar;
++import android.widget.SeekBar.OnSeekBarChangeListener;
++
++/**
++ * @author meld0
++ *
++ */
++public class JukeboxActivity extends SubsonicTabActivity implements ProgressListener, OnClickListener, OnSeekBarChangeListener, OnItemClickListener{
++
++ private ImageButton play, stop, next, prev, shuffle;
++ private ListView entryList;
++ private SeekBar gain;
++ private Jukebox jukebox;
++
++ @Override
++ public void onCreate(Bundle savedInstanceState){
++ super.onCreate(savedInstanceState);
++ setContentView(R.layout.jukebox);
++
++ jukebox = new Jukebox();
++
++ play = (ImageButton) findViewById(R.id.jukebox_start);
++ stop = (ImageButton) findViewById(R.id.jukebox_stop);
++ prev = (ImageButton) findViewById(R.id.jukebox_previous);
++ next = (ImageButton) findViewById(R.id.jukebox_next);
++ shuffle = (ImageButton) findViewById(R.id.jukebox_shuffle);
++ entryList = (ListView) findViewById(R.id.jukebox_list);
++
++ gain = (SeekBar) findViewById(R.id.jukebox_seek);
++ gain.setMax(100);
++
++ play.setOnClickListener(this);
++ stop.setOnClickListener(this);
++ next.setOnClickListener(this);
++ prev.setOnClickListener(this);
++ shuffle.setOnClickListener(this);
++ gain.setOnSeekBarChangeListener(this);
++ entryList.setOnItemClickListener(this);
++
++ registerForContextMenu(entryList);
++ load();
++ }
++
++ @Override
++ public void updateProgress(int messageId){
++
++ }
++
++ private void load(){
++ load(Jukebox.GET);
++ }
++
++ private void load(String action){
++ load(action, "");
++ }
++
++ private void load(final String action, final String additional){
++ BackgroundTask task = new TabActivityBackgroundTask(this){
++
++ protected Boolean doInBackground() throws Throwable{
++ boolean update = false;
++ if (!Util.isOffline(JukeboxActivity.this)) {
++ MusicService musicService = MusicServiceFactory.getMusicService(JukeboxActivity.this);
++ jukebox.lastAction = action;
++ jukebox = musicService.getJukebox(jukebox, JukeboxActivity.this, this, additional);
++ update = true;
++ }
++ return update;
++ }
++
++ protected void done(Boolean result){
++ if (jukebox.isSuccess()) {
++ if (jukebox.lastAction.equals(Jukebox.SET_GAIN)) {
++ jukebox.setGain((int) (Double.valueOf(additional.split("=")[1]) * 100));
++ gain.setProgress(jukebox.getGain());
++ } else if (jukebox.lastAction.equals(Jukebox.REMOVE_ITEM)) ((SongListAdapter) entryList.getAdapter()).remove((Entry) entryList.getAdapter().getItem(
++ Integer.valueOf(additional.split("=")[1])));
++ else if (jukebox.lastAction.equals(Jukebox.CLEAR_PLAYLIST)) ((SongListAdapter) entryList.getAdapter()).clear();
++ else if (jukebox.lastAction.equals(Jukebox.SKIP_TO_INDEX)) jukebox.setCurrentIndex(Integer.valueOf(additional.split("=")[1]));
++
++ if (jukebox.isNewPlaylist()) entryList.setAdapter(new SongListAdapter(jukebox.getChildren()));
++ }
++ }
++ };
++ task.execute();
++ }
++
++ @Override
++ public void onCreateContextMenu(ContextMenu menu, View view, ContextMenu.ContextMenuInfo menuInfo){
++ super.onCreateContextMenu(menu, view, menuInfo);
++ MenuInflater inflater = getMenuInflater();
++ inflater.inflate(R.menu.jukebox_context, menu);
++ }
++
++ @Override
++ public boolean onContextItemSelected(MenuItem menuItem){
++ AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuItem.getMenuInfo();
++ switch (menuItem.getItemId()) {
++ case R.id.jukebox_remove:
++ load(Jukebox.REMOVE_ITEM, "index=" + info.position);
++ break;
++ case R.id.jukebox_clear:
++ load(Jukebox.CLEAR_PLAYLIST);
++ break;
++ default:
++ return super.onContextItemSelected(menuItem);
++ }
++ return true;
++ }
++
++ @Override
++ protected void onResume(){
++ super.onResume();
++ load();
++ }
++
++ @Override
++ public void onClick(View arg0){
++ if (arg0.equals(this.play)) {
++ load(Jukebox.START_PLAYBACK);
++ } else if (arg0.equals(this.stop)) {
++ load(Jukebox.STOP_PLAYBACK);
++ } else if (arg0.equals(this.prev)) {
++ loadW();
++ load(Jukebox.SKIP_TO_INDEX, "index=" + (jukebox.getCurrentIndex() - 1)); // TODO // MAYBE // BETTER // ?
++ } else if (arg0.equals(this.next)) {
++ loadW();
++ load(Jukebox.SKIP_TO_INDEX, "index=" + (jukebox.getCurrentIndex() + 1));
++ } else if (arg0.equals(this.shuffle)) {
++ load(Jukebox.SHUFFLE_PLAYLIST);
++ load();
++ }
++ }
++
++ @Override
++ public void onProgressChanged(SeekBar arg0, int arg1, boolean arg2){
++ }
++
++ @Override
++ public void onStartTrackingTouch(SeekBar arg0){
++ }
++
++ @Override
++ public void onStopTrackingTouch(SeekBar arg0){
++ load(Jukebox.SET_GAIN, "gain=" + ((double) arg0.getProgress() / 100));
++ }
++
++ private class SongListAdapter extends ArrayAdapter{
++
++ public SongListAdapter(List entries){
++ super(JukeboxActivity.this, android.R.layout.simple_list_item_1, entries);
++ }
++
++ @Override
++ public View getView(int position, View convertView, ViewGroup parent){
++ JukeboxSongView view;
++ if (convertView != null && convertView instanceof JukeboxSongView) {
++ view = (JukeboxSongView) convertView;
++ } else {
++ view = new JukeboxSongView(JukeboxActivity.this);
++ }
++ MusicDirectory.Entry entry = getItem(position);
++
++ if (position == jukebox.getCurrentIndex()) {
++ view.setSong(entry, false, true);
++ } else view.setSong(entry, false, false);
++ return view;
++ }
++ }
++
++ @Override
++ public void onItemClick(AdapterView> arg0, View arg1, int arg2, long arg3){
++ load(Jukebox.SKIP_TO_INDEX, "index=" + arg3);
++ }
++
++ public void loadW(){
++ if (!Util.isOffline(JukeboxActivity.this)) {
++ MusicService musicService = MusicServiceFactory.getMusicService(JukeboxActivity.this);
++ jukebox.lastAction = Jukebox.GET;
++ try {
++ jukebox = musicService.getJukebox(jukebox, JukeboxActivity.this, this, "");
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++ }
++ }
++
++}
+Index: src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/activity/SelectAlbumActivity.java (working copy)
+@@ -252,6 +252,9 @@
+ case R.id.album_menu_pin:
+ downloadRecursively(entry.getId(), true, true, false);
+ break;
++ case R.id.album_menu_jukebox_add:
++ addToJukebox(entry.getId());
++ break;
+ case R.id.song_menu_play_now:
+ getDownloadService().download(songs, false, true, true);
+ break;
+@@ -261,6 +264,8 @@
+ case R.id.song_menu_play_last:
+ getDownloadService().download(songs, false, false, false);
+ break;
++ case R.id.song_menu_jukebox_add:
++ addToJukebox(entry);
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+Index: src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/activity/SelectArtistActivity.java (working copy)
+@@ -210,6 +210,9 @@
+ case R.id.artist_menu_pin:
+ downloadRecursively(artist.getId(), true, true, false);
+ break;
++ case R.id.artist_menu_jukebox_add:
++ addToJukebox(artist.getId());
++ break;
+ default:
+ return super.onContextItemSelected(menuItem);
+ }
+Index: src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/activity/SubsonicTabActivity.java (working copy)
+@@ -23,6 +23,16 @@
+ import java.util.LinkedList;
+ import java.util.List;
+
++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.MusicService;
++import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
++import net.sourceforge.subsonic.androidapp.util.Constants;
++import net.sourceforge.subsonic.androidapp.util.ImageLoader;
++import net.sourceforge.subsonic.androidapp.util.ModalBackgroundTask;
++import net.sourceforge.subsonic.androidapp.util.Util;
+ import android.app.Activity;
+ import android.content.Context;
+ import android.content.Intent;
+@@ -39,16 +49,6 @@
+ import android.view.View;
+ import android.view.Window;
+ 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.MusicService;
+-import net.sourceforge.subsonic.androidapp.service.MusicServiceFactory;
+-import net.sourceforge.subsonic.androidapp.util.Constants;
+-import net.sourceforge.subsonic.androidapp.util.ImageLoader;
+-import net.sourceforge.subsonic.androidapp.util.ModalBackgroundTask;
+-import net.sourceforge.subsonic.androidapp.util.Util;
+
+ /**
+ * @author Sindre Mehus
+@@ -63,6 +63,7 @@
+ private View musicButton;
+ private View searchButton;
+ private View playlistButton;
++ private View jukeboxButton;
+ private View nowPlayingButton;
+
+ @Override
+@@ -118,6 +119,14 @@
+ Util.startActivityWithoutTransition(SubsonicTabActivity.this, intent);
+ }
+ });
++
++ jukeboxButton = findViewById(R.id.button_bar_jukebox);
++ jukeboxButton.setOnClickListener(new View.OnClickListener() {
++ @Override
++ public void onClick(View view) {
++ Util.startActivityWithoutTransition(SubsonicTabActivity.this, JukeboxActivity.class);
++ }
++ });
+
+ nowPlayingButton = findViewById(R.id.button_bar_now_playing);
+ nowPlayingButton.setOnClickListener(new View.OnClickListener() {
+@@ -225,6 +234,22 @@
+ int visibility = Util.isOffline(this) ? View.GONE : View.VISIBLE;
+ searchButton.setVisibility(visibility);
+ playlistButton.setVisibility(visibility);
++ jukeboxButton.setVisibility(visibility);
++ /*
++ * The following block would check for valid Permissions
++ * but it is called too often -> network traffic/ problems ?
++ *
++ MusicService musicService = MusicServiceFactory.getMusicService(this);
++ try {
++ User user = musicService.getUser(this, null);
++ if(!user.isJukeboxControl()){
++ jukeboxButton.setVisibility(View.GONE);
++ }
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++ */
+ }
+
+ public void setProgressVisible(boolean visible) {
+@@ -363,5 +388,67 @@
+ }
+ }
+ }
++
++ protected void addToJukebox(String id) {
++
++ List entries = new LinkedList();
++ MusicService musicService = MusicServiceFactory.getMusicService(this);
++ MusicDirectory root;
++
++ try {
++ root = musicService.getMusicDirectory(id, false, SubsonicTabActivity.this, null);
++ collectEntries(root, entries);
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++
++
++ StringBuilder build = new StringBuilder();
++ build.append("action=add");
++ for(MusicDirectory.Entry entry2: entries){
++ build.append("&id=");
++ build.append(entry2.getId());
++ }
++ try {
++ musicService.getJukebox(null, this, null, build.toString());
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++
++ }
++
++ protected void addToJukebox(MusicDirectory.Entry entry) {
++ try {
++ MusicService musicService = MusicServiceFactory.getMusicService(this);
++ musicService.getJukebox(null, this, null, "action=add&id="+entry.getId());
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++ }
++
++ private List collectEntries(MusicDirectory parent, List songs){
++ for (MusicDirectory.Entry song : parent.getChildren(false, true)) {
++ if (!song.isVideo()) {
++ songs.add(song);
++ }
++ }
++ for (MusicDirectory.Entry dir : parent.getChildren(true, false)) {
++ MusicService musicService = MusicServiceFactory.getMusicService(SubsonicTabActivity.this);
++ try {
++ collectEntries(musicService.getMusicDirectory(dir.getId(), false, SubsonicTabActivity.this, null), songs);
++ } catch (Exception e) {
++ // TODO Auto-generated catch block
++ e.printStackTrace();
++ }
++ }
++
++ return songs;
++ }
++
++
++
+ }
+
+Index: src/net/sourceforge/subsonic/androidapp/domain/Jukebox.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/domain/Jukebox.java (revision 0)
++++ src/net/sourceforge/subsonic/androidapp/domain/Jukebox.java (revision 0)
+@@ -0,0 +1,113 @@
++/*
++ This file is part of Subsonic.
++
++ Subsonic is free software: you can redistribute it and/or modify
++ it under the terms of the GNU General Public License as published by
++ the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ Subsonic is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU General Public License for more details.
++
++ You should have received a copy of the GNU General Public License
++ along with Subsonic. If not, see .
++
++ Copyright 2009 (C) Sindre Mehus
++ */
++
++package net.sourceforge.subsonic.androidapp.domain;
++
++import java.io.Serializable;
++import java.util.List;
++
++/**
++ * @author meld0
++ *
++ */
++public class Jukebox extends MusicDirectory implements Serializable{
++
++ public String lastAction;
++
++ //get, start, stop, skip, add, clear, remove, shuffle, setGain
++ public static final String GET = "get";
++ public static final String START_PLAYBACK = "start";
++ public static final String STOP_PLAYBACK = "stop";
++ public static final String SKIP_TO_INDEX = "skip";
++ public static final String ADD_TO_PLAYLIST = "add";
++ public static final String CLEAR_PLAYLIST = "clear";
++ public static final String REMOVE_ITEM = "remove";
++ public static final String SHUFFLE_PLAYLIST = "shuffle";
++ public static final String SET_GAIN = "setGain";
++
++ public Jukebox() {
++ super();
++ }
++
++ public boolean isPlaying() {
++ return playing;
++ }
++
++ public void setPlaying(boolean playing) {
++ this.playing = playing;
++ }
++
++ public int getGain() {
++ return gain;
++ }
++
++ public void setGain(int gain) {
++ this.gain = gain;
++ }
++
++ public int getCurrentIndex() {
++ return currentIndex;
++ }
++
++ public void setCurrentIndex(int currentIndex) {
++ this.currentIndex = currentIndex;
++ }
++
++ public boolean isAvailable() {
++ return available;
++ }
++
++ public boolean isSuccess() {
++ return success;
++ }
++
++ public void setAvailable(boolean available) {
++ this.available = available;
++ }
++
++ public void setSuccess(boolean success) {
++ this.success = success;
++ }
++
++ public boolean isNewPlaylist() {
++ return newPlaylist;
++ }
++
++ public void setNewPlaylist(boolean newPlaylist) {
++ this.newPlaylist = newPlaylist;
++ }
++
++ private boolean playing = false;
++ private int gain = 0;
++ private int currentIndex = 0;
++ private boolean available = false;
++ private boolean success = false;
++ private boolean newPlaylist = false;
++
++ public void addChildren(List children){
++ super.addChildren(children);
++ }
++
++ public void reset(){
++ this.available = false;
++ this.success = false;
++ this.newPlaylist = false;
++ }
++
++}
+Index: src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/domain/MusicDirectory.java (working copy)
+@@ -59,6 +59,13 @@
+ }
+ return result;
+ }
++
++ protected void addChildren(List children){
++ if(!children.isEmpty()) {
++ this.children.clear();
++ this.children.addAll(children);
++ }
++ }
+
+ public static class Entry implements Serializable {
+ private String id;
+Index: src/net/sourceforge/subsonic/androidapp/domain/User.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/domain/User.java (revision 0)
++++ src/net/sourceforge/subsonic/androidapp/domain/User.java (revision 0)
+@@ -0,0 +1,43 @@
++/*
++ This file is part of Subsonic.
++
++ Subsonic is free software: you can redistribute it and/or modify
++ it under the terms of the GNU General Public License as published by
++ the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ Subsonic is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU General Public License for more details.
++
++ You should have received a copy of the GNU General Public License
++ along with Subsonic. If not, see .
++
++ Copyright 2009 (C) Sindre Mehus
++ */
++
++package net.sourceforge.subsonic.androidapp.domain;
++
++import java.io.Serializable;
++
++/**
++ * @author meld0
++ *
++ */
++public class User implements Serializable{
++
++ //TODO implement missing user attributes
++
++ private boolean jukeboxControl;
++
++ public boolean isJukeboxControl() {
++ return jukeboxControl;
++ }
++
++ public void setJukeboxControl(boolean jukeboxControl) {
++ this.jukeboxControl = jukeboxControl;
++ }
++
++
++}
+Index: src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/service/CachedMusicService.java (working copy)
+@@ -21,9 +21,11 @@
+ import android.content.Context;
+ import android.graphics.Bitmap;
+ import net.sourceforge.subsonic.androidapp.domain.Indexes;
++import net.sourceforge.subsonic.androidapp.domain.Jukebox;
+ import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+ import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+ import net.sourceforge.subsonic.androidapp.domain.Playlist;
++import net.sourceforge.subsonic.androidapp.domain.User;
+ import net.sourceforge.subsonic.androidapp.domain.Version;
+ import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+ import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+@@ -199,4 +201,17 @@
+ restUrl = newUrl;
+ }
+ }
++
++ @Override
++ public Jukebox getJukebox(Jukebox jukebox, Context context,
++ ProgressListener progressListener, String action) throws Exception {
++ return musicService.getJukebox(jukebox, context, progressListener, action);
++ }
++
++ @Override
++ public User getUser(Context context, ProgressListener progressListener)
++ throws Exception {
++ return musicService.getUser(context, progressListener);
++ }
++
+ }
+Index: src/net/sourceforge/subsonic/androidapp/service/MusicService.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/service/MusicService.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/service/MusicService.java (working copy)
+@@ -21,9 +21,11 @@
+ import android.content.Context;
+ import android.graphics.Bitmap;
+ import net.sourceforge.subsonic.androidapp.domain.Indexes;
++import net.sourceforge.subsonic.androidapp.domain.Jukebox;
+ import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+ import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+ import net.sourceforge.subsonic.androidapp.domain.Playlist;
++import net.sourceforge.subsonic.androidapp.domain.User;
+ import net.sourceforge.subsonic.androidapp.domain.Version;
+ import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+ import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+@@ -74,4 +76,8 @@
+ Version getLatestVersion(Context context, ProgressListener progressListener) throws Exception;
+
+ String getVideoUrl(Context context, String id);
++
++ Jukebox getJukebox(Jukebox jukebox, Context context, ProgressListener progressListener , String action) throws Exception;
++
++ User getUser(Context context, ProgressListener progressListener) throws Exception;
+ }
+\ No newline at end of file
+Index: src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java
+===================================================================
+--- src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java (revision 2441)
++++ src/net/sourceforge/subsonic/androidapp/service/RESTMusicService.java (working copy)
+@@ -28,6 +28,7 @@
+ import android.util.Log;
+ import net.sourceforge.subsonic.androidapp.R;
+ import net.sourceforge.subsonic.androidapp.domain.Indexes;
++import net.sourceforge.subsonic.androidapp.domain.Jukebox;
+ import net.sourceforge.subsonic.androidapp.domain.Lyrics;
+ import net.sourceforge.subsonic.androidapp.domain.MusicDirectory;
+ import net.sourceforge.subsonic.androidapp.domain.MusicFolder;
+@@ -35,10 +36,12 @@
+ import net.sourceforge.subsonic.androidapp.domain.SearchCritera;
+ import net.sourceforge.subsonic.androidapp.domain.SearchResult;
+ import net.sourceforge.subsonic.androidapp.domain.ServerInfo;
++import net.sourceforge.subsonic.androidapp.domain.User;
+ import net.sourceforge.subsonic.androidapp.domain.Version;
+ import net.sourceforge.subsonic.androidapp.service.parser.AlbumListParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.ErrorParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.IndexesParser;
++import net.sourceforge.subsonic.androidapp.service.parser.JukeboxParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.LicenseParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.LyricsParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.MusicDirectoryParser;
+@@ -48,6 +51,7 @@
+ import net.sourceforge.subsonic.androidapp.service.parser.RandomSongsParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.SearchResult2Parser;
+ import net.sourceforge.subsonic.androidapp.service.parser.SearchResultParser;
++import net.sourceforge.subsonic.androidapp.service.parser.UserParser;
+ import net.sourceforge.subsonic.androidapp.service.parser.VersionParser;
+ import net.sourceforge.subsonic.androidapp.service.ssl.SSLSocketFactory;
+ import net.sourceforge.subsonic.androidapp.service.ssl.TrustSelfSignedStrategy;
+@@ -195,6 +199,53 @@
+ Util.close(reader);
+ }
+ }
++
++ public Jukebox getJukebox(Jukebox jukebox, Context context, ProgressListener progressListener, String action) throws Exception {
++
++ List parameterNames = new ArrayList();
++ List
+ *
+ *
+ *
+ * Issue a certificate signing request (CSR)
+ *
keytool -certreq -alias "my client key" -file mycertreq.csr -keystore my.keystore
+ *
+ *
+ *
+ *
+ * Send the certificate request to the trusted Certificate Authority for signature.
+ * One may choose to act as her own CA and sign the certificate request using a PKI
+ * tool, such as OpenSSL.
+ *
+ *
+ *
+ *
+ * Import the trusted CA root certificate
+ *
keytool -import -alias "my trusted ca" -file caroot.crt -keystore my.keystore
+ *
+ *
+ *
+ *
+ * Import the PKCS#7 file containg the complete certificate chain
+ *
keytool -import -alias "my client key" -file mycert.p7 -keystore my.keystore
+ *
+ *
+ *
+ *
+ * Verify the content the resultant keystore file
+ *
keytool -list -v -keystore my.keystore
+ *
+ *
+ *
+ *
+ * @since 4.0
+ */
+public class SSLSocketFactory implements LayeredSocketFactory {
+
+ public static final String TLS = "TLS";
+ public static final String SSL = "SSL";
+ public static final String SSLV2 = "SSLv2";
+
+ public static final X509HostnameVerifier ALLOW_ALL_HOSTNAME_VERIFIER
+ = new AllowAllHostnameVerifier();
+
+ public static final X509HostnameVerifier BROWSER_COMPATIBLE_HOSTNAME_VERIFIER
+ = new BrowserCompatHostnameVerifier();
+
+ public static final X509HostnameVerifier STRICT_HOSTNAME_VERIFIER
+ = new StrictHostnameVerifier();
+
+ /**
+ * The default factory using the default JVM settings for secure connections.
+ */
+ private static final SSLSocketFactory DEFAULT_FACTORY = new SSLSocketFactory();
+
+ /**
+ * Gets the default factory, which uses the default JVM settings for secure
+ * connections.
+ *
+ * @return the default factory
+ */
+ public static SSLSocketFactory getSocketFactory() {
+ return DEFAULT_FACTORY;
+ }
+
+ private final javax.net.ssl.SSLSocketFactory socketfactory;
+ private final HostNameResolver nameResolver;
+ // TODO: make final
+ private volatile X509HostnameVerifier hostnameVerifier;
+
+ private static SSLContext createSSLContext(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final TrustStrategy trustStrategy)
+ throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException, KeyManagementException {
+ if (algorithm == null) {
+ algorithm = TLS;
+ }
+ KeyManagerFactory kmfactory = KeyManagerFactory.getInstance(
+ KeyManagerFactory.getDefaultAlgorithm());
+ kmfactory.init(keystore, keystorePassword != null ? keystorePassword.toCharArray(): null);
+ KeyManager[] keymanagers = kmfactory.getKeyManagers();
+ TrustManagerFactory tmfactory = TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm());
+ tmfactory.init(keystore);
+ TrustManager[] trustmanagers = tmfactory.getTrustManagers();
+ if (trustmanagers != null && trustStrategy != null) {
+ for (int i = 0; i < trustmanagers.length; i++) {
+ TrustManager tm = trustmanagers[i];
+ if (tm instanceof X509TrustManager) {
+ trustmanagers[i] = new TrustManagerDecorator(
+ (X509TrustManager) tm, trustStrategy);
+ }
+ }
+ }
+
+ SSLContext sslcontext = SSLContext.getInstance(algorithm);
+ sslcontext.init(keymanagers, trustmanagers, random);
+ return sslcontext;
+ }
+
+ /**
+ * @deprecated Use {@link #SSLSocketFactory(String, KeyStore, String, KeyStore, SecureRandom, X509HostnameVerifier)}
+ */
+ @Deprecated
+ public SSLSocketFactory(
+ final String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final HostNameResolver nameResolver)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, null),
+ nameResolver);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, null),
+ hostnameVerifier);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ String algorithm,
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore,
+ final SecureRandom random,
+ final TrustStrategy trustStrategy,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(createSSLContext(
+ algorithm, keystore, keystorePassword, truststore, random, trustStrategy),
+ hostnameVerifier);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore keystore,
+ final String keystorePassword,
+ final KeyStore truststore)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, keystore, keystorePassword, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore keystore,
+ final String keystorePassword)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException{
+ this(TLS, keystore, keystorePassword, null, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(
+ final KeyStore truststore)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, truststore, null, null, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final TrustStrategy trustStrategy,
+ final X509HostnameVerifier hostnameVerifier)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, null, null, trustStrategy, hostnameVerifier);
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final TrustStrategy trustStrategy)
+ throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, UnrecoverableKeyException {
+ this(TLS, null, null, null, null, trustStrategy, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ public SSLSocketFactory(final SSLContext sslContext) {
+ this(sslContext, BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
+ }
+
+ /**
+ * @deprecated Use {@link #SSLSocketFactory(SSLContext)}
+ */
+ @Deprecated
+ public SSLSocketFactory(
+ final SSLContext sslContext, final HostNameResolver nameResolver) {
+ super();
+ this.socketfactory = sslContext.getSocketFactory();
+ this.hostnameVerifier = BROWSER_COMPATIBLE_HOSTNAME_VERIFIER;
+ this.nameResolver = nameResolver;
+ }
+
+ /**
+ * @since 4.1
+ */
+ public SSLSocketFactory(
+ final SSLContext sslContext, final X509HostnameVerifier hostnameVerifier) {
+ super();
+ this.socketfactory = sslContext.getSocketFactory();
+ this.hostnameVerifier = hostnameVerifier;
+ this.nameResolver = null;
+ }
+
+ private SSLSocketFactory() {
+ super();
+ this.socketfactory = HttpsURLConnection.getDefaultSSLSocketFactory();
+ this.hostnameVerifier = null;
+ this.nameResolver = null;
+ }
+
+ /**
+ * @param params Optional parameters. Parameters passed to this method will have no effect.
+ * This method will create a unconnected instance of {@link Socket} class
+ * using {@link javax.net.ssl.SSLSocketFactory#createSocket()} method.
+ * @since 4.1
+ */
+ @SuppressWarnings("cast")
+ public Socket createSocket(final HttpParams params) throws IOException {
+ // the cast makes sure that the factory is working as expected
+ return (SSLSocket) this.socketfactory.createSocket();
+ }
+
+ @SuppressWarnings("cast")
+ public Socket createSocket() throws IOException {
+ // the cast makes sure that the factory is working as expected
+ return (SSLSocket) this.socketfactory.createSocket();
+ }
+
+ /**
+ * @since 4.1
+ */
+ public Socket connectSocket(
+ final Socket sock,
+ final InetSocketAddress remoteAddress,
+ final InetSocketAddress localAddress,
+ final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException {
+ if (remoteAddress == null) {
+ throw new IllegalArgumentException("Remote address may not be null");
+ }
+ if (params == null) {
+ throw new IllegalArgumentException("HTTP parameters may not be null");
+ }
+ SSLSocket sslsock = (SSLSocket) (sock != null ? sock : createSocket());
+ if (localAddress != null) {
+// sslsock.setReuseAddress(HttpConnectionParams.getSoReuseaddr(params));
+ sslsock.bind(localAddress);
+ }
+
+ int connTimeout = HttpConnectionParams.getConnectionTimeout(params);
+ int soTimeout = HttpConnectionParams.getSoTimeout(params);
+
+ try {
+ sslsock.connect(remoteAddress, connTimeout);
+ } catch (SocketTimeoutException ex) {
+ throw new ConnectTimeoutException("Connect to " + remoteAddress.getHostName() + "/"
+ + remoteAddress.getAddress() + " timed out");
+ }
+ sslsock.setSoTimeout(soTimeout);
+ if (this.hostnameVerifier != null) {
+ try {
+ this.hostnameVerifier.verify(remoteAddress.getHostName(), sslsock);
+ // verifyHostName() didn't blowup - good!
+ } catch (IOException iox) {
+ // close the socket before re-throwing the exception
+ try { sslsock.close(); } catch (Exception x) { /*ignore*/ }
+ throw iox;
+ }
+ }
+ return sslsock;
+ }
+
+
+ /**
+ * Checks whether a socket connection is secure.
+ * This factory creates TLS/SSL socket connections
+ * which, by default, are considered secure.
+ *
+ * Derived classes may override this method to perform
+ * runtime checks, for example based on the cypher suite.
+ *
+ * @param sock the connected socket
+ *
+ * @return true
+ *
+ * @throws IllegalArgumentException if the argument is invalid
+ */
+ public boolean isSecure(final Socket sock) throws IllegalArgumentException {
+ if (sock == null) {
+ throw new IllegalArgumentException("Socket may not be null");
+ }
+ // This instanceof check is in line with createSocket() above.
+ if (!(sock instanceof SSLSocket)) {
+ throw new IllegalArgumentException("Socket not created by this factory");
+ }
+ // This check is performed last since it calls the argument object.
+ if (sock.isClosed()) {
+ throw new IllegalArgumentException("Socket is closed");
+ }
+ return true;
+ }
+
+ /**
+ * @since 4.1
+ */
+ public Socket createLayeredSocket(
+ final Socket socket,
+ final String host,
+ final int port,
+ final boolean autoClose) throws IOException, UnknownHostException {
+ SSLSocket sslSocket = (SSLSocket) this.socketfactory.createSocket(
+ socket,
+ host,
+ port,
+ autoClose
+ );
+ if (this.hostnameVerifier != null) {
+ this.hostnameVerifier.verify(host, sslSocket);
+ }
+ // verifyHostName() didn't blowup - good!
+ return sslSocket;
+ }
+
+ @Deprecated
+ public void setHostnameVerifier(X509HostnameVerifier hostnameVerifier) {
+ if ( hostnameVerifier == null ) {
+ throw new IllegalArgumentException("Hostname verifier may not be null");
+ }
+ this.hostnameVerifier = hostnameVerifier;
+ }
+
+ public X509HostnameVerifier getHostnameVerifier() {
+ return this.hostnameVerifier;
+ }
+
+ /**
+ * @deprecated Use {@link #connectSocket(Socket, InetSocketAddress, InetSocketAddress, HttpParams)}
+ */
+ @Deprecated
+ public Socket connectSocket(
+ final Socket socket,
+ final String host, int port,
+ final InetAddress localAddress, int localPort,
+ final HttpParams params) throws IOException, UnknownHostException, ConnectTimeoutException {
+ InetSocketAddress local = null;
+ if (localAddress != null || localPort > 0) {
+ // we need to bind explicitly
+ if (localPort < 0) {
+ localPort = 0; // indicates "any"
+ }
+ local = new InetSocketAddress(localAddress, localPort);
+ }
+ InetAddress remoteAddress;
+ if (this.nameResolver != null) {
+ remoteAddress = this.nameResolver.resolve(host);
+ } else {
+ remoteAddress = InetAddress.getByName(host);
+ }
+ InetSocketAddress remote = new InetSocketAddress(remoteAddress, port);
+ return connectSocket(socket, remote, local, params);
+ }
+
+ /**
+ * @deprecated Use {@link #createLayeredSocket(Socket, String, int, boolean)}
+ */
+ @Deprecated
+ public Socket createSocket(
+ final Socket socket,
+ final String host, int port,
+ boolean autoClose) throws IOException, UnknownHostException {
+ return createLayeredSocket(socket, host, port, autoClose);
+ }
+
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java
new file mode 100644
index 00000000..41d98249
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustManagerDecorator.java
@@ -0,0 +1,65 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.X509TrustManager;
+
+
+/**
+ * @since 4.1
+ */
+class TrustManagerDecorator implements X509TrustManager {
+
+ private final X509TrustManager trustManager;
+ private final TrustStrategy trustStrategy;
+
+ TrustManagerDecorator(final X509TrustManager trustManager, final TrustStrategy trustStrategy) {
+ super();
+ this.trustManager = trustManager;
+ this.trustStrategy = trustStrategy;
+ }
+
+ public void checkClientTrusted(
+ final X509Certificate[] chain, final String authType) throws CertificateException {
+ this.trustManager.checkClientTrusted(chain, authType);
+ }
+
+ public void checkServerTrusted(
+ final X509Certificate[] chain, final String authType) throws CertificateException {
+ if (!this.trustStrategy.isTrusted(chain, authType)) {
+ this.trustManager.checkServerTrusted(chain, authType);
+ }
+ }
+
+ public X509Certificate[] getAcceptedIssuers() {
+ return this.trustManager.getAcceptedIssuers();
+ }
+
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.jav b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.jav
new file mode 100644
index 00000000..4fdaaba2
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustSelfSignedStrategy.jav
@@ -0,0 +1,44 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * A trust strategy that accepts self-signed certificates as trusted. Verification of all other
+ * certificates is done by the trust manager configured in the SSL context.
+ *
+ * @since 4.1
+ */
+public class TrustSelfSignedStrategy implements TrustStrategy {
+
+ public boolean isTrusted(final X509Certificate[] chain, final String authType) throws CertificateException {
+ return true;
+ }
+
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java
new file mode 100644
index 00000000..3cf75b68
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/service/ssl/TrustStrategy.java
@@ -0,0 +1,57 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package net.sourceforge.subsonic.androidapp.service.ssl;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * A strategy to establish trustworthiness of certificates without consulting the trust manager
+ * configured in the actual SSL context. This interface can be used to override the standard
+ * JSSE certificate verification process.
+ *
+ * @since 4.1
+ */
+public interface TrustStrategy {
+
+ /**
+ * Determines whether the certificate chain can be trusted without consulting the trust manager
+ * configured in the actual SSL context. This method can be used to override the standard JSSE
+ * certificate verification process.
+ *
+ * Please note that, if this method returns false, the trust manager configured
+ * in the actual SSL context can still clear the certificate as trusted.
+ *
+ * @param chain the peer certificate chain
+ * @param authType the authentication type based on the client certificate
+ * @return true if the certificate can be trusted without verification by
+ * the trust manager, false otherwise.
+ * @throws CertificateException thrown if the certificate is not trusted or invalid.
+ */
+ boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException;
+
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java b/src/net/sourceforge/subsonic/androidapp/util/AlbumView.java
new file mode 100644
index 00000000..a4dd3acd
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java b/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java
new file mode 100644
index 00000000..b4776030
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/util/ArtistAdapter.java
@@ -0,0 +1,77 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see .
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.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 implements SectionIndexer {
+
+ // Both arrays are indexed by section ID.
+ private final Object[] sections;
+ private final Integer[] positions;
+
+ public ArtistAdapter(Context context, List artists) {
+ super(context, R.layout.artist_list_item, artists);
+
+ Set sectionSet = new LinkedHashSet(30);
+ List positionList = new ArrayList(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) {
+ 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/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java b/src/net/sourceforge/subsonic/androidapp/util/BackgroundTask.java
new file mode 100644
index 00000000..1db2fdc1
--- /dev/null
+++ b/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 .
+
+ 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 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/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java b/src/net/sourceforge/subsonic/androidapp/util/CacheCleaner.java
new file mode 100644
index 00000000..46459571
--- /dev/null
+++ b/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 files = new ArrayList();
+ List dirs = new ArrayList();
+
+ findCandidatesForDeletion(FileUtil.getMusicDirectory(context), files, dirs);
+ sortByAscendingModificationTime(files);
+
+ Set 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 dirs, Set 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 files, Set 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 files, List 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 files) {
+ Collections.sort(files, new Comparator() {
+ @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 findUndeletableFiles() {
+ Set undeletable = new HashSet(5);
+
+ for (DownloadFile downloadFile : downloadService.getDownloads()) {
+ undeletable.add(downloadFile.getPartialFile());
+ undeletable.add(downloadFile.getCompleteFile());
+ }
+
+ undeletable.add(FileUtil.getMusicDirectory(context));
+ return undeletable;
+ }
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java b/src/net/sourceforge/subsonic/androidapp/util/CancellableTask.java
new file mode 100644
index 00000000..90912c86
--- /dev/null
+++ b/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 .
+
+ 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 = new AtomicReference();
+ private final AtomicReference cancelListener = new AtomicReference();
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/Constants.java b/src/net/sourceforge/subsonic/androidapp/util/Constants.java
new file mode 100644
index 00000000..bebe49ce
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java b/src/net/sourceforge/subsonic/androidapp/util/EntryAdapter.java
new file mode 100644
index 00000000..1b4d72cf
--- /dev/null
+++ b/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 .
+
+ 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 {
+
+ private final SubsonicTabActivity activity;
+ private final ImageLoader imageLoader;
+ private final boolean checkable;
+
+ public EntryAdapter(SubsonicTabActivity activity, ImageLoader imageLoader, List 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/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java b/src/net/sourceforge/subsonic/androidapp/util/ErrorDialog.java
new file mode 100644
index 00000000..7649d95d
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java b/src/net/sourceforge/subsonic/androidapp/util/FileUtil.java
new file mode 100644
index 00000000..29911313
--- /dev/null
+++ b/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 .
+
+ 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 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 listFiles(File dir) {
+ File[] files = dir.listFiles();
+ if (files == null) {
+ Log.w(TAG, "Failed to list children for " + dir.getPath());
+ return new TreeSet();
+ }
+
+ return new TreeSet(Arrays.asList(files));
+ }
+
+ public static SortedSet listMusicFiles(File dir) {
+ SortedSet files = listFiles(dir);
+ Iterator 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 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 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/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java b/src/net/sourceforge/subsonic/androidapp/util/HorizontalSlider.java
new file mode 100644
index 00000000..4e6ff64c
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java b/src/net/sourceforge/subsonic/androidapp/util/ImageLoader.java
new file mode 100644
index 00000000..83e4fd6b
--- /dev/null
+++ b/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 .
+
+ 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.
+ *
+ * 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 cache = new LRUCache(100);
+ private final BlockingQueue queue;
+ private final int imageSizeDefault;
+ private final int imageSizeLarge;
+ private Drawable largeUnknownImage;
+
+ public ImageLoader(Context context) {
+ queue = new LinkedBlockingQueue(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/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java b/src/net/sourceforge/subsonic/androidapp/util/LRUCache.java
new file mode 100644
index 00000000..587d09d0
--- /dev/null
+++ b/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 .
+
+ 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{
+
+ private final int capacity;
+ private final Map map;
+
+ public LRUCache(int capacity) {
+ map = new HashMap(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 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 value;
+ private long timestamp;
+
+ public TimestampedValue(V value) {
+ this.value = new SoftReference(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/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java b/src/net/sourceforge/subsonic/androidapp/util/MergeAdapter.java
new file mode 100644
index 00000000..97dbc125
--- /dev/null
+++ b/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.
+ *
+ * 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 pieces = new ArrayList();
+
+ /**
+ * 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 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 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 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/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java b/src/net/sourceforge/subsonic/androidapp/util/ModalBackgroundTask.java
new file mode 100644
index 00000000..df25ced9
--- /dev/null
+++ b/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 .
+
+ 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 extends BackgroundTask {
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java b/src/net/sourceforge/subsonic/androidapp/util/MyViewFlipper.java
new file mode 100644
index 00000000..94f217ff
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/Pair.java b/src/net/sourceforge/subsonic/androidapp/util/Pair.java
new file mode 100644
index 00000000..f38c7c8a
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/util/Pair.java
@@ -0,0 +1,43 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see .
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import java.io.Serializable;
+
+/**
+ * @author Sindre Mehus
+ */
+public class Pair implements Serializable {
+
+ private final S first;
+ private final T second;
+
+ public Pair(S first, T second) {
+ this.first = first;
+ this.second = second;
+ }
+
+ public S getFirst() {
+ return first;
+ }
+
+ public T getSecond() {
+ return second;
+ }
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java b/src/net/sourceforge/subsonic/androidapp/util/ProgressListener.java
new file mode 100644
index 00000000..e5fc64ac
--- /dev/null
+++ b/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 .
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java b/src/net/sourceforge/subsonic/androidapp/util/SackOfViewsAdapter.java
new file mode 100644
index 00000000..ca825e55
--- /dev/null
+++ b/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.
+ *
+ * If you supply a size, you must implement newView(), to
+ * create a required view. The adapter will then cache these
+ * views.
+ *
+ * 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.
+ *
+ * Subclasses may also wish to override areAllItemsEnabled()
+ * (default: false) and isEnabled() (default: false), if some
+ * of their rows should be selectable.
+ *
+ * It is assumed each view is unique, and therefore will not
+ * get recycled.
+ *
+ * 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 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(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 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/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java b/src/net/sourceforge/subsonic/androidapp/util/ShufflePlayBuffer.java
new file mode 100644
index 00000000..825fcc44
--- /dev/null
+++ b/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 .
+
+ 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 buffer = new ArrayList();
+ 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 get(int size) {
+ clearBufferIfnecessary();
+
+ List result = new ArrayList(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/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java b/src/net/sourceforge/subsonic/androidapp/util/SilentBackgroundTask.java
new file mode 100644
index 00000000..7aa85d7c
--- /dev/null
+++ b/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 .
+
+ Copyright 2010 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.app.Activity;
+
+/**
+ * @author Sindre Mehus
+ */
+public abstract class SilentBackgroundTask extends BackgroundTask {
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java b/src/net/sourceforge/subsonic/androidapp/util/SimpleServiceBinder.java
new file mode 100644
index 00000000..b917564c
--- /dev/null
+++ b/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 .
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.util;
+
+import android.os.Binder;
+
+/**
+ * @author Sindre Mehus
+ */
+public class SimpleServiceBinder extends Binder {
+
+ private final S service;
+
+ public SimpleServiceBinder(S service) {
+ this.service = service;
+ }
+
+ public S getService() {
+ return service;
+ }
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/util/SongView.java b/src/net/sourceforge/subsonic/androidapp/util/SongView.java
new file mode 100644
index 00000000..22902a11
--- /dev/null
+++ b/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 .
+
+ 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 INSTANCES = new WeakHashMap();
+ 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/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java b/src/net/sourceforge/subsonic/androidapp/util/TabActivityBackgroundTask.java
new file mode 100644
index 00000000..033a51ad
--- /dev/null
+++ b/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 extends BackgroundTask {
+
+ 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/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java b/src/net/sourceforge/subsonic/androidapp/util/TimeLimitedCache.java
new file mode 100644
index 00000000..80c65351
--- /dev/null
+++ b/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 .
+
+ 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 {
+
+ private SoftReference 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(value);
+ expires = System.currentTimeMillis() + timeUnit.toMillis(ttl);
+ }
+
+ public void clear() {
+ expires = 0L;
+ value = null;
+ }
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/util/Util.java b/src/net/sourceforge/subsonic/androidapp/util/Util.java
new file mode 100644
index 00000000..8fc98f8a
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/util/Util.java
@@ -0,0 +1,776 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see .
+
+ Copyright 2009 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.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.widget.RemoteViews;
+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 SERVER_REST_VERSIONS = new ConcurrentHashMap();
+
+ // Used by hexEncode()
+ private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
+ private static Toast toast;
+
+ private Util() {
+ }
+
+ public static boolean isOffline(Context context) {
+ 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 InputStream as a byte[].
+ *
+ * This method buffers the input internally, so there is no need to use a
+ * BufferedInputStream.
+ *
+ * @param input the InputStream 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:
+ *
+ *
format(918) returns "918 B".
+ *
format(98765) returns "96 KB".
+ *
format(1238476) returns "1.2 MB".
+ *
+ * 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:
+ *
+ *
format(918) returns "918 B".
+ *
format(98765) returns "96 KB".
+ *
format(1238476) returns "1.2 MB".
+ *
+ * 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);
+
+ 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 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.");
+ }
+ }
+
+ /**
+ *
Broadcasts the given song info as the new song being played.
Broadcasts the given player state as the one being set.
+ */
+ 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);
+ }
+}
diff --git a/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java b/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java
new file mode 100644
index 00000000..76a45253
--- /dev/null
+++ b/src/net/sourceforge/subsonic/androidapp/view/VisualizerView.java
@@ -0,0 +1,132 @@
+/*
+ This file is part of Subsonic.
+
+ Subsonic is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Subsonic is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Subsonic. If not, see .
+
+ Copyright 2011 (C) Sindre Mehus
+ */
+package net.sourceforge.subsonic.androidapp.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.media.audiofx.Visualizer;
+import android.util.AttributeSet;
+import android.view.View;
+import net.sourceforge.subsonic.androidapp.audiofx.VisualizerController;
+import net.sourceforge.subsonic.androidapp.domain.PlayerState;
+import net.sourceforge.subsonic.androidapp.service.DownloadService;
+import net.sourceforge.subsonic.androidapp.service.DownloadServiceImpl;
+
+/**
+ * A simple class that draws waveform data received from a
+ * {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture}
+ *
+ * @author Sindre Mehus
+ * @version $Id$
+ */
+public class VisualizerView extends View {
+
+ private static final int PREFERRED_CAPTURE_RATE_MILLIHERTZ = 20000;
+
+ private final Paint paint = new Paint();
+
+ private byte[] data;
+ private float[] points;
+ private boolean active;
+
+ public VisualizerView(Context context) {
+ super(context);
+
+ paint.setStrokeWidth(2f);
+ paint.setAntiAlias(true);
+ paint.setColor(Color.rgb(129, 201, 54));
+ }
+
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ Visualizer visualizer = getVizualiser();
+ if (visualizer == null) {
+ return;
+ }
+
+ int captureRate = Math.min(PREFERRED_CAPTURE_RATE_MILLIHERTZ, Visualizer.getMaxCaptureRate());
+ if (active) {
+ visualizer.setDataCaptureListener(new Visualizer.OnDataCaptureListener() {
+ @Override
+ public void onWaveFormDataCapture(Visualizer visualizer, byte[] waveform, int samplingRate) {
+ updateVisualizer(waveform);
+ }
+
+ @Override
+ public void onFftDataCapture(Visualizer visualizer, byte[] fft, int samplingRate) {
+ }
+ }, captureRate, true, false);
+ } else {
+ visualizer.setDataCaptureListener(null, captureRate, false, false);
+ }
+
+ visualizer.setEnabled(active);
+ invalidate();
+ }
+
+ private Visualizer getVizualiser() {
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ VisualizerController visualizerController = downloadService == null ? null : downloadService.getVisualizerController();
+ return visualizerController == null ? null : visualizerController.getVisualizer();
+ }
+
+ private void updateVisualizer(byte[] waveform) {
+ this.data = waveform;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+
+ if (!active) {
+ return;
+ }
+ DownloadService downloadService = DownloadServiceImpl.getInstance();
+ if (downloadService != null && downloadService.getPlayerState() != PlayerState.STARTED) {
+ return;
+ }
+
+ if (data == null) {
+ return;
+ }
+
+ if (points == null || points.length < data.length * 4) {
+ points = new float[data.length * 4];
+ }
+
+ int w = getWidth();
+ int h = getHeight();
+
+ for (int i = 0; i < data.length - 1; i++) {
+ points[i * 4] = w * i / (data.length - 1);
+ points[i * 4 + 1] = h / 2 + ((byte) (data[i] + 128)) * (h / 2) / 128;
+ points[i * 4 + 2] = w * (i + 1) / (data.length - 1);
+ points[i * 4 + 3] = h / 2 + ((byte) (data[i + 1] + 128)) * (h / 2) / 128;
+ }
+
+ canvas.drawLines(points, paint);
+ }
+}
diff --git a/subsonic.keystore b/subsonic.keystore
new file mode 100644
index 00000000..b6223fea
Binary files /dev/null and b/subsonic.keystore differ
diff --git a/tests/AndroidManifest.xml b/tests/AndroidManifest.xml
new file mode 100644
index 00000000..58fb1e95
--- /dev/null
+++ b/tests/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/build.properties b/tests/build.properties
new file mode 100644
index 00000000..a6504854
--- /dev/null
+++ b/tests/build.properties
@@ -0,0 +1,15 @@
+# This file is used to override default values used by the Ant build system.
+#
+# This file must be checked in Version Control Systems, as it is
+# integral to the build system of your project.
+
+# The name of your application package as defined in the manifest.
+# Used by the 'uninstall' rule.
+#application-package=com.example.myproject
+
+# The name of the source folder.
+#source-folder=src
+
+# The name of the output folder.
+#out-folder=bin
+
diff --git a/tests/build.xml b/tests/build.xml
new file mode 100644
index 00000000..433559fa
--- /dev/null
+++ b/tests/build.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/default.properties b/tests/default.properties
new file mode 100644
index 00000000..4513a1e4
--- /dev/null
+++ b/tests/default.properties
@@ -0,0 +1,11 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must be checked in Version Control Systems.
+#
+# To customize properties used by the Ant build system use,
+# "build.properties", and override values to adapt the script to your
+# project structure.
+
+# Project target.
+target=android-3
diff --git a/tests/local.properties b/tests/local.properties
new file mode 100644
index 00000000..2e5877fa
--- /dev/null
+++ b/tests/local.properties
@@ -0,0 +1,10 @@
+# This file is automatically generated by Android Tools.
+# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
+#
+# This file must *NOT* be checked in Version Control Systems,
+# as it contains information specific to your local configuration.
+
+# location of the SDK. This is only used by Ant
+# For customization when using a Version Control System, please read the
+# header note.
+sdk-location=C:\\progs\\android-sdk-windows-1.5_r2