113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/client/Client.java:
--------------------------------------------------------------------------------
1 | package net.kevinboone.androidmediaserver.client;
2 |
3 | import java.io.*;
4 | import java.net.*;
5 | import org.json.*;
6 |
7 | public class Client
8 | {
9 | protected int port;
10 | protected String host;
11 |
12 | public Client (String host, int port)
13 | {
14 | this.host = host;
15 | this.port = port;
16 | }
17 |
18 | private String streamToString (java.io.InputStream is)
19 | {
20 | java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A");
21 | return s.hasNext() ? s.next() : "";
22 | }
23 |
24 | public JSONObject runCommand (String command)
25 | throws ClientException, IOException
26 | {
27 | try
28 | {
29 | URL url = new URL ("http://" + host + ":" + port + "/cmd?cmd=" +
30 | URLEncoder.encode (command));
31 | InputStream is = url.openStream ();
32 | String result = streamToString (is);
33 | //System.out.println ("result= "+ result);
34 | JSONObject jo = new JSONObject (result);
35 | is.close();
36 | return jo;
37 | }
38 | catch (MalformedURLException e)
39 | {
40 | // This should never happen, unless the JVM is broken
41 | throw new ClientException (e.toString());
42 | }
43 | catch (JSONException e)
44 | {
45 | throw new ClientException (e.toString());
46 | }
47 | }
48 |
49 |
50 | protected void checkJSONResponse (JSONObject response)
51 | throws ClientException
52 | {
53 | try
54 | {
55 | int status = response.getInt ("status");
56 | if (status != 0)
57 | {
58 | String msg = response.getString ("message");
59 | if (msg == null)
60 | throw new ClientException ("Server returned error code " + status);
61 | else
62 | throw new ClientException (msg);
63 | }
64 | }
65 | catch (JSONException e)
66 | {
67 | throw new ClientException ("Error parsing JSON: " + e.toString());
68 | }
69 | }
70 |
71 |
72 | public void play () throws ClientException, IOException
73 | {
74 | JSONObject response = runCommand ("play");
75 | checkJSONResponse (response);
76 | }
77 |
78 |
79 | public void pause () throws ClientException, IOException
80 | {
81 | JSONObject response = runCommand ("pause");
82 | checkJSONResponse (response);
83 | }
84 |
85 |
86 | public void stop () throws ClientException, IOException
87 | {
88 | JSONObject response = runCommand ("stop");
89 | checkJSONResponse (response);
90 | }
91 |
92 |
93 | public void next () throws ClientException, IOException
94 | {
95 | JSONObject response = runCommand ("next");
96 | checkJSONResponse (response);
97 | }
98 |
99 | public void prev () throws ClientException, IOException
100 | {
101 | JSONObject response = runCommand ("prev");
102 | checkJSONResponse (response);
103 | }
104 |
105 |
106 | public Status getStatus () throws ClientException, IOException
107 | {
108 | try
109 | {
110 | JSONObject response = runCommand ("status");
111 | checkJSONResponse (response);
112 | Status status = new Status();
113 | String sTransportStatus = response.getString ("transport_status");
114 | if ("playing".equals (sTransportStatus))
115 | status.setTransportStatus (Status.TransportStatus.PLAYING);
116 | else if ("stopped".equals (sTransportStatus))
117 | status.setTransportStatus (Status.TransportStatus.STOPPED);
118 | else if ("paused".equals (sTransportStatus))
119 | status.setTransportStatus (Status.TransportStatus.PAUSED);
120 | else
121 | status.setTransportStatus (Status.TransportStatus.UNKNOWN);
122 |
123 | status.setTitle (response.getString ("title"));
124 | status.setUri (response.getString ("uri"));
125 | status.setArtist (response.getString ("artist"));
126 | status.setAlbum (response.getString ("album"));
127 | status.setPosition (Integer.parseInt (response.getString
128 | ("transport_position")));
129 | status.setDuration (Integer.parseInt (response.getString
130 | ("transport_duration")));
131 |
132 | return status;
133 | }
134 | catch (JSONException e)
135 | {
136 | throw new ClientException ("Error parsing response from server: " +
137 | e.toString());
138 | }
139 | }
140 |
141 | public static void main (String[] args) throws Exception
142 | {
143 | Client client = new Client ("192.168.1.104", 30000); // TEST -- change
144 | if (args.length == 0)
145 | {
146 | Status ts = client.getStatus ();
147 | System.out.println (ts);
148 | }
149 | else
150 | {
151 | if ("play".equals (args[0]))
152 | client.play();
153 | else if ("pause".equals (args[0]))
154 | client.pause();
155 | else if ("stop".equals (args[0]))
156 | client.stop();
157 | else if ("next".equals (args[0]))
158 | client.next();
159 | else if ("prev".equals (args[0]))
160 | client.prev();
161 | }
162 | }
163 |
164 | }
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
14 |
16 |
17 |
24 |
31 |
32 |
33 |
40 |
47 |
48 |
49 |
56 |
63 |
64 |
65 |
72 |
79 |
80 |
81 |
88 |
95 |
96 |
97 |
98 |
102 |
103 |
108 |
109 |
113 |
114 |
121 |
127 |
133 |
139 |
145 |
151 |
157 |
163 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmediaserver/Main.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 | package net.kevinboone.androidmediaserver;
7 |
8 | import android.app.Activity;
9 | import android.util.Log;
10 | import android.os.*;
11 | import android.view.*;
12 | import android.widget.*;
13 | import android.media.*;
14 | import android.content.*;
15 | import java.io.*;
16 | import java.net.*;
17 | import android.preference.*;
18 | import net.kevinboone.androidmediaserver.client.*;
19 |
20 | /** This class contains the Android user interface, such as it is. */
21 | public class Main extends Activity
22 | {
23 | private Handler handler = new Handler();
24 | // Ugly, but we need the port number to be accessible to the background
25 | // service, and there is no easy way to pass arguments to it
26 | protected static int port = 30000;
27 | protected int uiUpdateInterval = 5000; // msec
28 | protected int webUpdateInterval = 5000; // msec
29 | protected static int tracksPerPage = 30;
30 | protected static int maxSearchResults = 20;
31 |
32 |
33 | /*An arbitrary value to distinguish completion of the Settings activity
34 | from any other activity (there are none, at present) */
35 | private static final int RESULT_SETTINGS = 0;
36 |
37 | TextView titleView;
38 | TextView albumView;
39 | TextView artistView;
40 | TextView transportStatusView;
41 |
42 | TextView messageView;
43 |
44 | /* Define a runnable for use with the Handler, that will
45 | update the UI on the main thread every 5 seconds */
46 | private Runnable updateUITask = new Runnable()
47 | {
48 | public void run()
49 | {
50 | messageView.setText ("");
51 | updateUI();
52 | handler.postDelayed (this, uiUpdateInterval);
53 | }
54 | };
55 |
56 | /**
57 | Update the UI from the server monitoring thread.
58 | */
59 | public void updateUI()
60 | {
61 | try
62 | {
63 | Client client = new Client ("localhost", port);
64 | Status status = client.getStatus();
65 | titleView.setText (status.getTitle());
66 | albumView.setText (status.getAlbum());
67 | artistView.setText (status.getArtist());
68 | transportStatusView.setText
69 | (status.transportStatusToString (status.getTransportStatus()));
70 | }
71 | catch (ConnectException e)
72 | {
73 | /* This is probably the user's only indication that the web server
74 | did not initialize. */
75 | Log.e ("AMS", e.toString());
76 | messageView.setText
77 | ("Can't connect to service: check server port number and restart");
78 | }
79 | catch (Exception e)
80 | {
81 | Log.e ("AMS", e.toString());
82 | messageView.setText (e.toString());
83 | }
84 | }
85 |
86 | @Override
87 | public void onCreate (Bundle savedInstanceState)
88 | {
89 | super.onCreate(savedInstanceState);
90 |
91 | /*These lines allow network operations on the main thread. In nearly
92 | all cases this is a bad idea, but here the network operation is
93 | to a different thread in this same application, and should never
94 | block. If it does block, the app's hosed anyway, so this won't
95 | make it worse. */
96 | StrictMode.ThreadPolicy policy =
97 | new StrictMode.ThreadPolicy.Builder().permitAll().build();
98 | StrictMode.setThreadPolicy(policy);
99 |
100 | applySettings();
101 |
102 | setContentView(R.layout.main);
103 | setVolumeControlStream(AudioManager.STREAM_MUSIC);
104 | TextView urlView = (TextView) findViewById (R.id.url);
105 | titleView = (TextView) findViewById (R.id.title);
106 | messageView = (TextView) findViewById (R.id.message);
107 | artistView = (TextView) findViewById (R.id.artist);
108 | albumView = (TextView) findViewById (R.id.album);
109 | transportStatusView = (TextView) findViewById (R.id.transport_status);
110 |
111 | String ip = AndroidNetworkUtil.getWifiIP(this);
112 | if (ip != null)
113 | {
114 | try
115 | {
116 | startBackgroundService();
117 | urlView.setText ("http://" + ip + ":" + port + "/");
118 |
119 | // If we get here, with luck the server is running
120 |
121 | handler.removeCallbacks (updateUITask);
122 | handler.postDelayed (updateUITask, uiUpdateInterval);
123 | }
124 | catch (Throwable e)
125 | {
126 | messageView.setText ("Error: " + e.getMessage());
127 | }
128 | }
129 | else
130 | {
131 | messageView.setText ("No WIFI connection?");
132 | }
133 | }
134 |
135 | @Override
136 | public void onDestroy()
137 | {
138 | stopBackgroundService();
139 | super.onDestroy();
140 | }
141 |
142 |
143 | public void stopBackgroundService ()
144 | {
145 | Intent intent = new Intent (this, WebPlayerService.class);
146 | stopService (intent);
147 | }
148 |
149 |
150 | public void startBackgroundService ()
151 | {
152 | Intent intent = new Intent (this, WebPlayerService.class);
153 | startService (intent);
154 | }
155 |
156 |
157 | public void buttonSettingsClicked(View dummy)
158 | {
159 | Intent i = new Intent (this, SettingsActivity.class);
160 | startActivityForResult(i, RESULT_SETTINGS);
161 | }
162 |
163 |
164 | public void buttonShutdownClicked(View dummy)
165 | {
166 | finish();
167 | }
168 |
169 |
170 | public void buttonPlayClicked(View dummy)
171 | {
172 | play();
173 | updateUI();
174 | }
175 |
176 |
177 | public void buttonPauseClicked(View dummy)
178 | {
179 | pause();
180 | updateUI();
181 | }
182 |
183 |
184 | public void buttonNextClicked(View dummy)
185 | {
186 | next();
187 | updateUI();
188 | }
189 |
190 |
191 | public void buttonPrevClicked(View dummy)
192 | {
193 | prev();
194 | updateUI();
195 | }
196 |
197 |
198 | public void buttonStopClicked(View dummy)
199 | {
200 | stop();
201 | updateUI();
202 | }
203 |
204 |
205 | public void play()
206 | {
207 | Client client = new Client ("localhost", port);
208 | try
209 | {
210 | client.play();
211 | }
212 | catch (Exception e)
213 | {
214 | messageView.setText (e.getMessage());
215 | }
216 | }
217 |
218 |
219 | public void pause()
220 | {
221 | Client client = new Client ("localhost", port);
222 | try
223 | {
224 | client.pause();
225 | }
226 | catch (Exception e)
227 | {
228 | messageView.setText (e.getMessage());
229 | }
230 | }
231 |
232 |
233 | public void next()
234 | {
235 | Client client = new Client ("localhost", port);
236 | try
237 | {
238 | client.next();
239 | }
240 | catch (Exception e)
241 | {
242 | messageView.setText (e.getMessage());
243 | }
244 | }
245 |
246 |
247 | public void prev()
248 | {
249 | Client client = new Client ("localhost", port);
250 | try
251 | {
252 | client.prev();
253 | }
254 | catch (Exception e)
255 | {
256 | messageView.setText (e.getMessage());
257 | }
258 | }
259 |
260 |
261 | public void stop()
262 | {
263 | Client client = new Client ("localhost", port);
264 | try
265 | {
266 | client.stop();
267 | }
268 | catch (Exception e)
269 | {
270 | messageView.setText (e.getMessage());
271 | }
272 | }
273 |
274 | @Override
275 | protected void onActivityResult (int requestCode,
276 | int resultCode, Intent data)
277 | {
278 | super.onActivityResult (requestCode, resultCode, data);
279 | switch (requestCode)
280 | {
281 | case RESULT_SETTINGS:
282 | applySettings ();
283 | break;
284 | }
285 | }
286 |
287 |
288 | private void applySettings ()
289 | {
290 | uiUpdateInterval = getIntPreference ("uiupdateinterval", 5) * 1000;
291 | maxSearchResults = getIntPreference ("maxsearchresults", 20);
292 | tracksPerPage = getIntPreference ("tracksperpage", 30);
293 | webUpdateInterval = getIntPreference ("webupdateinterval", 5) * 1000;
294 | int newPort = getIntPreference ("port", 30000);
295 | if (newPort != port)
296 | {
297 | port = newPort;
298 | Log.w ("AMS", "Changing port number to " + port);
299 | stopBackgroundService();
300 | startBackgroundService();
301 | }
302 | }
303 |
304 | /** Wrapper around Android's brain-dead (non-)handling of integer-valued
305 | * user preferences :/. */
306 | int getIntPreference (String name, int deflt)
307 | {
308 | SharedPreferences sharedPrefs =
309 | PreferenceManager.getDefaultSharedPreferences (this);
310 |
311 | int value = deflt;
312 | try
313 | {
314 | value =
315 | Integer.parseInt (sharedPrefs.getString (name, "" + deflt));
316 | }
317 | catch (Exception e)
318 | {
319 | value = deflt;
320 | }
321 | return value;
322 | }
323 |
324 |
325 |
326 | }
327 |
--------------------------------------------------------------------------------
/app/src/main/assets/docroot/functions.js:
--------------------------------------------------------------------------------
1 | var message_tick = 0;
2 |
3 | /*
4 | Called on loading main page
5 | */
6 | function onload ()
7 | {
8 | setInterval (tick, 5000);
9 | set_message ("Android music player ready");
10 | make_command_request ("status", response_callback_transport_status);
11 | }
12 |
13 | /*
14 | Called every 5 seconds by timer created at page load time
15 | */
16 | function tick()
17 | {
18 | message_tick++;
19 | if (message_tick >= 2)
20 | {
21 | set_message("");
22 | message_tick = 0;
23 | }
24 | make_command_request ("status", response_callback_transport_status);
25 | }
26 |
27 |
28 |
29 |
30 | function response_callback_transport_status (response_text)
31 | {
32 | var obj = eval ('(' + response_text + ')');
33 | set_transport_title (obj.title);
34 | set_transport_status (obj.transport_status);
35 | set_transport_position (msectominsec (obj.transport_position));
36 | set_transport_duration (msectominsec (obj.transport_duration));
37 | set_transport_artist (obj.artist);
38 | set_transport_album (obj.album);
39 | }
40 |
41 | /*
42 | Make an HTTP request on the specified uri, and call callback with
43 | the results when complete
44 | */
45 | function make_request (uri, callback)
46 | {
47 | var http_request = false;
48 | if (window.XMLHttpRequest)
49 | { // Mozilla, Safari, ...
50 | http_request = new XMLHttpRequest();
51 | if (http_request.overrideMimeType)
52 | {
53 | http_request.overrideMimeType('text/plain');
54 | }
55 | }
56 | else if (window.ActiveXObject)
57 | { // IE
58 | try
59 | {
60 | http_request = new ActiveXObject("Msxml2.XMLHTTP");
61 | }
62 | catch (e)
63 | {
64 | try
65 | {
66 | http_request = new ActiveXObject("Microsoft.XMLHTTP");
67 | }
68 | catch (e)
69 | {}
70 | }
71 | }
72 | if (!http_request)
73 | {
74 | alert('Giving up :( Cannot create an XMLHTTP instance');
75 | return false;
76 | }
77 | http_request.onreadystatechange = function()
78 | {
79 | do_http_request_complete (callback, http_request);
80 | };
81 | http_request.open('GET', uri, true);
82 | http_request.timeout = 10000; // Got to have _some_ value
83 | http_request.send(null);
84 | }
85 |
86 | /*
87 | do_http_request_complete
88 | Helper function for make_request
89 | */
90 | function do_http_request_complete (callback, http_request)
91 | {
92 | if (http_request.readyState == 4)
93 | {
94 | if (http_request.status == 200)
95 | {
96 | set_message ("");
97 | callback (http_request.responseText);
98 | }
99 | else
100 | {
101 | //alert('There was a problem with the request.');
102 | }
103 | }
104 | }
105 |
106 |
107 | /*
108 | stop() invoked from a link on the HTML page
109 | */
110 | function stop()
111 | {
112 | make_command_request ("stop", response_callback_gen_status);
113 | }
114 |
115 |
116 | /*
117 | play() invoked from a link on the HTML page
118 | */
119 | function play()
120 | {
121 | make_command_request ("play", response_callback_gen_status);
122 | }
123 |
124 |
125 | /*
126 | play_file_now(file) invoked from a link on the HTML page
127 | */
128 | function play_file_now(file)
129 | {
130 | make_command_request ("play_file_now " + file , response_callback_gen_status);
131 | }
132 |
133 |
134 | /*
135 | play_album_now(album) invoked from a link on the HTML page
136 | */
137 | function play_album_now(album)
138 | {
139 | make_command_request ("play_album_now " + album,
140 | response_callback_gen_status);
141 | }
142 |
143 |
144 | /*
145 | add_to_playlist(file) invoked from a link on the HTML page
146 | */
147 | function add_to_playlist(file)
148 | {
149 | make_command_request ("add_to_playlist " + file , response_callback_gen_status);
150 | }
151 |
152 |
153 | /*
154 | add_album_to_playlist(album) invoked from a link on the HTML page
155 | */
156 | function add_album_to_playlist(album)
157 | {
158 | make_command_request ("add_album_to_playlist " + album , response_callback_gen_status);
159 | }
160 |
161 |
162 |
163 | /*
164 | pause() invoked by a link on the HTML page
165 | */
166 | function pause()
167 | {
168 | make_command_request ("pause", response_callback_gen_status);
169 | }
170 |
171 |
172 | /*
173 | prev() invoked by a link on the HTML page
174 | */
175 | function prev()
176 | {
177 | make_command_request ("prev", response_callback_gen_status);
178 | }
179 |
180 |
181 | /*
182 | next() invoked by a link on the HTML page
183 | */
184 | function next()
185 | {
186 | make_command_request ("next", response_callback_gen_status);
187 | }
188 |
189 |
190 | /*
191 | clear_playlist() invoked by a link on the HTML page
192 | */
193 | function clear_playlist()
194 | {
195 | make_command_request ("clear_playlist", response_callback_gen_status);
196 | }
197 |
198 |
199 | /*
200 | rescan_catalog() invoked by a link on the HTML page
201 | */
202 | function rescan_catalog()
203 | {
204 | make_command_request ("rescan_catalog", response_callback_gen_status);
205 | }
206 |
207 |
208 | /*
209 | random_album() invoked by a link on the HTML page
210 | */
211 | function random_album()
212 | {
213 | make_command_request ("random_album", response_callback_gen_status);
214 | }
215 |
216 |
217 | /*
218 | shuffle_playlist() invoked by a link on the HTML page
219 | */
220 | function shuffle_playlist()
221 | {
222 | make_command_request ("shuffle_playlist", response_callback_gen_status);
223 | }
224 |
225 |
226 | /*
227 | rescan_filesystem() invoked by a link on the HTML page
228 | */
229 | function rescan_filesystem()
230 | {
231 | make_command_request ("rescan_filesystem", response_callback_gen_status);
232 | }
233 |
234 |
235 | /*
236 | volume_up() invoked by a link on the HTML page
237 | */
238 | function volume_up()
239 | {
240 | make_command_request ("volume_up", response_callback_gen_status);
241 | }
242 |
243 |
244 | /*
245 | enable_eq() invoked by a link on the HTML page or this file
246 | */
247 | function enable_eq()
248 | {
249 | make_command_request ("enable_eq", response_callback_gen_status);
250 | }
251 |
252 | /*
253 | disable_eq() invoked by a link on the HTML page or this file
254 | */
255 | function disable_eq()
256 | {
257 | make_command_request ("disable_eq", response_callback_gen_status);
258 | }
259 |
260 | /*
261 | enable_bass_boost() invoked by a link on the HTML page or this file
262 | */
263 | function enable_bass_boost()
264 | {
265 | make_command_request ("enable_bass_boost", response_callback_gen_status);
266 | }
267 |
268 |
269 | /*
270 | disable_bass_boost() invoked by a link on the HTML page or this file
271 | */
272 | function disable_bass_boost()
273 | {
274 | make_command_request ("disable_bass_boost", response_callback_gen_status);
275 | }
276 |
277 |
278 | /*
279 | volume_down() invoked by a link on the HTML page
280 | */
281 | function volume_down()
282 | {
283 | make_command_request ("volume_down", response_callback_gen_status);
284 | }
285 |
286 |
287 | /*
288 | set_eq_level () invoked by a link on the HTML page or this page
289 | */
290 | function set_eq_level(band, level)
291 | {
292 | make_command_request ("set_eq_level " + band + "," + level,
293 | response_callback_gen_status);
294 | }
295 |
296 |
297 | /*
298 | set_bb_level () invoked by a link on the HTML page or this page
299 | */
300 | function set_bb_level(level)
301 | {
302 | make_command_request ("set_bb_level " + level,
303 | response_callback_gen_status);
304 | }
305 |
306 |
307 | /*
308 | set_vol_level () invoked by a link on the HTML page or this page
309 | */
310 | function set_vol_level(level)
311 | {
312 | make_command_request ("set_vol_level " + level,
313 | response_callback_gen_status);
314 | }
315 |
316 |
317 |
318 | function make_command_request (cmd, callback)
319 | {
320 | self_uri = parse_uri (window.location.href);
321 |
322 |
323 | // The 'random' param is added to work around a stupid caching
324 | // bug in IE
325 | cmd_uri = "http://" + self_uri.host + ":" + self_uri.port +
326 | "/cmd?cmd=" + encodeURIComponent (cmd) + "&random=" + Math.random();
327 |
328 | make_request (cmd_uri, callback);
329 |
330 | //if (cmd != "get_transport_status")
331 | // set_message ("Communicating with server...");
332 | }
333 |
334 |
335 | /*
336 | A general callback to be attached to server commands that generate
337 | no specific response except a status message (play,
338 | add_to_playlist, etc)
339 | */
340 | function response_callback_gen_status (response_text)
341 | {
342 | var obj = eval ('(' + response_text + ')');
343 | set_message (obj.message);
344 | }
345 |
346 | /*
347 | The following functions just set text strings to page elements
348 | */
349 | function set_message (msg)
350 | {
351 | document.getElementById ("messagecell").innerHTML = msg;
352 | }
353 |
354 | function set_transport_uri (s)
355 | {
356 | document.getElementById ("transport_uri").innerHTML = s;
357 | }
358 |
359 | function set_transport_title (s)
360 | {
361 | document.getElementById ("transport_title").innerHTML = s;
362 | }
363 |
364 | function set_transport_album (s)
365 | {
366 | document.getElementById ("transport_album").innerHTML = s;
367 | }
368 |
369 | function set_transport_artist (s)
370 | {
371 | document.getElementById ("transport_artist").innerHTML = s;
372 | }
373 |
374 | function set_transport_status (s)
375 | {
376 | document.getElementById ("transport_status").innerHTML = s;
377 | }
378 |
379 | function set_transport_position (s)
380 | {
381 | document.getElementById ("transport_position").innerHTML = s;
382 | }
383 |
384 | function set_transport_duration (s)
385 | {
386 | document.getElementById ("transport_duration").innerHTML = s;
387 | }
388 |
389 | /*
390 | parse_uri
391 | Parse a uri into host, port, etc. Result are obatined in a structure
392 | */
393 | function parse_uri (str) {
394 | var o = parse_uri.options,
395 | m = o.parser[o.strictMode ? "strict" : "loose"].exec(str),
396 | uri = {},
397 | i = 14;
398 |
399 | while (i--) uri[o.key[i]] = m[i] || "";
400 |
401 | uri[o.q.name] = {};
402 | uri[o.key[12]].replace(o.q.parser, function ($0, $1, $2) {
403 | if ($1) uri[o.q.name][$1] = $2;
404 | });
405 |
406 | return uri;
407 | };
408 |
409 |
410 | parse_uri.options = {
411 | strictMode: false,
412 | key: ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"],
413 | q: {
414 | name: "queryKey",
415 | parser: /(?:^|&)([^&=]*)=?([^&]*)/g
416 | },
417 | parser: {
418 | strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,
419 | loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
420 | }
421 | };
422 |
423 |
424 | /*
425 | Convert a time in msec to min:sec
426 | */
427 | function msectominsec (msec)
428 | {
429 | var totalsec = Math.floor (msec / 1000);
430 | if (totalsec < 0) totalsec = 0; // Work around Xine bug
431 | var min = Math.floor (totalsec / 60);
432 | var sec = totalsec - min * 60;
433 | var smin = "" + min;
434 | if (min < 10) smin = "0" + smin;
435 | var ssec = "" + sec;
436 | if (sec < 10) ssec = "0" + ssec;
437 | return "" + smin + ":" + ssec;
438 | }
439 |
440 | /*
441 | Called when EQ is enabled or disabled on the gui_eq page
442 | */
443 | function onClickEqEnabled (cb)
444 | {
445 | if (cb.checked)
446 | enable_eq ();
447 | else
448 | disable_eq ();
449 | }
450 |
451 |
452 | /*
453 | Called when bass boost is enabled or disabled on the gui_eq page
454 | */
455 | function onClickBBEnabled (cb)
456 | {
457 | if (cb.checked)
458 | enable_bass_boost ();
459 | else
460 | disable_bass_boost ();
461 | }
462 |
463 |
464 | /*
465 | Called when an EQ slider is moved
466 | */
467 | function onChangeEqSlider (band, value)
468 | {
469 | set_eq_level (band, value);
470 | }
471 |
472 |
473 | /*
474 | Called when the BB slider is moved
475 | */
476 | function onChangeBBSlider (value)
477 | {
478 | set_bb_level (value);
479 | }
480 |
481 |
482 | /*
483 | Called when the Volume slider is moved
484 | */
485 | function onChangeVolSlider (value)
486 | {
487 | set_vol_level (value);
488 | }
489 |
490 |
491 | /* We have to go to prodigious lengths to get a delay in JS.
492 | In this case, we need to interpose a delay between executing a
493 | command on the server, and having the page refresh itself. */
494 | function refresh()
495 | {
496 | window.location.reload(true);
497 | }
498 |
499 |
500 | function delay_and_refresh()
501 | {
502 | setTimeout ("refresh()", 300);
503 | }
504 |
505 |
506 |
507 |
508 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/Player.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 | import java.util.*;
9 | import java.text.*;
10 | import java.io.*;
11 | import java.net.*;
12 | import android.os.*;
13 | import android.content.*;
14 | import android.graphics.*;
15 | import android.media.MediaPlayer;
16 | import android.util.Log;
17 | import android.media.*;
18 | import android.media.audiofx.*;
19 |
20 | public class Player implements
21 | MediaPlayer.OnCompletionListener,
22 | AudioManager.OnAudioFocusChangeListener
23 | {
24 | private MediaPlayer mediaPlayer = new MediaPlayer();
25 | private String currentPlaybackUri = null; //File
26 | private TrackInfo currentPlaybackTrackInfo = null;
27 | protected List playlist = new Vector();
28 | protected int currentPlaylistIndex = -1;
29 | private Equalizer eq = null;
30 | private BassBoost bb = null;
31 | public static final int MAX_EQ_BANDS = 10;
32 | protected AudioDatabase audioDatabase = null;
33 | private Context context;
34 |
35 | /** Constructor. */
36 | public Player (Context context)
37 | {
38 | this.context = context;
39 | mediaPlayer.setOnCompletionListener (this);
40 | audioDatabase = new AudioDatabase();
41 | RemoteControlReceiver.setPlayer (this);
42 | audioDatabase.scan (context);
43 | try
44 | {
45 | // I'm told that these constructors can fail. If they do,
46 | // leave them as null. Other things will fail later, but
47 | // at least the service will start up, and some things
48 | // might still work
49 | eq = new Equalizer (0, mediaPlayer.getAudioSessionId());
50 | bb = new BassBoost (0, mediaPlayer.getAudioSessionId());
51 | }
52 | catch (Throwable e)
53 | {
54 | Log.w ("AMS", "Can't initialize effects: " + e.toString());
55 | }
56 | }
57 |
58 | public void stop()
59 | {
60 | mediaPlayer.reset();
61 | releaseAudioFocus();
62 | currentPlaybackUri = null;
63 | currentPlaybackTrackInfo = null;
64 | }
65 |
66 | @Override
67 | public void onAudioFocusChange (int focusChange)
68 | {
69 | }
70 |
71 |
72 | public void getAudioFocus()
73 | {
74 | AudioManager am = (AudioManager) context.getSystemService
75 | (Context.AUDIO_SERVICE);
76 | am.requestAudioFocus(this, AudioManager.STREAM_MUSIC,
77 | AudioManager.AUDIOFOCUS_GAIN);
78 | am.registerMediaButtonEventReceiver (new ComponentName
79 | (context.getPackageName(), RemoteControlReceiver.class.getName()));
80 | }
81 |
82 |
83 | public void releaseAudioFocus()
84 | {
85 | AudioManager am = (AudioManager) context.getSystemService
86 | (Context.AUDIO_SERVICE);
87 | am.abandonAudioFocus (this);
88 | am.unregisterMediaButtonEventReceiver (new ComponentName
89 | (context.getPackageName(), RemoteControlReceiver.class.getName()));
90 | }
91 |
92 |
93 | protected void playNextInPlaylist ()
94 | throws PlayerException
95 | {
96 | if (playlist.size() > 0 && currentPlaylistIndex < playlist.size() - 1)
97 | {
98 | movePlaylistIndexForward();
99 | playCurrentPlaylistItem();
100 | }
101 | }
102 |
103 |
104 | protected void playPrevInPlaylist ()
105 | throws PlayerException
106 | {
107 | if (playlist.size() > 0 && currentPlaylistIndex > 0)
108 | {
109 | movePlaylistIndexBack();
110 | playCurrentPlaylistItem();
111 | }
112 | }
113 |
114 |
115 | /** Invoked by the Android player when playback of an item is complete.
116 | Try to advance to the next playlist item. */
117 | @Override
118 | public void onCompletion (MediaPlayer mp)
119 | {
120 | currentPlaybackUri = null; // set current URI so it' s clear we
121 | // aren't just paused
122 | currentPlaybackTrackInfo = null;
123 | releaseAudioFocus();
124 | Log.w ("AMP", "Playback completed: " + currentPlaybackUri);
125 | if (playlist.size() > 0 && currentPlaylistIndex < playlist.size() - 1)
126 | {
127 | try
128 | {
129 | playNextInPlaylist();
130 | }
131 | catch (Exception e)
132 | {
133 | Log.w ("AMS", e.toString());
134 | }
135 | }
136 | }
137 |
138 |
139 | public void setEqEnabled (boolean enabled)
140 | {
141 | eq.setEnabled (enabled); //TOD null check
142 | }
143 |
144 |
145 | public boolean getEqEnabled()
146 | {
147 | return eq.getEnabled(); //TOD null check
148 | }
149 |
150 |
151 | public void setEqBandLevel (int band, int level)
152 | {
153 | eq.setBandLevel ((short)band, (short)level);
154 | }
155 |
156 |
157 | public void setBBEnabled (boolean enabled)
158 | {
159 | bb.setEnabled (enabled);
160 | }
161 |
162 |
163 | public boolean getBBEnabled ()
164 | {
165 | return bb.getEnabled ();
166 | }
167 |
168 |
169 | public void setBBStrength (int strength)
170 | {
171 | bb.setStrength ((short)strength);
172 | }
173 |
174 | /** Bass boost strength, 0-1000. */
175 | public int getBBStrength ()
176 | {
177 | int bbStrength = bb.getRoundedStrength();
178 | return bbStrength;
179 | }
180 |
181 |
182 | public String getEqBandFreqRange (int band)
183 | {
184 | int[] fRange = eq.getBandFreqRange ((short)band);
185 | String sFreqRange = AndroidEqUtil.formatBandLabel (fRange);
186 | return sFreqRange;
187 | }
188 |
189 |
190 | public int getEqBandLevel (int band)
191 | {
192 | int level = (int)eq.getBandLevel ((short)band);
193 | return level;
194 | }
195 |
196 |
197 | public int getEqMinLevel()
198 | {
199 | short r[] = eq.getBandLevelRange();
200 | int minLevel = r[0];
201 | return minLevel;
202 | }
203 |
204 |
205 | public int getEqMaxLevel()
206 | {
207 | short r[] = eq.getBandLevelRange();
208 | int maxLevel = r[1];
209 | return maxLevel;
210 | }
211 |
212 |
213 | public int getEqNumberOfBands()
214 | {
215 | int numBands = eq.getNumberOfBands ();
216 | return numBands;
217 | }
218 |
219 |
220 | public void pause ()
221 | {
222 | mediaPlayer.pause();
223 | }
224 |
225 |
226 | public void clearPlaylist ()
227 | {
228 | mediaPlayer.reset();
229 | releaseAudioFocus();
230 | playlist = new Vector();
231 | currentPlaybackUri = null;
232 | currentPlaybackTrackInfo = null;
233 | }
234 |
235 |
236 | public void shufflePlaylist()
237 | {
238 | Collections.shuffle (playlist);
239 | }
240 |
241 |
242 | public void movePlaylistIndexBack()
243 | throws PlayerException
244 | {
245 | if (playlist.size() == 0)
246 | throw new PlaylistEmptyException();
247 |
248 | if (currentPlaylistIndex <= 0)
249 | throw new AlreadyAtStartOfPlaylistException();
250 |
251 | currentPlaylistIndex--;
252 | }
253 |
254 |
255 | public void movePlaylistIndexForward()
256 | throws PlayerException
257 | {
258 | if (playlist.size() == 0)
259 | throw new PlaylistEmptyException();
260 |
261 | if (currentPlaylistIndex >= playlist.size() - 1)
262 | throw new AlreadyAtEndOfPlaylistException();
263 |
264 | currentPlaylistIndex++;
265 | }
266 |
267 |
268 | public void playCurrentPlaylistItem ()
269 | throws PlayerException
270 | {
271 | if (playlist.size() == 0)
272 | throw new PlaylistEmptyException();
273 | if (currentPlaylistIndex < 0)
274 | currentPlaylistIndex = 0;
275 | playInPlaylist (currentPlaylistIndex);
276 | }
277 |
278 |
279 | public void playInPlaylist (int index)
280 | throws PlayerException
281 | {
282 | if (playlist.size() == 0)
283 | throw new PlaylistEmptyException();
284 |
285 | if (index < 0 || index >= playlist.size())
286 | throw new PlaylistIndexOutOfRangeException();
287 |
288 | String uri = playlist.get (index).uri;
289 | currentPlaylistIndex = index;
290 | playFileNow (uri);
291 | }
292 |
293 |
294 | public int playAlbumNow (String album)
295 | throws PlayerException
296 | {
297 | List albumURIs = audioDatabase.getAlbumURIs (context, album);
298 | int count = 0;
299 | clearPlaylist();
300 | for (String uri : albumURIs)
301 | {
302 | TrackInfo ti = audioDatabase.getTrackInfo (context, uri);
303 | playlist.add (ti);
304 | count++;
305 | }
306 |
307 | playFromStartOfPlaylist();
308 | return count;
309 | }
310 |
311 |
312 | /* TODO: handle non-existent album */
313 | public int addAlbumToPlaylist (String album)
314 | throws PlayerException
315 | {
316 | List albumURIs = audioDatabase.getAlbumURIs (context, album);
317 | int count = 0;
318 | for (String uri : albumURIs)
319 | {
320 | TrackInfo ti = audioDatabase.getTrackInfo (context, uri);
321 | playlist.add (ti);
322 | count++;
323 | }
324 | return count;
325 | }
326 |
327 |
328 | public void play ()
329 | throws PlayerException
330 | {
331 | if (currentPlaybackUri != null)
332 | {
333 | // We are paused (or even plaing), not stopped
334 | getAudioFocus();
335 | mediaPlayer.start();
336 | }
337 | else
338 | playFromStartOfPlaylist ();
339 | }
340 |
341 |
342 | public int getCurrentPlaybackPositionMsec()
343 | {
344 | int position = mediaPlayer.getCurrentPosition();
345 | return position;
346 | }
347 |
348 |
349 | public int getCurrentPlaybackDurationMsec()
350 | {
351 | int duration = mediaPlayer.getDuration();
352 | return duration;
353 | }
354 |
355 |
356 | /** May be a file or a content: URI. Will be null if nothing is playing. */
357 | public String getCurrentPlaybackUri ()
358 | {
359 | return currentPlaybackUri;
360 | }
361 |
362 | /** Note that "paused" is not playing. */
363 | public boolean isPlaying ()
364 | {
365 | return mediaPlayer.isPlaying();
366 | }
367 |
368 |
369 | /** May return null if nothing is playing, or a value with meaningless
370 | contents -- check playback status as well. */
371 | public TrackInfo getCurrentPlaybackTrackInfo ()
372 | {
373 | return currentPlaybackTrackInfo;
374 | }
375 |
376 | public List getPlaylist()
377 | {
378 | return playlist;
379 | }
380 |
381 | /**
382 | Adds the specified filesystem item, which might be a directory,
383 | to the playlist, and return the number of items added.
384 | */
385 | public int addFileOrDirectoryToPlaylist (String path)
386 | throws PlayerException
387 | {
388 | File f = new File (path);
389 | if (f.isDirectory ())
390 | {
391 | String[] list = f.list();
392 | int count = 0;
393 | for (String name : list)
394 | {
395 | String cand = path + "/" + name;
396 | File f2 = new File (cand);
397 | if (isPlayableFile (f2.toString()))
398 | {
399 | TrackInfo ti = audioDatabase.getTrackInfo (context, cand);
400 | playlist.add (ti);
401 | count++;
402 | }
403 | }
404 | return count;
405 | }
406 | else
407 | {
408 | TrackInfo ti = audioDatabase.getTrackInfo (context, path);
409 | playlist.add (ti);
410 | return 1;
411 | }
412 | }
413 |
414 |
415 | public void playFromStartOfPlaylist ()
416 | throws PlayerException
417 | {
418 | playInPlaylist (0);
419 | }
420 |
421 | /**
422 | Start playback of the file, and set the currentUri.
423 | */
424 | public void playFileNow (String uri)
425 | throws PlayerException
426 | {
427 | mediaPlayer.reset();
428 | try
429 | {
430 | if (uri.startsWith ("content:"))
431 | {
432 | android.net.Uri contentUri = android.net.Uri.parse (uri);
433 | mediaPlayer.setDataSource (context, contentUri);
434 | }
435 | else
436 | {
437 | String filename = uri;
438 | mediaPlayer.setDataSource (filename);
439 | }
440 | mediaPlayer.prepare();
441 | getAudioFocus();
442 | currentPlaybackUri = uri;
443 | currentPlaybackTrackInfo = audioDatabase.getTrackInfo (context, uri);
444 | mediaPlayer.start();
445 | }
446 | catch (IOException e)
447 | {
448 | mediaPlayer.reset();
449 | currentPlaybackUri = null;
450 | throw new PlayerIOException (e.toString());
451 | }
452 | }
453 |
454 |
455 | public void scanAudioDatabase ()
456 | {
457 | audioDatabase.scan (context);
458 | }
459 |
460 | public Set getAlbums()
461 | {
462 | return audioDatabase.getAlbums();
463 | }
464 |
465 |
466 | public Set getArtists()
467 | {
468 | return audioDatabase.getArtists();
469 | }
470 |
471 |
472 | public Set getGenres()
473 | {
474 | return audioDatabase.getGenres();
475 | }
476 |
477 |
478 | public Set getComposers()
479 | {
480 | return audioDatabase.getComposers();
481 | }
482 |
483 |
484 | public byte[] getEmbeddedPictureForTrackUri (String uri)
485 | {
486 | return audioDatabase.getEmbeddedPicture (context, uri);
487 | }
488 |
489 |
490 | public String getFilePathFromContentUri (android.net.Uri uri)
491 | {
492 | return audioDatabase.getFilePathFromContentUri (context, uri);
493 | }
494 |
495 |
496 | public List getAlbumTrackUris (String album)
497 | {
498 | return audioDatabase.getAlbumURIs (context, album);
499 | }
500 |
501 |
502 | public Set getAlbumsByArtist (String artist)
503 | {
504 | return audioDatabase.getAlbumsByArtist (context, artist);
505 | }
506 |
507 |
508 | public Set getAlbumsByComposer (String artist)
509 | {
510 | return audioDatabase.getAlbumsByComposer (context, artist);
511 | }
512 |
513 |
514 | public Set getAlbumsByGenre (String genre)
515 | {
516 | return audioDatabase.getAlbumsByGenre (context, genre);
517 | }
518 |
519 |
520 | public TrackInfo getTrackInfo (String uri)
521 | {
522 | return audioDatabase.getTrackInfo (context, uri);
523 | }
524 |
525 |
526 | /** Returns true if the filename suggests mp3, aac, etc. */
527 | static public boolean isPlayableFile (String name)
528 | {
529 | int p = name.lastIndexOf ('.');
530 | if (p <= 0) return false;
531 | String ext = name.substring (p);
532 | if (".mp3".equalsIgnoreCase (ext)) return true;
533 | if (".m4a".equalsIgnoreCase (ext)) return true;
534 | if (".aac".equalsIgnoreCase (ext)) return true;
535 | if (".ogg".equalsIgnoreCase (ext)) return true;
536 | if (".wma".equalsIgnoreCase (ext)) return true;
537 | if (".flac".equalsIgnoreCase (ext)) return true;
538 | return false;
539 | }
540 |
541 | public Set findTracks (SearchSpec search, int start, int num)
542 | {
543 | return audioDatabase.findTracks (context, search, start, num);
544 | }
545 |
546 |
547 | public int getApproxNumTracks ()
548 | {
549 | return audioDatabase.getApproxNumTracks();
550 | }
551 |
552 |
553 | public Set getMatchingAlbums (SearchSpec ss, int max)
554 | {
555 | return audioDatabase.getMatchingAlbums (ss, max);
556 | }
557 |
558 |
559 | public Set getMatchingArtists (SearchSpec ss, int max)
560 | {
561 | return audioDatabase.getMatchingArtists (ss, max);
562 | }
563 |
564 | public Set getMatchingComposers (SearchSpec ss, int max)
565 | {
566 | return audioDatabase.getMatchingComposers (ss, max);
567 | }
568 |
569 | }
570 |
571 |
572 |
573 |
--------------------------------------------------------------------------------
/app/src/main/java/net/kevinboone/androidmusicplayer/AudioDatabase.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Kevin's Music Server for Android
3 | * Copyright (c)2015-2021
4 | * Distributed under the terms of the GNU Public Licence, version 2.0
5 | */
6 |
7 | package net.kevinboone.androidmusicplayer;
8 | import java.util.*;
9 | import java.io.*;
10 | import java.net.*;
11 | import android.content.*;
12 | import android.media.MediaPlayer;
13 | import android.util.Log;
14 | import android.media.*;
15 | import android.content.*;
16 | import android.database.*;
17 | import android.net.Uri;
18 | import android.provider.MediaStore;
19 | import net.kevinboone.textutils.*;
20 |
21 | /** This class integrates the music server with the Android built-in
22 | media scanner. */
23 | public class AudioDatabase
24 | {
25 | private final String GENRE_ID = MediaStore.Audio.Genres._ID;
26 | private final String GENRE_NAME = MediaStore.Audio.Genres.NAME;
27 | private final String AUDIO_ID = MediaStore.Audio.Media._ID;
28 |
29 | protected TreeSet albums = new TreeSet();
30 | protected TreeSet artists = new TreeSet();
31 | protected TreeSet composers = new TreeSet();
32 | protected TreeSet genres = new TreeSet();
33 | MediaMetadataRetriever mmr = new MediaMetadataRetriever();
34 |
35 | protected int approxNumTracks = 0;
36 |
37 | /**
38 | Scan for albums, etc., in the Android media database. Note that this
39 | method does not cause Android to rescan its filesystem.
40 | */
41 | public void scan(Context context)
42 | {
43 | Log.w ("AMS", "Starting media database scan");
44 | albums = new TreeSet(); // Clear any old entries
45 | artists = new TreeSet(); // Clear any old entries
46 | composers = new TreeSet(); // Clear any old entries
47 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
48 | approxNumTracks = 0;
49 | Cursor cur = context.getContentResolver().query(uri, null,
50 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
51 | if (cur.moveToFirst())
52 | {
53 | int artistColumn = cur.getColumnIndex(MediaStore.Audio.Media.ARTIST);
54 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
55 | int composerColumn = cur.getColumnIndex(MediaStore.Audio.Media.COMPOSER);
56 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
57 |
58 | do
59 | {
60 | long id = cur.getLong (idColumn);
61 | Uri extUri = ContentUris.withAppendedId
62 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
63 | String album = cur.getString (albumColumn);
64 | if (album != null && album.length() > 0)
65 | albums.add (album);
66 | String composer = cur.getString (composerColumn);
67 | if (composer != null && composer.length() > 0)
68 | composers.add (composer);
69 | String artist = cur.getString (artistColumn);
70 | if (artist != null && artist.length() > 0)
71 | artists.add (artist);
72 | approxNumTracks++;
73 | } while (cur.moveToNext());
74 | cur.close();
75 |
76 | Log.d ("AMS", "Enumerating genres");
77 | cur = context.getContentResolver().query (
78 | MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
79 | new String[] { MediaStore.Audio.Genres._ID,
80 | MediaStore.Audio.Genres.NAME}, null, null, null);
81 | for (cur.moveToFirst(); !cur.isAfterLast(); cur.moveToNext())
82 | {
83 | String genreID = cur.getString(0);
84 | Log.d ("AMS", "genre ID is " + genreID);
85 | if (genreID != null)
86 | {
87 | String genreName = cur.getString(1);
88 | if (genreHasTracks(context, genreID))
89 | genres.add (genreName);
90 | }
91 | }
92 | cur.close();
93 | }
94 | else
95 | Log.w ("AMS", "Media database scan produced no results");
96 | Log.w ("AMS", "Done media database scan");
97 | }
98 |
99 |
100 | /**
101 | Try to determine whether the specified genre ID is associated with
102 | any tracks. This is to prevent including empty genres in the list.
103 | Notethat this method takes a genre ID, not a genre name, and so is
104 | probably not much use except as a helper to the scan() method
105 | */
106 | public boolean genreHasTracks (Context context, String genreID)
107 | {
108 | Uri uri = MediaStore.Audio.Genres.Members.getContentUri
109 | ("external", Long.valueOf(genreID));
110 |
111 | String[] projection = new String[]{MediaStore.Audio.Media.TITLE,
112 | MediaStore.Audio.Media._ID};
113 |
114 | Cursor cur = context.getContentResolver().query(uri, projection,
115 | null, null, null);
116 |
117 | boolean ret;
118 |
119 | if (cur.moveToFirst())
120 | ret = true;
121 | else
122 | ret = false;
123 |
124 | cur.close();
125 |
126 | return ret;
127 | }
128 |
129 |
130 | public Set getAlbumsByArtist (Context context, String artist)
131 | {
132 | Set results = new TreeSet();
133 |
134 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
135 | Cursor cur = context.getContentResolver().query(uri, null,
136 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
137 | if (cur.moveToFirst())
138 | {
139 | int artistColumn = cur.getColumnIndex(MediaStore.Audio.Media.ARTIST);
140 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
141 |
142 | do
143 | {
144 | String candArtist = cur.getString (artistColumn);
145 | if (candArtist != null && candArtist.length() > 0
146 | && candArtist.equalsIgnoreCase (artist))
147 | {
148 | String album = cur.getString (albumColumn);
149 | if (album != null && album.length() > 0)
150 | results.add (album);
151 | }
152 | } while (cur.moveToNext());
153 | }
154 | cur.close();
155 |
156 | return results;
157 | }
158 |
159 |
160 | public Set getAlbumsByComposer (Context context, String composer)
161 | {
162 | Set results = new TreeSet();
163 |
164 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
165 | Cursor cur = context.getContentResolver().query(uri, null,
166 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null, null);
167 | if (cur.moveToFirst())
168 | {
169 | int composerColumn = cur.getColumnIndex(MediaStore.Audio.Media.COMPOSER);
170 | int albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM);
171 |
172 | do
173 | {
174 | String candComposer = cur.getString (composerColumn);
175 | if (candComposer != null && candComposer.length() > 0
176 | && candComposer.equalsIgnoreCase (composer))
177 | {
178 | String album = cur.getString (albumColumn);
179 | if (album != null && album.length() > 0)
180 | results.add (album);
181 | }
182 | } while (cur.moveToNext());
183 | }
184 | cur.close();
185 |
186 | return results;
187 | }
188 |
189 |
190 | public Set getAlbumsByGenre (Context context, String genre)
191 | {
192 | Set results = new TreeSet();
193 | for (String album : albums)
194 | {
195 | List trackUris = getAlbumURIs (context, album);
196 | for (String trackUri : trackUris)
197 | {
198 | TrackInfo ti = getTrackInfo (context, trackUri, true);
199 | if (genre.equals (ti.genre))
200 | {
201 | results.add (album);
202 | break;
203 | }
204 | // Because this is so slow, only check the first track of each
205 | // album for genre
206 | break;
207 | }
208 | }
209 |
210 | return results;
211 | }
212 |
213 | public Set getAlbums()
214 | {
215 | return albums;
216 | }
217 |
218 | public Set getArtists()
219 | {
220 | return artists;
221 | }
222 |
223 | public Set getComposers()
224 | {
225 | return composers;
226 | }
227 |
228 | public Set getGenres()
229 | {
230 | return genres;
231 | }
232 |
233 |
234 | /** Get a list of content URIs (of the form content:...) for
235 | the specified album */
236 | public List getAlbumURIs (Context context, String album)
237 | {
238 | Vector list = new Vector();
239 |
240 | String escAlbum = EscapeUtils.escapeSQL (album);
241 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
242 | Cursor cur = context.getContentResolver().query(uri, null,
243 | MediaStore.Audio.Media.IS_MUSIC + " = 1 and "
244 | + MediaStore.Audio.Media.ALBUM + "= '" + escAlbum + "'" , null,
245 | MediaStore.Audio.Media.TRACK + "," + MediaStore.Audio.Media.TITLE);
246 | if (cur.moveToFirst())
247 | {
248 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
249 |
250 | do
251 | {
252 | Long id = cur.getLong (idColumn);
253 | Uri extUri = ContentUris.withAppendedId
254 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
255 | list.add (extUri.toString());
256 | } while (cur.moveToNext());
257 | cur.close();
258 | }
259 | return list;
260 | }
261 |
262 | /** Try to get track info from the uri, which may be a simple filename,
263 | or a content: uri. If it fails, return a TrackInfo with only the
264 | URI and title (which is made up) set. THis method does _not_
265 | retrieve genre information, which is very slow. */
266 | TrackInfo getTrackInfo (Context context, String uri)
267 | {
268 | return getTrackInfo (context, uri, false);
269 | }
270 |
271 | /** Try to get track info from the uri, which may be a simple filename,
272 | or a content: uri. If it fails, return a TrackInfo with only the
273 | URI and title (which is made up) set */
274 | TrackInfo getTrackInfo (Context context, String uri, boolean includeGenre)
275 | {
276 | try
277 | {
278 | if (uri.startsWith ("content:"))
279 | {
280 | android.net.Uri contentUri = android.net.Uri.parse (uri);
281 | mmr.setDataSource (context, contentUri);
282 | }
283 | else
284 | {
285 | String filename = uri;
286 | mmr.setDataSource (filename);
287 | }
288 |
289 | TrackInfo ti = new TrackInfo(uri);
290 | ti.title = mmr.extractMetadata (mmr.METADATA_KEY_TITLE);
291 | ti.artist = mmr.extractMetadata (mmr.METADATA_KEY_ARTIST);
292 | ti.composer = mmr.extractMetadata (mmr.METADATA_KEY_COMPOSER);
293 | ti.album = mmr.extractMetadata (mmr.METADATA_KEY_ALBUM);
294 | ti.trackNumber = mmr.extractMetadata (mmr.METADATA_KEY_CD_TRACK_NUMBER);
295 | if (includeGenre)
296 | ti.genre = mmr.extractMetadata (mmr.METADATA_KEY_GENRE);
297 | else
298 | ti.genre = "";
299 | if (ti.trackNumber == null || ti.trackNumber == "")
300 | ti.trackNumber = "1";
301 | if (ti.title == null) ti.title = "?";
302 | if (ti.artist == null) ti.artist = "?";
303 | if (ti.composer == null) ti.composer = "?";
304 | if (ti.album == null) ti.album = "?";
305 | return ti;
306 | }
307 | catch (Throwable e)
308 | {
309 | Log.w ("AMS", "Error fetching media metadata: " + e.toString());
310 | TrackInfo ti = new TrackInfo(uri);
311 | ti.title = TrackInfo.makeTitleFromUri (uri);
312 | return ti;
313 | }
314 | }
315 |
316 | /**
317 | Try to get the embedded picture for an item, if there is one.
318 | If not, or in the event of error, return null. For some reason,
319 | calls on the metadata extractor are not thread safe, so this method
320 | has to be synchronized :/
321 | */
322 | synchronized byte[] getEmbeddedPicture (Context context, String uri)
323 | {
324 | try
325 | {
326 | if (uri.startsWith ("content:"))
327 | {
328 | android.net.Uri contentUri = android.net.Uri.parse (uri);
329 | mmr.setDataSource (context, contentUri);
330 | }
331 | else
332 | {
333 | String filename = uri;
334 | mmr.setDataSource (filename);
335 | }
336 |
337 | byte[] ep = mmr.getEmbeddedPicture ();
338 | return ep;
339 | }
340 | catch (Throwable e)
341 | {
342 | Log.w ("AMS", "Error fetching embdedded picture: " + e.toString());
343 | return null;
344 | }
345 | }
346 |
347 |
348 | public String getFilePathFromContentUri (Context context, Uri uri)
349 | {
350 | String filePath;
351 | String[] filePathColumn = {android.provider.MediaStore.MediaColumns.DATA};
352 |
353 | Cursor cursor = context.getContentResolver().query (uri, filePathColumn,
354 | null, null, null);
355 | cursor.moveToFirst();
356 |
357 | int columnIndex = cursor.getColumnIndex (filePathColumn[0]);
358 | filePath = cursor.getString(columnIndex);
359 | cursor.close();
360 | return filePath;
361 | }
362 |
363 |
364 | public Set findTracks (Context context, SearchSpec search,
365 | int start, int num)
366 | {
367 | Set results = new TreeSet();
368 | Uri uri = android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
369 | Cursor cur = context.getContentResolver().query(uri, null,
370 | MediaStore.Audio.Media.IS_MUSIC + " = 1", null,
371 | MediaStore.Audio.Media.TITLE + "," + MediaStore.Audio.Media.TRACK);
372 | int titleColumn = cur.getColumnIndex(MediaStore.Audio.Media.TITLE);
373 | int idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID);
374 | int count = 0;
375 |
376 | if (cur.moveToFirst())
377 | {
378 | do
379 | {
380 | long id = cur.getLong (idColumn);
381 | Uri extUri = ContentUris.withAppendedId
382 | (android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id);
383 | String title = cur.getString (titleColumn);
384 | if (search == null)
385 | {
386 | if (count >= start)
387 | {
388 | results.add (extUri.toString());
389 | count++;
390 | }
391 | }
392 | else
393 | {
394 | if (title != null && title.length() > 0)
395 | {
396 | if (title.toLowerCase(Locale.getDefault()).indexOf
397 | (search.getText().toLowerCase(Locale.getDefault())) >= 0)
398 | {
399 | if (count >= start)
400 | results.add (extUri.toString());
401 | }
402 | }
403 | }
404 | count++;
405 | } while (cur.moveToNext() && (num < 0 || results.size() <= num));
406 |
407 | cur.close();
408 | }
409 | return results;
410 | }
411 |
412 |
413 | public int getApproxNumTracks ()
414 | {
415 | return approxNumTracks;
416 | }
417 |
418 |
419 | public Set getMatchingAlbums (SearchSpec ss, int max)
420 | {
421 | Set results = new TreeSet();
422 |
423 | String text = ss.getText().toLowerCase(Locale.getDefault());
424 | for (String album : albums)
425 | {
426 | if (album.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
427 | {
428 | results.add (album);
429 | }
430 | if (results.size() >= max) break;
431 | }
432 |
433 | return results;
434 | }
435 |
436 |
437 | public Set getMatchingArtists (SearchSpec ss, int max)
438 | {
439 | Set results = new TreeSet();
440 |
441 | String text = ss.getText().toLowerCase(Locale.getDefault());
442 | for (String artist : artists)
443 | {
444 | if (artist.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
445 | {
446 | results.add (artist);
447 | }
448 | if (results.size() >= max) break;
449 | }
450 |
451 | return results;
452 | }
453 |
454 |
455 | public Set getMatchingComposers (SearchSpec ss, int max)
456 | {
457 | Set results = new TreeSet();
458 |
459 | String text = ss.getText().toLowerCase(Locale.getDefault());
460 | for (String composer : composers)
461 | {
462 | if (composer.toLowerCase(Locale.getDefault()).indexOf (text) >= 0)
463 | {
464 | results.add (composer);
465 | }
466 | if (results.size() >= max) break;
467 | }
468 |
469 | return results;
470 | }
471 |
472 | }
473 |
474 |
475 |
476 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # androidmusicserver
2 |
3 | Version 0.0.9, January 2023
4 |
5 | A web interface to the Android audio player -- control your media
6 | playback using a browser.
7 |
8 | ## Warning -- old, old code
9 |
10 | Please note that this app has been largely unchanged since 2015.
11 | I've made only the minimum necessary changes to keep it working
12 | on the Android devices I own. The most recent device I've tested
13 | is the Samsung Galaxy s10.
14 |
15 | The app has many problems. The genre support that I mentioned as being
16 | problematic back in 2015 remains a problem -- it's shockingly slow
17 | (minutes, with more than a hundred or so albums). This is a crude,
18 | unsatisfactory app, and I only continue to
19 | maintain it because I can't find anything else that does
20 | the same thing. If anybody knows of a superior alternative -- ideally
21 | open-source -- please tell me, so I can let this project quietly
22 | fade away.
23 |
24 | Please bear in mind that the latest Android version that this app can
25 | currently target is 4.4 (API level 19). While it does seem to work
26 | on later devices, this API level is too early for the app to be accepted
27 | by any app store that I know of, even if I wanted to publish it that
28 | way. Although the app builds with the SDK for API level 31, it fails
29 | strangely on many devices.
30 |
31 | As I said, this is very old code, that really ought to be allowed to
32 | rest in peace.
33 |
34 | ## What is this?
35 |
36 | Android Music Server provides a web browser interface to
37 | control playback of audio files stored on most modern (4.x-11.0)
38 | Android devices.
39 | This allows the Android device
40 | to be used as a music server, in multi-room audio applications, among
41 | other things. I normally keep my Android phone docked, with a permanent
42 | USB connection to an audio DAC. This arrangement produces good quality
43 | audio playback, but I don't always have the pone within reach. It's
44 | awkward to fiddle with the little screen when it's docked, anyway.
45 | Providing a web interface -- albeit a crude one -- allows me to
46 | control playback from a web browser.
47 |
48 | Audio tracks can be selected using the browser from a list of albums, or
49 | directly from the filesystem (but see notes below).
50 | You can restrict the album listing to particular
51 | genres or particular artists rather than displaying all
52 | albums on the same page. Album cover
53 | art images can be displayed (but see notes below about this, too).
54 |
55 | The browser user interface looks like this:
56 |
57 | 
58 |
59 | While the app itself (which will probably never be used, apart from
60 | starting and stopping it) looks like this:
61 |
62 | 
63 |
64 | Android Music Server uses no Android feature introduced since about 2015, so
65 | it stands a chance of working on any relatively modern device. The most
66 | recent reported to work is the Samsung S10, but there's no particular
67 | reason why more recent versions won't work.
68 |
69 | Android Music Server is open-source, free of charge, and has no advertisements.
70 | It's easy to build from source if you have the Android SDK available.
71 |
72 |
73 | ## Features
74 | - Simple web interface -- works on most desktop web browsers and many mobile browsers
75 | - Integrates with the Android media catalogue -- browse by album, artist, genre, composer, or track
76 | - Supports file/folder browsing (if the Android version does)
77 | - Media catalogue text search
78 | - Equalizer
79 | - Cover art (both baked-in and album-folder images)
80 | - Playback control by headset or remote control
81 |
82 | ## Installation
83 |
84 | Android Music Server will never be available from any any app store, because I
85 | can't afford to pay money to distribute stuff free-of-charge. Sorry.
86 |
87 | To install, download the APK package from the Downloads section at the
88 | end of this page, copy it to your Android
89 | device, and install it using any file manager. Or simply download the APK
90 | directly from this page using your Android device's Web browser.
91 | You may have to tell
92 | your device to allow apps from unknown suppliers. If you're worried that this app might
93 | transmit all your secret passwords to villains, you're welcome to inspect
94 | and build the application yourself.
95 |
96 | ## Building
97 |
98 | This version of Android Music Sever is designed be built using gradle,
99 | the gradle Android plugin, and the Android SDK for API level 19. With these
100 | things all in place, you should be able to build using:
101 |
102 | $ ./gradlew build
103 |
104 | You may need to create a `local.properties` file indicating the location
105 | of the Android SDK files:
106 |
107 | sdk.dir=/home/foo/Android/Sdk
108 |
109 | A successful build will produce APK files in `app/build/outputs/apk`.
110 |
111 | ## Permissions
112 |
113 | Android Music Server will request the "Read SD card" permission. In Android
114 | terminology, "SD card" covers both internal and plug-in storage.
115 |
116 | ## Operation
117 |
118 | Android Music Server is designed to run quietly in the background, so it
119 | has no complicated Android user interface. When you start the app,
120 | if all is well you'll see the URL to which you should point your web browser.
121 | The Web server listens on port 30000 by default, but this can be
122 | changed from the settings page.
123 | If there are problems, which will generally be network-related, you'll
124 | see an error message. The app displays some information about the audio
125 | track that is currently playing, if there is one, and provides some
126 | buttons to control playback, in a rudimentary way. The "Shutdown" button
127 | shuts the application down completely, including its background
128 | service.
129 |
130 | All real operation of the app is from a Web browser. I hope that the
131 | browser interface is relatively self-explanatory -- just select an
132 | album from one of the various lists and click "Play now", or "Add"
133 | to append the
134 | tracks to the playlist. Alternatively, click the "Files" link at
135 | the bottom of the page and navigate
136 | the filesystem to find some audio files. Or just click "Random" to
137 | play a randomly-selected album.
138 |
139 | You can operate Android Music Server using a web browser on the device
140 | itself (if the browser has adequate JavaScript support -- most now do);
141 | but the app
142 | is really intended to be operated from a browser on a different machine.
143 | It is intended for remote control of music playback; there are many
144 | good media players for on-device operation. In any event, the
145 | browser user interface may not display very well on a small screen.
146 |
147 | Android Music Server responds to remote control events -- from
148 | a bluetooth headset with control buttons, for example. In particular,
149 | it responds to play, pause, step, next track, and previous track events.
150 | Of course, for next track and previous track to work, there must be
151 | something in the playlist.
152 |
153 | ## Usage notes
154 |
155 | This app is intended to work with relatively modest
156 | collections of audio files, that are relatively tidily organized.
157 | All lists (of albums, artists, etc) are displayed on a single,
158 | possibly long page. In practice it seems to work reasonably well
159 | with collections of a few hundred albums, but the user interface
160 | will struggle with thousands of albums, particularly if
161 | you choose to display cover art. The capacity of the
162 | app in this regard really depends on the CPU speed and memory
163 | of the Android device. However, my experience is that even
164 | really fast, modern devices like the Samsung S10 don't devote a lot
165 | of resource to servicing remote clients.
166 |
167 | In general Android Music Server assumes
168 | (as Android generally does) that audio tracks are organized into
169 | albums, and that at least the album, title, and artist tags are
170 | filled in. To play an album in the right order it also assumes that
171 | the track number tag is filled in, or that the titles when arranged
172 | into alphabetical order will give the same ordering as the original
173 | album. Everything about this application will work somewhat better
174 | if files are thoroughly and consistently tagged -- but that's true
175 | of most music players. I'm told that "free music downloaders"
176 | (bootlegging utilities, in other words) do not fill in tags properly,
177 | and you can end up with two thousand tracks in the same album, all
178 | called "null". Still, if you sup with the Devil, as the saying goes,
179 | you're advised to use a long spoon.
180 |
181 | The web interface is completely stateless; that is, everything it
182 | needs to know is captured in the URL supplied by the browser. So
183 | you can
184 | freely bookmark albums, or artists, or filesystem locations for
185 | quick reference.
186 |
187 | Sadly, filesystem browsing won't work well with Android releases after
188 | 6.x or thereabouts -- the ability to read anything other than very
189 | specific locations has been removed. Worse, there's no
190 | reasonably-straightforward, reliable,
191 | robust, way to determine which filesystem locations might contain
192 | audio, and be readable. In my darker moments, I think that Google
193 | is deliberately trying to find new ways to break my apps. In any
194 | case, there's no point complaining to me about this -- go hassle
195 | Google, for what good that will do. In any case, the main page
196 | presents some fileststem locations that _might_ be readable but,
197 | then again, might not. You'll notice that, when requesting a
198 | file listing, the URL issued by the browser contains the attribute
199 | `path=/xxx`. If you happen to know which directory contains audio
200 | files, and can be read, you can edit (and, presumably, bookmark)
201 | the path manually. For example, if your device supports plug-in
202 | SD cards, the root of the SD card is probably something like
203 | `/storage/XXX-XXXX`, and you might be able to find the value of
204 | 'XXXX-XXXX' using a file manager.
205 |
206 | When browsing the filesystem (on devices that support this), you
207 | can add files one at a time to the playlist,
208 | or add the directory that
209 | contains them. The app will filter out playable audio files from other
210 | types, so it should be OK to click "Add" on a directory with mixed content.
211 | Note that the Add function in a directory only searches that specific
212 | directory -- it won't descend into subdirectories.
213 |
214 | If you connect a Bluetooth audio device (e.g., headset) whilst this app is
215 | playing (through speaker or wired headphones), then audio should automatically
216 | be diverted to the headset. However, you might need to stop the app,
217 | or at least stop playback, to route audio back to the speaker. This slightly
218 | odd behaviour is, so far as I know, not a feature of this program -- other
219 | Android media players behave the same way.
220 |
221 | The browser interface updates every five seconds, so don't expect
222 | mouse-clicks to be reflected immediately in the browser (although,
223 | of course, they should have immediate effect on the audio). This
224 | five-second update time is to reduce load on the Android device.
225 |
226 | If you click "Play now" on a track whilst an item in the playlist is being
227 | played, then playback will resume at the next playlist item when playback
228 | of the selected track finishes, if there is anything left to play in
229 | the playlist.
230 |
231 | You can control the volume of playback by clicking on the loudspeaker
232 | buttons in the menu bar at the top of each page, or by going to the
233 | "Equalizer, etc" link from the home page, and tweaking the volume
234 | slider.
235 | If you're using a headset, it might
236 | have its own volume controls; if it does, it probably sets the
237 | volume on the headset itself, not on the device. So, in that case,
238 | to get full volume you probably need to turn up the volume both on
239 | the headset and in this application.
240 |
241 | Not a feature of this app, but it's helpful to know that some folders that
242 | contain media can be removed from the oversight of the Android media scanner
243 | by creating an empty file called `.nomedia` in those directories.
244 | This can be useful for preventing ringtones and the like from appearing
245 | in the album list; but bear in mind that this trick affects all apps
246 | that use the media scanner.
247 |
248 | ## Cover art
249 |
250 | If you choose to browse albums including covers, then the
251 | app will attempt to find some cover art to display. The places it
252 | looks are as follows.
253 |
254 | First, the app will ask the Android media catalogue if any track in
255 | the specified album has an embedded image. If it does, then the first
256 | track that can provide an image does so. In practice, the Android media
257 | catalogue seems to be limited to returning "baked in" images, that is,
258 | images that are part of an ID3 tag or similar.
259 |
260 | Second, the app will look in the directory that contains the track file, for
261 | an image file that looks like it might contain cover art. At present, it
262 | considers files names `folder` or `cover`, perhaps
263 | with a leading "." (hidden files), and with extensions `.jpg` or `.png`.
264 | These names are in lower-case only. Naturally,
265 | this process will only produce good results if folders contain only tracks
266 | from the same album.
267 |
268 | The cover art extraction process is
269 | subject to a number of limitations.
270 |
271 | First, baked-in cover-art images can be quite large -- perhaps even photo-sized.
272 | Returning all the images on a page containing, say, a list of two hundred
273 | albums is a challenge for an Android device. If the browser is also on a
274 | mobile device, then the difficulty is compounded. The music server therefore
275 | attempts to avoid sending images if it can avoid doing so. It sets
276 | an `Expires` header one hour in the future for all images,
277 | and sets a
278 | `Last-Modified` header on all images based on the time the
279 | app starts up. In
280 | principle, therefore, the browser should not request images very
281 | frequently. But...
282 |
283 | Second, mobile browsers in particular are often stupid when it comes to
284 | handling date headers. Many ignore them completely, and just blindly
285 | request all images in every page. Apart from choosing to browse without
286 | covers, there's little that can be done to avoid this problem, if you
287 | have a stupid browser.
288 |
289 | Moreover, we don't know the actual last-modified
290 | time of a baked-in cover image, because it isn't stored. The
291 | music server uses its start-up time as the modification baseline, lacking
292 | any better information. What this means is that if you restart the app
293 | whilst the browser still has images in its cache, the browser will get
294 | confused: because the image has a last-modified date in the future, but
295 | in the browser's cache it has not yet expired.
296 | Clearing the browser cache usually fixes this
297 | problem.
298 |
299 | ## Genre support
300 |
301 | Android Music Server provides a list of genres, to make it easy
302 | to restrict the listing of
303 | albums to specific genres. Needless to say, for this to work the audio
304 | files must have valid genre tags. It doesn't really matter what they
305 | actually are, but they must at least be meaningful to the user.
306 |
307 | Querying the Android media catalogue for genre information is
308 | _excruciatingly_
309 | slow. I believe that there is some problem with the internal search
310 | implementation,
311 | which seems to require the whole genre catalogue to be expanded into tracks
312 | and then a query run against each track. Whatever the reason, with large
313 | numbers of tracks (more than a few hundred) some short-cuts have to be
314 | taken. The app therefore assumes that each track in an album has
315 | the same genre and, when searching which albums match a genre, only the
316 | first track is checked. Of course, it's not all that unusual for
317 | different tracks to have different genres in the same album, but testing
318 | them all is simply unfeasibly slow in Android.
319 |
320 | Genres that have no associated tracks are silently ignored.
321 |
322 | ## Artist support
323 |
324 | When an entry is selected from the Artist list, any album that contains
325 | at least one track attributed to that artist is included in the
326 | album list. That is, an album doesn't have to be limited to a single
327 | artist to be included in that artist's listing.
328 |
329 | It's not at all uncommon for an album to contain tracks by many different
330 | artists. If many albums are of that type, the artist list could be very
331 | long.
332 |
333 | Android Music Server is entirely at the mercy of the Android media scanner,
334 | when it comes to figuring out which tag in the audio file actually
335 | represents the artist. Many tag formats, particularly ID3v2, allow
336 | multiple artists, of multiple tasks. If all these tags are filled in,
337 | it's essentially pot luck which one will be used.
338 |
339 |
340 | ## Playlist operations
341 |
342 | On the Playlist page you can shuffle or clear the playlist, if
343 | it is not empty. Clicking either of the relevant links causes the
344 | page to refresh but, because the HTTP requests made using JavaScript
345 | are asynchronous, it can't be guaranteed that the playlist has changed
346 | on the server before the page is refreshed. You might need to refresh
347 | the page explicitly if changes to the playlist do not show up
348 | immediately.
349 |
350 | Shuffling only changes the order in which items appear in the playlist --
351 | if something is playing when you shuffle, the change in ordering will
352 | only be apparent when that track is finished.
353 |
354 |
355 | ## Settings
356 |
357 | The settings page (in the app's user interface, not the browser)
358 | provides some modest control over operation of
359 | the app -- the number of items displayed on each browser page, for
360 | example. There is no easy way to guess the appropriate settings --
361 | they depend on the capabilities of the Android device and of
362 | the web browser in use. If you are primarily interacting with the
363 | music server through a desktop browser, for example, you'll probably
364 | be able to set higher values of the number of items on each page.
365 |
366 | As with most Android settings pages, the changed settings take effect
367 | when you click the "back" button to get back to the main screen.
368 |
369 |
370 | ## Search
371 |
372 | There is a search box in the top menu bar of the web interface.
373 | The music server does
374 | a very simple, case-insensitive search for the text string,
375 | which may appear anywhere
376 | in any album, artist, composer, or track title. The number of matches
377 | of each category (album, artist, etc) that are displayed on
378 | the results page can be controlled using the
379 | Settings page.
380 |
381 |
382 | ## Android media catalogue issues
383 |
384 | Android maintains a catalogue of media files and their metadata (tags).
385 | When a file is added using a USB connection, or presented to the
386 | device on an SD card or similar, Android reads the metadata and updates
387 | the catalogue. Android Music Server relies entirely on this catalogue
388 | for information about albums, artists, etc. Two problems arise from the
389 | app's use of the media catalogue.
390 |
391 | First, the application has to scan the catalogue to get lists of
392 | albums, artists, etc., for the display. This process is not usually
393 | _very_ time-consuming, but not something that we want to do regularly.
394 | In principle, the music server could hook into the media scanner and
395 | rescan every time a file is added or deleted, but many files will be of
396 | no interest to the application (documents, pictures...), and rescanning
397 | like this could be overzealous. Instead, there is a link on the
398 | home page 'Rescan the media catalog.' This will cause the music server
399 | to rebuild its own lists of albums, artists, etc., from Android's catalogue.
400 | Of course, you could just restart the app.
401 |
402 | The second problem is that the media catalogue can sometimes get
403 | out-of-sync with the contents of storage. This isn't usually a problem
404 | with files added by USB, but can be a problem with files on removeable
405 | storage devices, and is particularly a problem if files are moved
406 | around using a general file manager (or, worse, at a command prompt,
407 | although most users probably won't do that).
408 |
409 | The link 'Rescan the filesystem' will ask Android to start a complete
410 | rescan of the filesystem. Android is completely at liberty to
411 | ignore this request and, in later versions, is increasingly willing to exactly
412 | that.
413 | With Android 5 and later, rebooting may be the only way to force a complete
414 | rescan. Note that the Music Server user interface will not wait
415 | for the rescan, and rescanning the filesystem does not imply that the
416 | music server app will rescan Android's media catalogue
417 | (because it has no way to know when the filesystem
418 | rescan is finished, if it even started.)
419 |
420 | ## Supported devices
421 |
422 | Android Music Server is known to work on at least the following
423 | devices. Feel free to report others that work or don't work.
424 |
425 | - Samsung Note 8, with Android 9.0
426 | - Google Nexus 7 second gen., with Android 4.4.4
427 | - Google Nexus 7 first gen., with Android 5.1.0
428 | - Samsung Galaxy S3, with Android 4.4.2
429 | - Samsung Note 3, with Android 4.4.2
430 |
431 | ## Limitations
432 |
433 | In general, Android Music Server supports whatever audio formats
434 | the device itself supports. When listing audio tracks by album/artist/etc
435 | you should never see anything that can't be played (unless it's actually
436 | a movie that Android has incorrectly identified as music). When listing
437 | files on the filesystem, you should also never see files that can't
438 | be played,
439 | because the app won't display files whose
440 | names do not end in a recognized audio extension, like .MP3 or .FLAC --
441 | it's just
442 | too time-consuming to have to scan each file and try to work out
443 | its contents. This does mean that some files that could, in fact,
444 | be played never get listed.
445 |
446 | The are particular issues regarding the display of cover art: please
447 | see the section "Cover art" above.
448 |
449 | Some Android devices are factory-configured to prevent _any_
450 | incoming network connection. Sorry but, without rooting the device,
451 | there's no way to change that, and this app simply won't work.
452 | Similarly, if your Android implementation shuts down the WiFi radio
453 | to save power when the screen blanks then, again, this app won't work.
454 |
455 | Android Music Server relies heavily on JavaScript to create and manage its
456 | web user interface. Your browser needs decent JavaScript support --
457 | the browser on the android device itself might not be up to the job
458 | (but Chrome, at least, seems to work pretty well.)
459 |
460 | Only WIFI operation is supported -- you won't be able to connect to the
461 | app over a mobile network. Even if the app allowed this, most likely
462 | the network would not.
463 |
464 | The app will not respond very well to changes in WIFI status -- if you
465 | change access points, for example -- and you'll probably need to restart
466 | it in such cases.
467 |
468 | Android Music Server relies for its tag (e.g., album)
469 | support entirely on the Android
470 | media scanner. If this isn't working (which is relatively common),
471 | results will be variable. The app queries the media scanner
472 | when it starts, so media added after starting may not be visible (even
473 | if the scanner is working), unless you click the "Rescan media catalogue"
474 | link in the home page. Please see the section 'Android media
475 | catalogue issues' for more information.
476 |
477 | One particular oddity of the Android media scanner is that it will sometimes
478 | present video files as 'Music,' presmumably because they have soundtracks.
479 | This app doesn't play video, so these entries in the album list are an
480 | irritation.
481 |
482 | Whilst you can skip forward and back between tracks in the playlist,
483 | there is no general foward/rewind facility within a specific
484 | track. This is a tricky thing to implement within a web interface.
485 |
486 | The app does not choose an open port for its built-in web server --
487 | this would be easy to implement, but users would probably prefer
488 | the port number to remain the same between sessions. If the port number
489 | clashes with something else, or is out of the permitted range, an
490 | error message should be displayed.
491 |
492 | There is quite a subtle limitation inherent in the way
493 | Android audio works. Android Music Server registers itself to receive
494 | remote control events (e.g., from a headset), but only when audio
495 | is playing. So you can select play/pause, next track, and previous
496 | track, if your headset has buttons for these functions. However,
497 | if you do a "stop" operation (if your hardware supports it), you
498 | won't be able to start again, or use remote control at all,
499 | until you resume playback using the web interface. The reason this limitation
500 | exists is that the app is designed to run in the background, and perhaps
501 | be idle a lot of the time. If it took over the remote control when it
502 | was running, it would prevent other media apps using the remote control.
503 | There are, in fact, a number of popular media players that suffer from
504 | this exact problem, and it can be quite a nuisance.
505 |
506 | *This app is, in essence, an unsecured web server*.
507 | It is not really intended for use in hostile environments.
508 |
509 | The user interface is currently English-language only.
510 |
511 | The part of Android Music Server that responds to HTTP requests and plays
512 | audio (i.e., most of it) is implemented as an Android background service.
513 | It is therefore less prone to automatic unloading than the user interface
514 | is. It's possible that Android might unload the user interface, whilst
515 | leaving the service running. This should be harmless, because when you
516 | restart the app, Android will not restart the service if it is still
517 | running. It's possible, in conditions of low memory, that Android will
518 | unload the service as well. In that case, Android is supposed to reload
519 | it when conditions improve, without user intervention. That process should
520 | also be transparent to the user except that, of course, whilst unloaded
521 | the service will not respond to HTTP requests. However, if the service
522 | is unloaded and reloading in this way, it will reinitialize, and
523 | the current playlist will be lost.
524 |
525 | The Android API specifies an interface by which app can control audio equalizer
526 | settings, but manufacturers do not have to implement it in any useful way.
527 | That is, the controls may not be connected to anything. "Bass boost" is
528 | particularly flakey -- on some devices it has an adjustable strength,
529 | on some it is just an on/off control, and on some it has no effect at all.
530 | Devices that provide their own, vendor-specific audio enhancers frequently
531 | do not implement the Android audio API at all.
532 | In any event, I am aware of few Android devices where the stock equalizer really
533 | works well. On some devices, I'm told that the equalizer API does not even
534 | initialize properly.
535 |
536 | Albums and tracks that appear in the search results are displayed either
537 | with, or without cover art -- the default is without. However, if you
538 | have recently browsed albums or tracks by cover, then covers will
539 | be included in search results as well.
540 |
541 | Finally, in this long list of limitations, there's the fact that
542 | changing the orientation of the device (portrait/landscape) will
543 | probably cause the app to reload. Because it's deliberately completely
544 | stateless, this will clear the playlist and stop playback. So long
545 | as the UI isn't actually visible -- and, frankly, it's nothing much
546 | to look at -- this isn't a problem.
547 |
548 | ## Legal and copying
549 |
550 | Android Music Server is open-source, and released under the terms
551 | of the GNU Public Licence, version 3.0. It contains components from
552 | a number of different authors. Please see the individual source
553 | files for detailed licencing and redistribution rights.
554 | The button icons are from the Tango icon set, released under the
555 | germs of the GNU Public Licence, version 2.0.
556 |
557 | Broadly,
558 | however, Android Music Server is free of charge and may be freely
559 | copied and distributed, so long as the original authors continue
560 | to be acknowledged.
561 |
562 | I wrote Android Music Server server for my own use; I'm
563 | publishing it in case somebody
564 | else might get some benefit from it -- even if it's only to look at the source
565 | code and see how not to write an Android app. It might work for you, it
566 | might not. If it doesn't, you're very welcome to fix it.
567 |
568 |
Revision history
569 |
570 |
571 |
572 |
573 | 0.0.8
574 |
575 |
576 | January 2023
577 |
578 |
579 | Refactored for gradle build and API 31
580 |
581 |
582 |
583 |
584 | 0.0.7
585 |
586 |
587 | November 2021
588 |
589 |
590 | Improved app screen layout a little
591 |
592 |
593 |
594 |
595 | 0.0.6
596 |
597 |
598 | October 2021
599 |
600 |
601 | Stopped the app crashing when a genre is "null" in the
602 | media database.
603 |
604 |
605 |
606 |
607 | 0.0.5
608 |
609 |
610 | June 12 2019
611 |
612 |
613 | Various bug fixes related to later Android releases
614 |
615 |
616 |
617 |
618 | 0.0.4
619 |
620 |
621 | April 18 2015
622 |
623 |
624 | Added search facility, and preferences page
625 |
626 |
627 |
628 |
629 | 0.0.3
630 |
631 |
632 | April 15 2015
633 |
634 |
635 | Added on-device status display and controls, and equalizer page
636 |
637 |
638 |
639 |
640 | 0.0.2
641 |
642 |
643 | April 12 2015
644 |
645 |
646 | Added preliminary cover art, and genre/artist filtering support
647 |
No fixed config files, logging, authorization etc. (Implement yourself if you need them.)
42 | *
Supports parameter parsing of GET and POST methods (+ rudimentary PUT support in 1.25)
43 | *
Supports both dynamic content and file serving
44 | *
Supports file upload (since version 1.2, 2010)
45 | *
Supports partial content (streaming)
46 | *
Supports ETags
47 | *
Never caches anything
48 | *
Doesn't limit bandwidth, request time or simultaneous connections
49 | *
Default code serves files and shows all HTTP parameters and headers
50 | *
File server supports directory listing, index.html and index.htm
51 | *
File server supports partial content (streaming)
52 | *
File server supports ETags
53 | *
File server does the 301 redirection trick for directories without '/'
54 | *
File server supports simple skipping for files (continue download)
55 | *
File server serves also very long files without memory overhead
56 | *
Contains a built-in list of most common mime types
57 | *
All header names are converted lowercase so they don't vary between browsers/clients
58 | *
59 | *
60 | *
61 | *
62 | * How to use:
63 | *
64 | *
65 | *
Subclass and implement serve() and embed to your own program
66 | *
67 | *
68 | *
69 | * See the separate "LICENSE.md" file for the distribution license (Modified BSD licence)
70 | */
71 | public abstract class NanoHTTPD {
72 | /**
73 | * Maximum time to wait on Socket.getInputStream().read() (in milliseconds)
74 | * This is required as the Keep-Alive HTTP connections would otherwise
75 | * block the socket reading thread forever (or as long the browser is open).
76 | */
77 | public static final int SOCKET_READ_TIMEOUT = 5000;
78 | /**
79 | * Common mime type for dynamic content: plain text
80 | */
81 | public static final String MIME_PLAINTEXT = "text/plain";
82 | /**
83 | * Common mime type for dynamic content: html
84 | */
85 | public static final String MIME_HTML = "text/html";
86 | /**
87 | * Pseudo-Parameter to use to store the actual query string in the parameters map for later re-processing.
88 | */
89 | private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING";
90 | private final String hostname;
91 | private final int myPort;
92 | private ServerSocket myServerSocket;
93 | private Set openConnections = new HashSet();
94 | private Thread myThread;
95 | /**
96 | * Pluggable strategy for asynchronously executing requests.
97 | */
98 | private AsyncRunner asyncRunner;
99 | /**
100 | * Pluggable strategy for creating and cleaning up temporary files.
101 | */
102 | private TempFileManagerFactory tempFileManagerFactory;
103 |
104 | /**
105 | * Constructs an HTTP server on given port.
106 | */
107 | public NanoHTTPD(int port) {
108 | this(null, port);
109 | }
110 |
111 | /**
112 | * Constructs an HTTP server on given hostname and port.
113 | */
114 | public NanoHTTPD(String hostname, int port) {
115 | this.hostname = hostname;
116 | this.myPort = port;
117 | setTempFileManagerFactory(new DefaultTempFileManagerFactory());
118 | setAsyncRunner(new DefaultAsyncRunner());
119 | }
120 |
121 | private static final void safeClose(Closeable closeable) {
122 | if (closeable != null) {
123 | try {
124 | closeable.close();
125 | } catch (IOException e) {
126 | }
127 | }
128 | }
129 |
130 | private static final void safeClose(Socket closeable) {
131 | if (closeable != null) {
132 | try {
133 | closeable.close();
134 | } catch (IOException e) {
135 | }
136 | }
137 | }
138 |
139 | private static final void safeClose(ServerSocket closeable) {
140 | if (closeable != null) {
141 | try {
142 | closeable.close();
143 | } catch (IOException e) {
144 | }
145 | }
146 | }
147 |
148 | /**
149 | * Start the server.
150 | *
151 | * @throws IOException if the socket is in use.
152 | */
153 | public void start() throws IOException {
154 | myServerSocket = new ServerSocket();
155 | myServerSocket.bind((hostname != null) ? new InetSocketAddress(hostname, myPort) : new InetSocketAddress(myPort));
156 |
157 | myThread = new Thread(new Runnable() {
158 | @Override
159 | public void run() {
160 | do {
161 | try {
162 | final Socket finalAccept = myServerSocket.accept();
163 | registerConnection(finalAccept);
164 | finalAccept.setSoTimeout(SOCKET_READ_TIMEOUT);
165 | final InputStream inputStream = finalAccept.getInputStream();
166 | asyncRunner.exec(new Runnable() {
167 | @Override
168 | public void run() {
169 | OutputStream outputStream = null;
170 | try {
171 | outputStream = finalAccept.getOutputStream();
172 | TempFileManager tempFileManager = tempFileManagerFactory.create();
173 | HTTPSession session = new HTTPSession(tempFileManager, inputStream, outputStream, finalAccept.getInetAddress());
174 | while (!finalAccept.isClosed()) {
175 | session.execute();
176 | }
177 | } catch (Exception e) {
178 | // When the socket is closed by the client, we throw our own SocketException
179 | // to break the "keep alive" loop above.
180 | if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage()))) {
181 | e.printStackTrace();
182 | }
183 | } finally {
184 | safeClose(outputStream);
185 | safeClose(inputStream);
186 | safeClose(finalAccept);
187 | unRegisterConnection(finalAccept);
188 | }
189 | }
190 | });
191 | } catch (IOException e) {
192 | }
193 | } while (!myServerSocket.isClosed());
194 | }
195 | });
196 | myThread.setDaemon(true);
197 | myThread.setName("NanoHttpd Main Listener");
198 | myThread.start();
199 | }
200 |
201 | /**
202 | * Stop the server.
203 | */
204 | public void stop() {
205 | try {
206 | safeClose(myServerSocket);
207 | closeAllConnections();
208 | if (myThread != null) {
209 | myThread.join();
210 | }
211 | } catch (Exception e) {
212 | e.printStackTrace();
213 | }
214 | }
215 |
216 | /**
217 | * Registers that a new connection has been set up.
218 | *
219 | * @param socket the {@link Socket} for the connection.
220 | */
221 | public synchronized void registerConnection(Socket socket) {
222 | openConnections.add(socket);
223 | }
224 |
225 | /**
226 | * Registers that a connection has been closed
227 | *
228 | * @param socket
229 | * the {@link Socket} for the connection.
230 | */
231 | public synchronized void unRegisterConnection(Socket socket) {
232 | openConnections.remove(socket);
233 | }
234 |
235 | /**
236 | * Forcibly closes all connections that are open.
237 | */
238 | public synchronized void closeAllConnections() {
239 | for (Socket socket : openConnections) {
240 | safeClose(socket);
241 | }
242 | }
243 |
244 | public final int getListeningPort() {
245 | return myServerSocket == null ? -1 : myServerSocket.getLocalPort();
246 | }
247 |
248 | public final boolean wasStarted() {
249 | return myServerSocket != null && myThread != null;
250 | }
251 |
252 | public final boolean isAlive() {
253 | return wasStarted() && !myServerSocket.isClosed() && myThread.isAlive();
254 | }
255 |
256 | /**
257 | * Override this to customize the server.
258 | *
259 | *
260 | * (By default, this delegates to serveFile() and allows directory listing.)
261 | *
262 | * @param uri Percent-decoded URI without parameters, for example "/index.cgi"
263 | * @param method "GET", "POST" etc.
264 | * @param parms Parsed, percent decoded parameters from URI and, in case of POST, data.
265 | * @param headers Header entries, percent decoded
266 | * @return HTTP response, see class Response for details
267 | */
268 | @Deprecated
269 | public Response serve(String uri, Method method, Map headers, Map parms,
270 | Map files) {
271 | return new Response(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found");
272 | }
273 |
274 | /**
275 | * Override this to customize the server.
276 | *
277 | *
278 | * (By default, this delegates to serveFile() and allows directory listing.)
279 | *
280 | * @param session The HTTP session
281 | * @return HTTP response, see class Response for details
282 | */
283 | public Response serve(IHTTPSession session) {
284 | Map files = new HashMap();
285 | Method method = session.getMethod();
286 | if (Method.PUT.equals(method) || Method.POST.equals(method)) {
287 | try {
288 | session.parseBody(files);
289 | } catch (IOException ioe) {
290 | return new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
291 | } catch (ResponseException re) {
292 | return new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
293 | }
294 | }
295 |
296 | Map parms = session.getParms();
297 | parms.put(QUERY_STRING_PARAMETER, session.getQueryParameterString());
298 | return serve(session.getUri(), method, session.getHeaders(), parms, files);
299 | }
300 |
301 | /**
302 | * Decode percent encoded String values.
303 | *
304 | * @param str the percent encoded String
305 | * @return expanded form of the input, for example "foo%20bar" becomes "foo bar"
306 | */
307 | protected String decodePercent(String str) {
308 | String decoded = null;
309 | try {
310 | decoded = URLDecoder.decode(str, "UTF8");
311 | } catch (UnsupportedEncodingException ignored) {
312 | }
313 | return decoded;
314 | }
315 |
316 | /**
317 | * Decode parameters from a URL, handing the case where a single parameter name might have been
318 | * supplied several times, by return lists of values. In general these lists will contain a single
319 | * element.
320 | *
321 | * @param parms original NanoHttpd parameters values, as passed to the serve() method.
322 | * @return a map of String (parameter name) to List<String> (a list of the values supplied).
323 | */
324 | protected Map> decodeParameters(Map parms) {
325 | return this.decodeParameters(parms.get(QUERY_STRING_PARAMETER));
326 | }
327 |
328 | /**
329 | * Decode parameters from a URL, handing the case where a single parameter name might have been
330 | * supplied several times, by return lists of values. In general these lists will contain a single
331 | * element.
332 | *
333 | * @param queryString a query string pulled from the URL.
334 | * @return a map of String (parameter name) to List<String> (a list of the values supplied).
335 | */
336 | protected Map> decodeParameters(String queryString) {
337 | Map> parms = new HashMap>();
338 | if (queryString != null) {
339 | StringTokenizer st = new StringTokenizer(queryString, "&");
340 | while (st.hasMoreTokens()) {
341 | String e = st.nextToken();
342 | int sep = e.indexOf('=');
343 | String propertyName = (sep >= 0) ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim();
344 | if (!parms.containsKey(propertyName)) {
345 | parms.put(propertyName, new ArrayList());
346 | }
347 | String propertyValue = (sep >= 0) ? decodePercent(e.substring(sep + 1)) : null;
348 | if (propertyValue != null) {
349 | parms.get(propertyName).add(propertyValue);
350 | }
351 | }
352 | }
353 | return parms;
354 | }
355 |
356 | // ------------------------------------------------------------------------------- //
357 | //
358 | // Threading Strategy.
359 | //
360 | // ------------------------------------------------------------------------------- //
361 |
362 | /**
363 | * Pluggable strategy for asynchronously executing requests.
364 | *
365 | * @param asyncRunner new strategy for handling threads.
366 | */
367 | public void setAsyncRunner(AsyncRunner asyncRunner) {
368 | this.asyncRunner = asyncRunner;
369 | }
370 |
371 | // ------------------------------------------------------------------------------- //
372 | //
373 | // Temp file handling strategy.
374 | //
375 | // ------------------------------------------------------------------------------- //
376 |
377 | /**
378 | * Pluggable strategy for creating and cleaning up temporary files.
379 | *
380 | * @param tempFileManagerFactory new strategy for handling temp files.
381 | */
382 | public void setTempFileManagerFactory(TempFileManagerFactory tempFileManagerFactory) {
383 | this.tempFileManagerFactory = tempFileManagerFactory;
384 | }
385 |
386 | /**
387 | * HTTP Request methods, with the ability to decode a String back to its enum value.
388 | */
389 | public enum Method {
390 | GET, PUT, POST, DELETE, HEAD, OPTIONS;
391 |
392 | static Method lookup(String method) {
393 | for (Method m : Method.values()) {
394 | if (m.toString().equalsIgnoreCase(method)) {
395 | return m;
396 | }
397 | }
398 | return null;
399 | }
400 | }
401 |
402 | /**
403 | * Pluggable strategy for asynchronously executing requests.
404 | */
405 | public interface AsyncRunner {
406 | void exec(Runnable code);
407 | }
408 |
409 | /**
410 | * Factory to create temp file managers.
411 | */
412 | public interface TempFileManagerFactory {
413 | TempFileManager create();
414 | }
415 |
416 | // ------------------------------------------------------------------------------- //
417 |
418 | /**
419 | * Temp file manager.
420 | *
421 | *
Temp file managers are created 1-to-1 with incoming requests, to create and cleanup
422 | * temporary files created as a result of handling the request.
By default, the server spawns a new Thread for every incoming request. These are set
448 | * to daemon status, and named according to the request number. The name is
449 | * useful when profiling the application.
450 | */
451 | public static class DefaultAsyncRunner implements AsyncRunner {
452 | private long requestCount;
453 |
454 | @Override
455 | public void exec(Runnable code) {
456 | ++requestCount;
457 | Thread t = new Thread(code);
458 | t.setDaemon(true);
459 | t.setName("NanoHttpd Request Processor (#" + requestCount + ")");
460 | t.start();
461 | }
462 | }
463 |
464 | /**
465 | * Default strategy for creating and cleaning up temporary files.
466 | *
467 | * This class stores its files in the standard location (that is,
468 | * wherever java.io.tmpdir points to). Files are added
469 | * to an internal list, and deleted when no longer needed (that is,
470 | * when clear() is invoked at the end of processing a
471 | * request).
472 | */
473 | public static class DefaultTempFileManager implements TempFileManager {
474 | private final String tmpdir;
475 | private final List tempFiles;
476 |
477 | public DefaultTempFileManager() {
478 | tmpdir = System.getProperty("java.io.tmpdir");
479 | tempFiles = new ArrayList();
480 | }
481 |
482 | @Override
483 | public TempFile createTempFile() throws Exception {
484 | DefaultTempFile tempFile = new DefaultTempFile(tmpdir);
485 | tempFiles.add(tempFile);
486 | return tempFile;
487 | }
488 |
489 | @Override
490 | public void clear() {
491 | for (TempFile file : tempFiles) {
492 | try {
493 | file.delete();
494 | } catch (Exception ignored) {
495 | }
496 | }
497 | tempFiles.clear();
498 | }
499 | }
500 |
501 | /**
502 | * Default strategy for creating and cleaning up temporary files.
503 | *
504 | * [>By default, files are created by File.createTempFile() in
505 | * the directory specified.
506 | */
507 | public static class DefaultTempFile implements TempFile {
508 | private File file;
509 | private OutputStream fstream;
510 |
511 | public DefaultTempFile(String tempdir) throws IOException {
512 | file = File.createTempFile("NanoHTTPD-", "", new File(tempdir));
513 | fstream = new FileOutputStream(file);
514 | }
515 |
516 | @Override
517 | public OutputStream open() throws Exception {
518 | return fstream;
519 | }
520 |
521 | @Override
522 | public void delete() throws Exception {
523 | safeClose(fstream);
524 | file.delete();
525 | }
526 |
527 | @Override
528 | public String getName() {
529 | return file.getAbsolutePath();
530 | }
531 | }
532 |
533 | /**
534 | * HTTP response. Return one of these from serve().
535 | */
536 | public static class Response {
537 | /**
538 | * HTTP status code after processing, e.g. "200 OK", HTTP_OK
539 | */
540 | private IStatus status;
541 | /**
542 | * MIME type of content, e.g. "text/html"
543 | */
544 | private String mimeType;
545 | /**
546 | * Data of the response, may be null.
547 | */
548 | private InputStream data;
549 | /**
550 | * Headers for the HTTP response. Use addHeader() to add lines.
551 | */
552 | private Map header = new HashMap();
553 | /**
554 | * The request method that spawned this response.
555 | */
556 | private Method requestMethod;
557 | /**
558 | * Use chunkedTransfer
559 | */
560 | private boolean chunkedTransfer;
561 |
562 | /**
563 | * Default constructor: response = HTTP_OK, mime = MIME_HTML and your supplied message
564 | */
565 | public Response(String msg) {
566 | this(Status.OK, MIME_HTML, msg);
567 | }
568 |
569 | /**
570 | * Basic constructor.
571 | */
572 | public Response(IStatus status, String mimeType, InputStream data) {
573 | this.status = status;
574 | this.mimeType = mimeType;
575 | this.data = data;
576 | }
577 |
578 | /**
579 | * Convenience method that makes an InputStream out of given text.
580 | */
581 | public Response(IStatus status, String mimeType, String txt) {
582 | this.status = status;
583 | this.mimeType = mimeType;
584 | try {
585 | this.data = txt != null ? new ByteArrayInputStream(txt.getBytes("UTF-8")) : null;
586 | } catch (java.io.UnsupportedEncodingException uee) {
587 | uee.printStackTrace();
588 | }
589 | }
590 |
591 | /**
592 | * Adds given line to the header.
593 | */
594 | public void addHeader(String name, String value) {
595 | header.put(name, value);
596 | }
597 |
598 | public String getHeader(String name) {
599 | return header.get(name);
600 | }
601 |
602 | /**
603 | * Sends given response to the socket.
604 | */
605 | protected void send(OutputStream outputStream) {
606 | String mime = mimeType;
607 | SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
608 | gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT"));
609 |
610 | try {
611 | if (status == null) {
612 | throw new Error("sendResponse(): Status can't be null.");
613 | }
614 | PrintWriter pw = new PrintWriter(outputStream);
615 | pw.print("HTTP/1.1 " + status.getDescription() + " \r\n");
616 |
617 | if (mime != null) {
618 | pw.print("Content-Type: " + mime + "\r\n");
619 | }
620 |
621 | if (header == null || header.get("Date") == null) {
622 | pw.print("Date: " + gmtFrmt.format(new Date()) + "\r\n");
623 | }
624 |
625 | if (header != null) {
626 | for (String key : header.keySet()) {
627 | String value = header.get(key);
628 | pw.print(key + ": " + value + "\r\n");
629 | }
630 | }
631 |
632 | sendConnectionHeaderIfNotAlreadyPresent(pw, header);
633 |
634 | if (requestMethod != Method.HEAD && chunkedTransfer) {
635 | sendAsChunked(outputStream, pw);
636 | } else {
637 | int pending = data != null ? data.available() : 0;
638 | sendContentLengthHeaderIfNotAlreadyPresent(pw, header, pending);
639 | pw.print("\r\n");
640 | pw.flush();
641 | sendAsFixedLength(outputStream, pending);
642 | }
643 | outputStream.flush();
644 | safeClose(data);
645 | } catch (IOException ioe) {
646 | // Couldn't write? No can do.
647 | }
648 | }
649 |
650 | protected void sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, Map header, int size) {
651 | if (!headerAlreadySent(header, "content-length")) {
652 | pw.print("Content-Length: "+ size +"\r\n");
653 | }
654 | }
655 |
656 | protected void sendConnectionHeaderIfNotAlreadyPresent(PrintWriter pw, Map header) {
657 | if (!headerAlreadySent(header, "connection")) {
658 | pw.print("Connection: keep-alive\r\n");
659 | }
660 | }
661 |
662 | private boolean headerAlreadySent(Map header, String name) {
663 | boolean alreadySent = false;
664 | for (String headerName : header.keySet()) {
665 | alreadySent |= headerName.equalsIgnoreCase(name);
666 | }
667 | return alreadySent;
668 | }
669 |
670 | private void sendAsChunked(OutputStream outputStream, PrintWriter pw) throws IOException {
671 | pw.print("Transfer-Encoding: chunked\r\n");
672 | pw.print("\r\n");
673 | pw.flush();
674 | int BUFFER_SIZE = 16 * 1024;
675 | byte[] CRLF = "\r\n".getBytes();
676 | byte[] buff = new byte[BUFFER_SIZE];
677 | int read;
678 | while ((read = data.read(buff)) > 0) {
679 | outputStream.write(String.format("%x\r\n", read).getBytes());
680 | outputStream.write(buff, 0, read);
681 | outputStream.write(CRLF);
682 | }
683 | outputStream.write(String.format("0\r\n\r\n").getBytes());
684 | }
685 |
686 | private void sendAsFixedLength(OutputStream outputStream, int pending) throws IOException {
687 | if (requestMethod != Method.HEAD && data != null) {
688 | int BUFFER_SIZE = 16 * 1024;
689 | byte[] buff = new byte[BUFFER_SIZE];
690 | while (pending > 0) {
691 | int read = data.read(buff, 0, ((pending > BUFFER_SIZE) ? BUFFER_SIZE : pending));
692 | if (read <= 0) {
693 | break;
694 | }
695 | outputStream.write(buff, 0, read);
696 | pending -= read;
697 | }
698 | }
699 | }
700 |
701 | public IStatus getStatus() {
702 | return status;
703 | }
704 |
705 | public void setStatus(Status status) {
706 | this.status = status;
707 | }
708 |
709 | public String getMimeType() {
710 | return mimeType;
711 | }
712 |
713 | public void setMimeType(String mimeType) {
714 | this.mimeType = mimeType;
715 | }
716 |
717 | public InputStream getData() {
718 | return data;
719 | }
720 |
721 | public void setData(InputStream data) {
722 | this.data = data;
723 | }
724 |
725 | public Method getRequestMethod() {
726 | return requestMethod;
727 | }
728 |
729 | public void setRequestMethod(Method requestMethod) {
730 | this.requestMethod = requestMethod;
731 | }
732 |
733 | public void setChunkedTransfer(boolean chunkedTransfer) {
734 | this.chunkedTransfer = chunkedTransfer;
735 | }
736 |
737 | public interface IStatus {
738 | int getRequestStatus();
739 | String getDescription();
740 | }
741 |
742 | /**
743 | * Some HTTP response status codes
744 | */
745 | public enum Status implements IStatus {
746 | SWITCH_PROTOCOL(101, "Switching Protocols"), OK(200, "OK"), CREATED(201, "Created"), ACCEPTED(202, "Accepted"), NO_CONTENT(204, "No Content"), PARTIAL_CONTENT(206, "Partial Content"), REDIRECT(301,
747 | "Moved Permanently"), NOT_MODIFIED(304, "Not Modified"), BAD_REQUEST(400, "Bad Request"), UNAUTHORIZED(401,
748 | "Unauthorized"), FORBIDDEN(403, "Forbidden"), NOT_FOUND(404, "Not Found"), METHOD_NOT_ALLOWED(405, "Method Not Allowed"), RANGE_NOT_SATISFIABLE(416,
749 | "Requested Range Not Satisfiable"), INTERNAL_ERROR(500, "Internal Server Error");
750 | private final int requestStatus;
751 | private final String description;
752 |
753 | Status(int requestStatus, String description) {
754 | this.requestStatus = requestStatus;
755 | this.description = description;
756 | }
757 |
758 | @Override
759 | public int getRequestStatus() {
760 | return this.requestStatus;
761 | }
762 |
763 | @Override
764 | public String getDescription() {
765 | return "" + this.requestStatus + " " + description;
766 | }
767 | }
768 | }
769 |
770 | public static final class ResponseException extends Exception {
771 |
772 | private final Response.Status status;
773 |
774 | public ResponseException(Response.Status status, String message) {
775 | super(message);
776 | this.status = status;
777 | }
778 |
779 | public ResponseException(Response.Status status, String message, Exception e) {
780 | super(message, e);
781 | this.status = status;
782 | }
783 |
784 | public Response.Status getStatus() {
785 | return status;
786 | }
787 | }
788 |
789 | /**
790 | * Default strategy for creating and cleaning up temporary files.
791 | */
792 | private class DefaultTempFileManagerFactory implements TempFileManagerFactory {
793 | @Override
794 | public TempFileManager create() {
795 | return new DefaultTempFileManager();
796 | }
797 | }
798 |
799 | /**
800 | * Handles one session, i.e. parses the HTTP request and returns the response.
801 | */
802 | public interface IHTTPSession {
803 | void execute() throws IOException;
804 |
805 | Map getParms();
806 |
807 | Map getHeaders();
808 |
809 | /**
810 | * @return the path part of the URL.
811 | */
812 | String getUri();
813 |
814 | String getQueryParameterString();
815 |
816 | Method getMethod();
817 |
818 | InputStream getInputStream();
819 |
820 | CookieHandler getCookies();
821 |
822 | /**
823 | * Adds the files in the request body to the files map.
824 | * @arg files - map to modify
825 | */
826 | void parseBody(Map files) throws IOException, ResponseException;
827 | }
828 |
829 | protected class HTTPSession implements IHTTPSession {
830 | public static final int BUFSIZE = 8192;
831 | private final TempFileManager tempFileManager;
832 | private final OutputStream outputStream;
833 | private PushbackInputStream inputStream;
834 | private int splitbyte;
835 | private int rlen;
836 | private String uri;
837 | private Method method;
838 | private Map parms;
839 | private Map headers;
840 | private CookieHandler cookies;
841 | private String queryParameterString;
842 |
843 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) {
844 | this.tempFileManager = tempFileManager;
845 | this.inputStream = new PushbackInputStream(inputStream, BUFSIZE);
846 | this.outputStream = outputStream;
847 | }
848 |
849 | public HTTPSession(TempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) {
850 | this.tempFileManager = tempFileManager;
851 | this.inputStream = new PushbackInputStream(inputStream, BUFSIZE);
852 | this.outputStream = outputStream;
853 | String remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString();
854 | headers = new HashMap();
855 |
856 | headers.put("remote-addr", remoteIp);
857 | headers.put("http-client-ip", remoteIp);
858 | }
859 |
860 | @Override
861 | public void execute() throws IOException {
862 | try {
863 | // Read the first 8192 bytes.
864 | // The full header should fit in here.
865 | // Apache's default header limit is 8KB.
866 | // Do NOT assume that a single read will get the entire header at once!
867 | byte[] buf = new byte[BUFSIZE];
868 | splitbyte = 0;
869 | rlen = 0;
870 | {
871 | int read = -1;
872 | try {
873 | read = inputStream.read(buf, 0, BUFSIZE);
874 | } catch (Exception e) {
875 | safeClose(inputStream);
876 | safeClose(outputStream);
877 | throw new SocketException("NanoHttpd Shutdown");
878 | }
879 | if (read == -1) {
880 | // socket was been closed
881 | safeClose(inputStream);
882 | safeClose(outputStream);
883 | throw new SocketException("NanoHttpd Shutdown");
884 | }
885 | while (read > 0) {
886 | rlen += read;
887 | splitbyte = findHeaderEnd(buf, rlen);
888 | if (splitbyte > 0)
889 | break;
890 | read = inputStream.read(buf, rlen, BUFSIZE - rlen);
891 | }
892 | }
893 |
894 | if (splitbyte < rlen) {
895 | inputStream.unread(buf, splitbyte, rlen - splitbyte);
896 | }
897 |
898 | parms = new HashMap();
899 | if(null == headers) {
900 | headers = new HashMap();
901 | }
902 |
903 | // Create a BufferedReader for parsing the header.
904 | BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, rlen)));
905 |
906 | // Decode the header into parms and header java properties
907 | Map pre = new HashMap();
908 | decodeHeader(hin, pre, parms, headers);
909 |
910 | method = Method.lookup(pre.get("method"));
911 | if (method == null) {
912 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error.");
913 | }
914 |
915 | uri = pre.get("uri");
916 |
917 | cookies = new CookieHandler(headers);
918 |
919 | // Ok, now do the serve()
920 | Response r = serve(this);
921 | if (r == null) {
922 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response.");
923 | } else {
924 | cookies.unloadQueue(r);
925 | r.setRequestMethod(method);
926 | r.send(outputStream);
927 | }
928 | } catch (SocketException e) {
929 | // throw it out to close socket object (finalAccept)
930 | throw e;
931 | } catch (SocketTimeoutException ste) {
932 | throw ste;
933 | } catch (IOException ioe) {
934 | Response r = new Response(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage());
935 | r.send(outputStream);
936 | safeClose(outputStream);
937 | } catch (ResponseException re) {
938 | Response r = new Response(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
939 | r.send(outputStream);
940 | safeClose(outputStream);
941 | } finally {
942 | tempFileManager.clear();
943 | }
944 | }
945 |
946 | @Override
947 | public void parseBody(Map files) throws IOException, ResponseException {
948 | RandomAccessFile randomAccessFile = null;
949 | BufferedReader in = null;
950 | try {
951 |
952 | randomAccessFile = getTmpBucket();
953 |
954 | long size;
955 | if (headers.containsKey("content-length")) {
956 | size = Integer.parseInt(headers.get("content-length"));
957 | } else if (splitbyte < rlen) {
958 | size = rlen - splitbyte;
959 | } else {
960 | size = 0;
961 | }
962 |
963 | // Now read all the body and write it to f
964 | byte[] buf = new byte[512];
965 | while (rlen >= 0 && size > 0) {
966 | rlen = inputStream.read(buf, 0, (int)Math.min(size, 512));
967 | size -= rlen;
968 | if (rlen > 0) {
969 | randomAccessFile.write(buf, 0, rlen);
970 | }
971 | }
972 |
973 | // Get the raw body as a byte []
974 | ByteBuffer fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length());
975 | randomAccessFile.seek(0);
976 |
977 | // Create a BufferedReader for easily reading it as string.
978 | InputStream bin = new FileInputStream(randomAccessFile.getFD());
979 | in = new BufferedReader(new InputStreamReader(bin));
980 |
981 | // If the method is POST, there may be parameters
982 | // in data section, too, read it:
983 | if (Method.POST.equals(method)) {
984 | String contentType = "";
985 | String contentTypeHeader = headers.get("content-type");
986 |
987 | StringTokenizer st = null;
988 | if (contentTypeHeader != null) {
989 | st = new StringTokenizer(contentTypeHeader, ",; ");
990 | if (st.hasMoreTokens()) {
991 | contentType = st.nextToken();
992 | }
993 | }
994 |
995 | if ("multipart/form-data".equalsIgnoreCase(contentType)) {
996 | // Handle multipart/form-data
997 | if (!st.hasMoreTokens()) {
998 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
999 | }
1000 |
1001 | String boundaryStartString = "boundary=";
1002 | int boundaryContentStart = contentTypeHeader.indexOf(boundaryStartString) + boundaryStartString.length();
1003 | String boundary = contentTypeHeader.substring(boundaryContentStart, contentTypeHeader.length());
1004 | if (boundary.startsWith("\"") && boundary.endsWith("\"")) {
1005 | boundary = boundary.substring(1, boundary.length() - 1);
1006 | }
1007 |
1008 | decodeMultipartData(boundary, fbuf, in, parms, files);
1009 | } else {
1010 | String postLine = "";
1011 | StringBuilder postLineBuffer = new StringBuilder();
1012 | char pbuf[] = new char[512];
1013 | int read = in.read(pbuf);
1014 | while (read >= 0 && !postLine.endsWith("\r\n")) {
1015 | postLine = String.valueOf(pbuf, 0, read);
1016 | postLineBuffer.append(postLine);
1017 | read = in.read(pbuf);
1018 | }
1019 | postLine = postLineBuffer.toString().trim();
1020 | // Handle application/x-www-form-urlencoded
1021 | if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) {
1022 | decodeParms(postLine, parms);
1023 | } else if (postLine.length() != 0) {
1024 | // Special case for raw POST data => create a special files entry "postData" with raw content data
1025 | files.put("postData", postLine);
1026 | }
1027 | }
1028 | } else if (Method.PUT.equals(method)) {
1029 | files.put("content", saveTmpFile(fbuf, 0, fbuf.limit()));
1030 | }
1031 | } finally {
1032 | safeClose(randomAccessFile);
1033 | safeClose(in);
1034 | }
1035 | }
1036 |
1037 | /**
1038 | * Decodes the sent headers and loads the data into Key/value pairs
1039 | */
1040 | private void decodeHeader(BufferedReader in, Map pre, Map parms, Map headers)
1041 | throws ResponseException {
1042 | try {
1043 | // Read the request line
1044 | String inLine = in.readLine();
1045 | if (inLine == null) {
1046 | return;
1047 | }
1048 |
1049 | StringTokenizer st = new StringTokenizer(inLine);
1050 | if (!st.hasMoreTokens()) {
1051 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html");
1052 | }
1053 |
1054 | pre.put("method", st.nextToken());
1055 |
1056 | if (!st.hasMoreTokens()) {
1057 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html");
1058 | }
1059 |
1060 | String uri = st.nextToken();
1061 |
1062 | // Decode parameters from the URI
1063 | int qmi = uri.indexOf('?');
1064 | if (qmi >= 0) {
1065 | decodeParms(uri.substring(qmi + 1), parms);
1066 | uri = decodePercent(uri.substring(0, qmi));
1067 | } else {
1068 | uri = decodePercent(uri);
1069 | }
1070 |
1071 | // If there's another token, it's protocol version,
1072 | // followed by HTTP headers. Ignore version but parse headers.
1073 | // NOTE: this now forces header names lowercase since they are
1074 | // case insensitive and vary by client.
1075 | if (st.hasMoreTokens()) {
1076 | String line = in.readLine();
1077 | while (line != null && line.trim().length() > 0) {
1078 | int p = line.indexOf(':');
1079 | if (p >= 0)
1080 | headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim());
1081 | line = in.readLine();
1082 | }
1083 | }
1084 |
1085 | pre.put("uri", uri);
1086 | } catch (IOException ioe) {
1087 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
1088 | }
1089 | }
1090 |
1091 | /**
1092 | * Decodes the Multipart Body data and put it into Key/Value pairs.
1093 | */
1094 | private void decodeMultipartData(String boundary, ByteBuffer fbuf, BufferedReader in, Map parms,
1095 | Map files) throws ResponseException {
1096 | try {
1097 | int[] bpositions = getBoundaryPositions(fbuf, boundary.getBytes());
1098 | int boundarycount = 1;
1099 | String mpline = in.readLine();
1100 | while (mpline != null) {
1101 | if (!mpline.contains(boundary)) {
1102 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but next chunk does not start with boundary. Usage: GET /example/file.html");
1103 | }
1104 | boundarycount++;
1105 | Map item = new HashMap();
1106 | mpline = in.readLine();
1107 | while (mpline != null && mpline.trim().length() > 0) {
1108 | int p = mpline.indexOf(':');
1109 | if (p != -1) {
1110 | item.put(mpline.substring(0, p).trim().toLowerCase(Locale.US), mpline.substring(p + 1).trim());
1111 | }
1112 | mpline = in.readLine();
1113 | }
1114 | if (mpline != null) {
1115 | String contentDisposition = item.get("content-disposition");
1116 | if (contentDisposition == null) {
1117 | throw new ResponseException(Response.Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but no content-disposition info found. Usage: GET /example/file.html");
1118 | }
1119 | StringTokenizer st = new StringTokenizer(contentDisposition, ";");
1120 | Map disposition = new HashMap();
1121 | while (st.hasMoreTokens()) {
1122 | String token = st.nextToken().trim();
1123 | int p = token.indexOf('=');
1124 | if (p != -1) {
1125 | disposition.put(token.substring(0, p).trim().toLowerCase(Locale.US), token.substring(p + 1).trim());
1126 | }
1127 | }
1128 | String pname = disposition.get("name");
1129 | pname = pname.substring(1, pname.length() - 1);
1130 |
1131 | String value = "";
1132 | if (item.get("content-type") == null) {
1133 | while (mpline != null && !mpline.contains(boundary)) {
1134 | mpline = in.readLine();
1135 | if (mpline != null) {
1136 | int d = mpline.indexOf(boundary);
1137 | if (d == -1) {
1138 | value += mpline;
1139 | } else {
1140 | value += mpline.substring(0, d - 2);
1141 | }
1142 | }
1143 | }
1144 | } else {
1145 | if (boundarycount > bpositions.length) {
1146 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "Error processing request");
1147 | }
1148 | int offset = stripMultipartHeaders(fbuf, bpositions[boundarycount - 2]);
1149 | String path = saveTmpFile(fbuf, offset, bpositions[boundarycount - 1] - offset - 4);
1150 | files.put(pname, path);
1151 | value = disposition.get("filename");
1152 | value = value.substring(1, value.length() - 1);
1153 | do {
1154 | mpline = in.readLine();
1155 | } while (mpline != null && !mpline.contains(boundary));
1156 | }
1157 | parms.put(pname, value);
1158 | }
1159 | }
1160 | } catch (IOException ioe) {
1161 | throw new ResponseException(Response.Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
1162 | }
1163 | }
1164 |
1165 | /**
1166 | * Find byte index separating header from body. It must be the last byte of the first two sequential new lines.
1167 | */
1168 | private int findHeaderEnd(final byte[] buf, int rlen) {
1169 | int splitbyte = 0;
1170 | while (splitbyte + 3 < rlen) {
1171 | if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') {
1172 | return splitbyte + 4;
1173 | }
1174 | splitbyte++;
1175 | }
1176 | return 0;
1177 | }
1178 |
1179 | /**
1180 | * Find the byte positions where multipart boundaries start.
1181 | */
1182 | private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) {
1183 | int matchcount = 0;
1184 | int matchbyte = -1;
1185 | List matchbytes = new ArrayList();
1186 | for (int i = 0; i < b.limit(); i++) {
1187 | if (b.get(i) == boundary[matchcount]) {
1188 | if (matchcount == 0)
1189 | matchbyte = i;
1190 | matchcount++;
1191 | if (matchcount == boundary.length) {
1192 | matchbytes.add(matchbyte);
1193 | matchcount = 0;
1194 | matchbyte = -1;
1195 | }
1196 | } else {
1197 | i -= matchcount;
1198 | matchcount = 0;
1199 | matchbyte = -1;
1200 | }
1201 | }
1202 | int[] ret = new int[matchbytes.size()];
1203 | for (int i = 0; i < ret.length; i++) {
1204 | ret[i] = matchbytes.get(i);
1205 | }
1206 | return ret;
1207 | }
1208 |
1209 | /**
1210 | * Retrieves the content of a sent file and saves it to a temporary file. The full path to the saved file is returned.
1211 | */
1212 | private String saveTmpFile(ByteBuffer b, int offset, int len) {
1213 | String path = "";
1214 | if (len > 0) {
1215 | FileOutputStream fileOutputStream = null;
1216 | try {
1217 | TempFile tempFile = tempFileManager.createTempFile();
1218 | ByteBuffer src = b.duplicate();
1219 | fileOutputStream = new FileOutputStream(tempFile.getName());
1220 | FileChannel dest = fileOutputStream.getChannel();
1221 | src.position(offset).limit(offset + len);
1222 | dest.write(src.slice());
1223 | path = tempFile.getName();
1224 | } catch (Exception e) { // Catch exception if any
1225 | throw new Error(e); // we won't recover, so throw an error
1226 | } finally {
1227 | safeClose(fileOutputStream);
1228 | }
1229 | }
1230 | return path;
1231 | }
1232 |
1233 | private RandomAccessFile getTmpBucket() {
1234 | try {
1235 | TempFile tempFile = tempFileManager.createTempFile();
1236 | return new RandomAccessFile(tempFile.getName(), "rw");
1237 | } catch (Exception e) {
1238 | throw new Error(e); // we won't recover, so throw an error
1239 | }
1240 | }
1241 |
1242 | /**
1243 | * It returns the offset separating multipart file headers from the file's data.
1244 | */
1245 | private int stripMultipartHeaders(ByteBuffer b, int offset) {
1246 | int i;
1247 | for (i = offset; i < b.limit(); i++) {
1248 | if (b.get(i) == '\r' && b.get(++i) == '\n' && b.get(++i) == '\r' && b.get(++i) == '\n') {
1249 | break;
1250 | }
1251 | }
1252 | return i + 1;
1253 | }
1254 |
1255 | /**
1256 | * Decodes parameters in percent-encoded URI-format ( e.g. "name=Jack%20Daniels&pass=Single%20Malt" ) and
1257 | * adds them to given Map. NOTE: this doesn't support multiple identical keys due to the simplicity of Map.
1258 | */
1259 | private void decodeParms(String parms, Map p) {
1260 | if (parms == null) {
1261 | queryParameterString = "";
1262 | return;
1263 | }
1264 |
1265 | queryParameterString = parms;
1266 | StringTokenizer st = new StringTokenizer(parms, "&");
1267 | while (st.hasMoreTokens()) {
1268 | String e = st.nextToken();
1269 | int sep = e.indexOf('=');
1270 | if (sep >= 0) {
1271 | p.put(decodePercent(e.substring(0, sep)).trim(),
1272 | decodePercent(e.substring(sep + 1)));
1273 | } else {
1274 | p.put(decodePercent(e).trim(), "");
1275 | }
1276 | }
1277 | }
1278 |
1279 | @Override
1280 | public final Map getParms() {
1281 | return parms;
1282 | }
1283 |
1284 | public String getQueryParameterString() {
1285 | return queryParameterString;
1286 | }
1287 |
1288 | @Override
1289 | public final Map getHeaders() {
1290 | return headers;
1291 | }
1292 |
1293 | @Override
1294 | public final String getUri() {
1295 | return uri;
1296 | }
1297 |
1298 | @Override
1299 | public final Method getMethod() {
1300 | return method;
1301 | }
1302 |
1303 | @Override
1304 | public final InputStream getInputStream() {
1305 | return inputStream;
1306 | }
1307 |
1308 | @Override
1309 | public CookieHandler getCookies() {
1310 | return cookies;
1311 | }
1312 | }
1313 |
1314 | public static class Cookie {
1315 | private String n, v, e;
1316 |
1317 | public Cookie(String name, String value, String expires) {
1318 | n = name;
1319 | v = value;
1320 | e = expires;
1321 | }
1322 |
1323 | public Cookie(String name, String value) {
1324 | this(name, value, 30);
1325 | }
1326 |
1327 | public Cookie(String name, String value, int numDays) {
1328 | n = name;
1329 | v = value;
1330 | e = getHTTPTime(numDays);
1331 | }
1332 |
1333 | public String getHTTPHeader() {
1334 | String fmt = "%s=%s; expires=%s";
1335 | return String.format(fmt, n, v, e);
1336 | }
1337 |
1338 | public static String getHTTPTime(int days) {
1339 | Calendar calendar = Calendar.getInstance();
1340 | SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
1341 | dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
1342 | calendar.add(Calendar.DAY_OF_MONTH, days);
1343 | return dateFormat.format(calendar.getTime());
1344 | }
1345 | }
1346 |
1347 | /**
1348 | * Provides rudimentary support for cookies.
1349 | * Doesn't support 'path', 'secure' nor 'httpOnly'.
1350 | * Feel free to improve it and/or add unsupported features.
1351 | *
1352 | * @author LordFokas
1353 | */
1354 | public class CookieHandler implements Iterable {
1355 | private HashMap cookies = new HashMap();
1356 | private ArrayList queue = new ArrayList();
1357 |
1358 | public CookieHandler(Map httpHeaders) {
1359 | String raw = httpHeaders.get("cookie");
1360 | if (raw != null) {
1361 | String[] tokens = raw.split(";");
1362 | for (String token : tokens) {
1363 | String[] data = token.trim().split("=");
1364 | if (data.length == 2) {
1365 | cookies.put(data[0], data[1]);
1366 | }
1367 | }
1368 | }
1369 | }
1370 |
1371 | @Override public Iterator iterator() {
1372 | return cookies.keySet().iterator();
1373 | }
1374 |
1375 | /**
1376 | * Read a cookie from the HTTP Headers.
1377 | *
1378 | * @param name The cookie's name.
1379 | * @return The cookie's value if it exists, null otherwise.
1380 | */
1381 | public String read(String name) {
1382 | return cookies.get(name);
1383 | }
1384 |
1385 | /**
1386 | * Sets a cookie.
1387 | *
1388 | * @param name The cookie's name.
1389 | * @param value The cookie's value.
1390 | * @param expires How many days until the cookie expires.
1391 | */
1392 | public void set(String name, String value, int expires) {
1393 | queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires)));
1394 | }
1395 |
1396 | public void set(Cookie cookie) {
1397 | queue.add(cookie);
1398 | }
1399 |
1400 | /**
1401 | * Set a cookie with an expiration date from a month ago, effectively deleting it on the client side.
1402 | *
1403 | * @param name The cookie name.
1404 | */
1405 | public void delete(String name) {
1406 | set(name, "-delete-", -30);
1407 | }
1408 |
1409 | /**
1410 | * Internally used by the webserver to add all queued cookies into the Response's HTTP Headers.
1411 | *
1412 | * @param response The Response object to which headers the queued cookies will be added.
1413 | */
1414 | public void unloadQueue(Response response) {
1415 | for (Cookie cookie : queue) {
1416 | response.addHeader("Set-Cookie", cookie.getHTTPHeader());
1417 | }
1418 | }
1419 | }
1420 | }
1421 |
--------------------------------------------------------------------------------