├── .gitignore ├── .gradle ├── 7.5 │ ├── checksums │ │ └── checksums.lock │ ├── dependencies-accessors │ │ ├── dependencies-accessors.lock │ │ └── gc.properties │ ├── executionHistory │ │ ├── executionHistory.bin │ │ └── executionHistory.lock │ ├── fileChanges │ │ └── last-build.bin │ ├── fileHashes │ │ ├── fileHashes.bin │ │ ├── fileHashes.lock │ │ └── resourceHashesCache.bin │ └── gc.properties ├── buildOutputCleanup │ ├── buildOutputCleanup.lock │ ├── cache.properties │ └── outputFiles.bin ├── file-system.probe └── vcs-1 │ └── gc.properties ├── README.md ├── androidmusicserverscreenshot.png ├── androidmusicserverscreenshot2.png ├── apk ├── app-debug.apk └── app-release.apk ├── app ├── build.gradle └── src │ └── main │ ├── AndroidManifest.xml │ ├── assets │ └── docroot │ │ ├── default_cover.png │ │ ├── functions.js │ │ ├── logo.png │ │ ├── nextbutton.png │ │ ├── pausebutton.png │ │ ├── playbutton.png │ │ ├── prevbutton.png │ │ ├── stopbutton.png │ │ ├── styles.css │ │ ├── vol_down.png │ │ └── vol_up.png │ ├── java │ └── net │ │ └── kevinboone │ │ ├── androidmediaserver │ │ ├── AndroidNetworkUtil.java │ │ ├── CoverUtils.java │ │ ├── FileUtils.java │ │ ├── Main.java │ │ ├── NanoHTTPD.java │ │ ├── SettingsActivity.java │ │ ├── Version.java │ │ ├── WebPlayerService.java │ │ ├── WebServer.java │ │ └── client │ │ │ ├── Client.java │ │ │ ├── ClientException.java │ │ │ └── Status.java │ │ ├── androidmusicplayer │ │ ├── AlreadyAtEndOfPlaylistException.java │ │ ├── AlreadyAtStartOfPlaylistException.java │ │ ├── AndroidEqUtil.java │ │ ├── AudioDatabase.java │ │ ├── Errors.java │ │ ├── Player.java │ │ ├── PlayerException.java │ │ ├── PlayerIOException.java │ │ ├── PlaylistEmptyException.java │ │ ├── PlaylistIndexOutOfRangeException.java │ │ ├── RemoteControlReceiver.java │ │ ├── SearchSpec.java │ │ └── TrackInfo.java │ │ └── textutils │ │ └── EscapeUtils.java │ └── res │ ├── drawable-hdpi │ └── ic_launcher.png │ ├── drawable-ldpi │ └── ic_launcher.png │ ├── drawable-mdpi │ └── ic_launcher.png │ ├── drawable-xhdpi │ ├── button_next.png │ ├── button_pause.png │ ├── button_play.png │ ├── button_prev.png │ ├── button_settings.png │ ├── button_shutdown.png │ ├── button_stop.png │ └── ic_launcher.png │ ├── layout │ └── main.xml │ ├── raw │ └── template.html │ ├── values │ └── strings.xml │ └── xml │ ├── network_security_config.xml │ └── preferences.xml ├── build.gradle ├── fastlane └── metadata │ └── android │ ├── de │ └── short_description.txt │ └── en-US │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── androidmusicserverscreenshot.jpg │ │ ├── androidmusicserverscreenshot.png │ │ ├── androidmusicserverscreenshot2.jpg │ │ └── androidmusicserverscreenshot2.png │ └── short_description.txt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── local.properties └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | local.properties 2 | app/build/ 3 | .gradle/ 4 | -------------------------------------------------------------------------------- /.gradle/7.5/checksums/checksums.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/checksums/checksums.lock -------------------------------------------------------------------------------- /.gradle/7.5/dependencies-accessors/dependencies-accessors.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/dependencies-accessors/dependencies-accessors.lock -------------------------------------------------------------------------------- /.gradle/7.5/dependencies-accessors/gc.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/dependencies-accessors/gc.properties -------------------------------------------------------------------------------- /.gradle/7.5/executionHistory/executionHistory.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/executionHistory/executionHistory.bin -------------------------------------------------------------------------------- /.gradle/7.5/executionHistory/executionHistory.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/executionHistory/executionHistory.lock -------------------------------------------------------------------------------- /.gradle/7.5/fileChanges/last-build.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gradle/7.5/fileHashes/fileHashes.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/fileHashes.bin -------------------------------------------------------------------------------- /.gradle/7.5/fileHashes/fileHashes.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/fileHashes.lock -------------------------------------------------------------------------------- /.gradle/7.5/fileHashes/resourceHashesCache.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/fileHashes/resourceHashesCache.bin -------------------------------------------------------------------------------- /.gradle/7.5/gc.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/7.5/gc.properties -------------------------------------------------------------------------------- /.gradle/buildOutputCleanup/buildOutputCleanup.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/buildOutputCleanup/buildOutputCleanup.lock -------------------------------------------------------------------------------- /.gradle/buildOutputCleanup/cache.properties: -------------------------------------------------------------------------------- 1 | #Sat Jan 14 12:29:56 GMT 2023 2 | gradle.version=7.5 3 | -------------------------------------------------------------------------------- /.gradle/buildOutputCleanup/outputFiles.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/buildOutputCleanup/outputFiles.bin -------------------------------------------------------------------------------- /.gradle/file-system.probe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/file-system.probe -------------------------------------------------------------------------------- /.gradle/vcs-1/gc.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/.gradle/vcs-1/gc.properties -------------------------------------------------------------------------------- /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 | ![Screenshot](androidmusicserverscreenshot.png) 58 | 59 | While the app itself (which will probably never be used, apart from 60 | starting and stopping it) looks like this: 61 | 62 | ![Screenshot](androidmusicserverscreenshot2.png) 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 | 575 | 578 | 581 | 582 | 583 | 586 | 589 | 592 | 593 | 594 | 597 | 600 | 604 | 605 | 606 | 609 | 612 | 615 | 616 | 617 | 620 | 623 | 626 | 627 | 628 | 631 | 634 | 637 | 638 | 639 | 642 | 645 | 648 | 649 | 650 | 653 | 656 | 659 | 660 |
573 | 0.0.8 574 | 576 | January 2023 577 | 579 | Refactored for gradle build and API 31 580 |
584 | 0.0.7 585 | 587 | November 2021 588 | 590 | Improved app screen layout a little 591 |
595 | 0.0.6 596 | 598 | October 2021 599 | 601 | Stopped the app crashing when a genre is "null" in the 602 | media database. 603 |
607 | 0.0.5 608 | 610 | June 12 2019 611 | 613 | Various bug fixes related to later Android releases 614 |
618 | 0.0.4 619 | 621 | April 18 2015 622 | 624 | Added search facility, and preferences page 625 |
629 | 0.0.3 630 | 632 | April 15 2015 633 | 635 | Added on-device status display and controls, and equalizer page 636 |
640 | 0.0.2 641 | 643 | April 12 2015 644 | 646 | Added preliminary cover art, and genre/artist filtering support 647 |
651 | 0.0.1 652 | 654 | April 3 2015 655 | 657 | First release 658 |
661 | 662 | ## Download 663 | 664 | [Download APK](apk/app-debug.apk) (on GitHub, click "View raw" to get the actual APK file) 665 | 666 | -------------------------------------------------------------------------------- /androidmusicserverscreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/androidmusicserverscreenshot.png -------------------------------------------------------------------------------- /androidmusicserverscreenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/androidmusicserverscreenshot2.png -------------------------------------------------------------------------------- /apk/app-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/apk/app-debug.apk -------------------------------------------------------------------------------- /apk/app-release.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/apk/app-release.apk -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | lintOptions { 5 | checkReleaseBuilds false 6 | abortOnError false 7 | } 8 | namespace 'net.kevinboone.androidmediaserver' 9 | compileSdkVersion 19 10 | buildToolsVersion "33.0.1" 11 | 12 | defaultConfig { 13 | applicationId "net.kevinboone.androidmediaserver" 14 | minSdkVersion 14 15 | targetSdkVersion 19 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/assets/docroot/default_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/default_cover.png -------------------------------------------------------------------------------- /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/assets/docroot/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/logo.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/nextbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/nextbutton.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/pausebutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/pausebutton.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/playbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/playbutton.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/prevbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/prevbutton.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/stopbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/stopbutton.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/styles.css: -------------------------------------------------------------------------------- 1 | body 2 | { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | a:link 8 | { 9 | color: #4480b4; 10 | text-decoration: none; 11 | } 12 | 13 | a:visited 14 | { 15 | color: #4480b4; 16 | text-decoration: none; 17 | } 18 | 19 | a:hover 20 | { 21 | color: #cb8a48; 22 | text-decoration: none; 23 | } 24 | 25 | a:active 26 | { 27 | color: #cb8a48; 28 | text-decoration: none; 29 | } 30 | 31 | .bigtime 32 | { 33 | font-family: fixed; 34 | font-size: 200%; 35 | } 36 | 37 | .smalltime 38 | { 39 | font-family: fixed; 40 | } 41 | 42 | #logocell 43 | { 44 | padding-right: 20px; 45 | } 46 | 47 | #transportbuttoncell 48 | { 49 | min-width: 200px; 50 | } 51 | 52 | #trackinfotable 53 | { 54 | padding-left: 5px; 55 | } 56 | 57 | .textbuttonspan 58 | { 59 | font-weight: bold; 60 | font-family: sans-serif; 61 | font-size: smaller; 62 | } 63 | 64 | .pagetitle 65 | { 66 | font-weight: bold; 67 | font-family: sans-serif; 68 | font-size: larger; 69 | padding-bottom: 1em; 70 | } 71 | 72 | .pagesubtitle 73 | { 74 | font-weight: bold; 75 | font-family: sans-serif; 76 | padding-top: 1em; 77 | } 78 | 79 | .pagesubsubtitle 80 | { 81 | font-weight: bold; 82 | font-style: italic; 83 | padding-top: 1em; 84 | } 85 | 86 | .menutable 87 | { 88 | margin: 0; 89 | padding: 0; 90 | width: 100%; 91 | background: black; 92 | background-color: black; 93 | font-weight: bold; 94 | font-family: sans-serif; 95 | } 96 | 97 | 98 | .maincontent 99 | { 100 | margin-left: 2em; 101 | } 102 | 103 | 104 | #searchform 105 | { 106 | padding: 0; 107 | margin: 0; 108 | text-align: right; 109 | color: #4480b4; 110 | font-size: smaller; 111 | font-family: sans-serif; 112 | } 113 | 114 | #searchform input 115 | { 116 | background-color: #444444; 117 | color: #4480b4; 118 | border: solid black; 119 | } 120 | 121 | -------------------------------------------------------------------------------- /app/src/main/assets/docroot/vol_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/vol_down.png -------------------------------------------------------------------------------- /app/src/main/assets/docroot/vol_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/assets/docroot/vol_up.png -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/AndroidNetworkUtil.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.net.wifi.*; 9 | import android.content.Context; 10 | import android.text.format.Formatter; 11 | import java.io.*; 12 | 13 | public class AndroidNetworkUtil 14 | { 15 | /** Try to get the WIFI IP. Returns null if there is not one. I don' t know 16 | how relaible this is, to be honest. */ 17 | public static String getWifiIP (Context c) 18 | { 19 | WifiManager wifiMgr = (WifiManager) 20 | c.getApplicationContext().getSystemService (Context.WIFI_SERVICE); 21 | WifiInfo wifiInfo = wifiMgr.getConnectionInfo(); 22 | if (wifiInfo != null) 23 | { 24 | int ip = wifiInfo.getIpAddress(); 25 | if (ip == 0) return null; 26 | return Formatter.formatIpAddress(ip); 27 | } 28 | else 29 | return null; 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/CoverUtils.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.androidmediaserver; 8 | import java.util.*; 9 | import java.text.*; 10 | import java.io.*; 11 | import java.net.*; 12 | 13 | public class CoverUtils 14 | { 15 | public static String getCoverFileForTrackFile (String filePath) 16 | { 17 | if (filePath.startsWith ("file://")) 18 | filePath = filePath.substring (7); 19 | 20 | int p = filePath.lastIndexOf ('/'); 21 | if (p <= 0) return null; 22 | 23 | filePath = filePath.substring (0, p); 24 | 25 | String cand = makeFile (filePath, "folder.jpg"); 26 | if (doesFileExist (cand)) return cand; 27 | cand = makeFile (filePath, "folder.png"); 28 | if (doesFileExist (cand)) return cand; 29 | cand = makeFile (filePath, "cover.jpg"); 30 | if (doesFileExist (cand)) return cand; 31 | cand = makeFile (filePath, "cover.png"); 32 | if (doesFileExist (cand)) return cand; 33 | cand = makeFile (filePath, ".folder.png"); 34 | if (doesFileExist (cand)) return cand; 35 | cand = makeFile (filePath, ".folder.jpg"); 36 | if (doesFileExist (cand)) return cand; 37 | 38 | return null; 39 | } 40 | 41 | private static String makeFile (String dir, String name) 42 | { 43 | return dir + "/" + name; 44 | } 45 | 46 | private static boolean doesFileExist (String filePath) 47 | { 48 | File f = new File (filePath); 49 | if (f.isFile()) return true; 50 | return false; 51 | } 52 | 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/FileUtils.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.androidmediaserver; 8 | import java.util.Locale; 9 | 10 | public class FileUtils 11 | { 12 | public static String getMimeType (String filename) 13 | { 14 | String lc = filename.toLowerCase(Locale.getDefault()); 15 | if (lc.endsWith (".jpg")) 16 | return "image/jpeg"; 17 | if (lc.endsWith (".png")) 18 | return "image/png"; 19 | if (lc.endsWith (".gif")) 20 | return "image/gif"; 21 | return "application/octet-stream"; 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /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/java/net/kevinboone/androidmediaserver/NanoHTTPD.java: -------------------------------------------------------------------------------- 1 | package net.kevinboone.androidmediaserver; 2 | 3 | import java.io.*; 4 | import java.net.InetAddress; 5 | import java.net.InetSocketAddress; 6 | import java.net.ServerSocket; 7 | import java.net.Socket; 8 | import java.net.SocketException; 9 | import java.net.SocketTimeoutException; 10 | import java.net.URLDecoder; 11 | import java.nio.ByteBuffer; 12 | import java.nio.channels.FileChannel; 13 | import java.text.SimpleDateFormat; 14 | import java.util.ArrayList; 15 | import java.util.Calendar; 16 | import java.util.Date; 17 | import java.util.HashMap; 18 | import java.util.HashSet; 19 | import java.util.Iterator; 20 | import java.util.List; 21 | import java.util.Locale; 22 | import java.util.Map; 23 | import java.util.Set; 24 | import java.util.StringTokenizer; 25 | import java.util.TimeZone; 26 | 27 | /** 28 | * A simple, tiny, nicely embeddable HTTP server in Java 29 | *

30 | *

31 | * NanoHTTPD 32 | *

Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias

33 | *

34 | *

35 | * Features + limitations: 36 | *

60 | *

61 | *

62 | * How to use: 63 | *

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.

423 | */ 424 | public interface TempFileManager { 425 | TempFile createTempFile() throws Exception; 426 | 427 | void clear(); 428 | } 429 | 430 | /** 431 | * A temp file. 432 | *

433 | *

Temp files are responsible for managing the actual temporary storage and cleaning 434 | * themselves up when no longer needed.

435 | */ 436 | public interface TempFile { 437 | OutputStream open() throws Exception; 438 | 439 | void delete() throws Exception; 440 | 441 | String getName(); 442 | } 443 | 444 | /** 445 | * Default threading strategy for NanoHttpd. 446 | *

447 | *

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 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/SettingsActivity.java: -------------------------------------------------------------------------------- 1 | package net.kevinboone.androidmediaserver; 2 | 3 | import android.app.*; 4 | import android.util.Log; 5 | import android.os.*; 6 | import android.view.*; 7 | import android.widget.*; 8 | import android.content.*; 9 | import android.preference.*; 10 | 11 | /** This class defines the Android settings page, such as it is. */ 12 | public class SettingsActivity extends PreferenceActivity 13 | { 14 | @Override 15 | public void onCreate(Bundle savedInstanceState) 16 | { 17 | super.onCreate (savedInstanceState); 18 | addPreferencesFromResource (R.xml.preferences); 19 | } 20 | 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/Version.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.androidmediaserver; 8 | import java.text.*; 9 | import java.util.*; 10 | import java.util.zip.*; 11 | import android.content.*; 12 | import android.content.pm.*; 13 | 14 | public class Version 15 | { 16 | private static final String versionString = "0.0.5"; 17 | 18 | public static String getVersionString () { return versionString; } 19 | 20 | public static String getBuildDateString (Context context) 21 | { 22 | long date = getBuildDate (context); 23 | return new SimpleDateFormat ("yyyy/MM/dd hh:mm", Locale.getDefault()) 24 | .format(date); 25 | } 26 | 27 | public static long getBuildDate (Context context) 28 | { 29 | try 30 | { 31 | ApplicationInfo ai = context.getPackageManager() 32 | .getApplicationInfo(context.getPackageName(), 0); 33 | ZipFile zf = new ZipFile(ai.sourceDir); 34 | ZipEntry ze = zf.getEntry("classes.dex"); 35 | long time = ze.getTime(); 36 | zf.close(); 37 | return time; 38 | } 39 | catch (Exception e) 40 | { 41 | return 0; 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/WebPlayerService.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.Service; 9 | import android.os.*; 10 | import android.view.*; 11 | import android.widget.*; 12 | import android.media.*; 13 | import android.content.*; 14 | import java.io.*; 15 | 16 | public class WebPlayerService extends Service 17 | { 18 | private WebServer server = null; 19 | 20 | @Override 21 | public void onCreate() 22 | { 23 | try 24 | { 25 | server = new WebServer (this); 26 | server.start(); 27 | } 28 | catch (Throwable e) 29 | { 30 | // _Any_ exception thrown out of here will stop the app from running 31 | // at all, so the user will never know what went wrong 32 | e.printStackTrace(); 33 | //throw new RuntimeException (e); 34 | } 35 | } 36 | 37 | @Override 38 | public int onStartCommand(Intent intent, int flags, int startId) 39 | { 40 | return START_STICKY; 41 | } 42 | 43 | @Override 44 | public IBinder onBind (Intent intent) 45 | { 46 | return null; 47 | } 48 | 49 | @Override 50 | public void onDestroy() 51 | { 52 | if (server != null) 53 | server.stop(); 54 | } 55 | 56 | } 57 | 58 | -------------------------------------------------------------------------------- /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/java/net/kevinboone/androidmediaserver/client/ClientException.java: -------------------------------------------------------------------------------- 1 | package net.kevinboone.androidmediaserver.client; 2 | 3 | import java.io.*; 4 | import java.net.*; 5 | 6 | public class ClientException extends Exception 7 | { 8 | public ClientException (String msg) 9 | { 10 | super (msg); 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmediaserver/client/Status.java: -------------------------------------------------------------------------------- 1 | package net.kevinboone.androidmediaserver.client; 2 | 3 | import java.io.*; 4 | import java.net.*; 5 | import org.json.*; 6 | 7 | public class Status 8 | { 9 | protected TransportStatus transportStatus = TransportStatus.UNKNOWN; 10 | protected int position; 11 | protected int duration; 12 | protected String title; 13 | protected String artist; 14 | protected String album; 15 | protected String uri; 16 | 17 | public enum TransportStatus {UNKNOWN, STOPPED, PAUSED, PLAYING}; 18 | 19 | public void setTransportStatus (TransportStatus ts) 20 | { 21 | this.transportStatus = ts; 22 | } 23 | 24 | public TransportStatus getTransportStatus () 25 | { 26 | return transportStatus; 27 | } 28 | 29 | public void setTitle (String s) { title = s; } 30 | public void setUri (String s) { uri = s; } 31 | public void setArtist (String s) { artist = s; } 32 | public void setAlbum (String s) { album = s; } 33 | public void setDuration (int d) { duration = d; } 34 | public void setPosition (int d) { position = d; } 35 | 36 | public String getTitle () { return title; } 37 | public String getUri () { return uri; } 38 | public String getArtist () { return artist; } 39 | public String getAlbum () { return album; } 40 | public int getPosition () { return position; } 41 | public int getDuration () { return duration; } 42 | 43 | 44 | public static String transportStatusToString (TransportStatus ts) 45 | { 46 | switch (ts) 47 | { 48 | case STOPPED: return "stopped"; 49 | case PAUSED: return "paused"; 50 | case PLAYING: return "playing"; 51 | } 52 | return "unknown"; 53 | } 54 | 55 | public String toString() 56 | { 57 | StringBuffer sb = new StringBuffer(); 58 | sb.append ("Transport status: "); 59 | sb.append (transportStatusToString (transportStatus)); 60 | sb.append ("\n"); 61 | sb.append ("Title: "); 62 | sb.append (title); 63 | sb.append ("\n"); 64 | sb.append ("Album: "); 65 | sb.append (album); 66 | sb.append ("\n"); 67 | sb.append ("Artist: "); 68 | sb.append (artist); 69 | sb.append ("\n"); 70 | sb.append ("Position: "); 71 | sb.append ("" + position); 72 | sb.append ("\n"); 73 | sb.append ("Duration: "); 74 | sb.append ("" + duration); 75 | sb.append ("\n"); 76 | return new String (sb); 77 | } 78 | 79 | 80 | } 81 | 82 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/AlreadyAtEndOfPlaylistException.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 | 9 | public class AlreadyAtEndOfPlaylistException extends PlayerException 10 | { 11 | public AlreadyAtEndOfPlaylistException () 12 | { 13 | super (Errors.ERR_ALREADY_AT_END_OF_PLAYLIST); 14 | } 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/AlreadyAtStartOfPlaylistException.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 | 9 | public class AlreadyAtStartOfPlaylistException extends PlayerException 10 | { 11 | public AlreadyAtStartOfPlaylistException () 12 | { 13 | super (Errors.ERR_ALREADY_AT_START_OF_PLAYLIST); 14 | } 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/AndroidEqUtil.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 | 9 | 10 | public class AndroidEqUtil 11 | { 12 | /** Formats the frequency range as bizarrely reported by the EQ 13 | * API into something readable. */ 14 | public static String formatBandLabel (int[] band) 15 | { 16 | return milliHzToString(band[0]) + "-" + milliHzToString(band[1]); 17 | } 18 | 19 | 20 | private static String milliHzToString (int milliHz) 21 | { 22 | if (milliHz < 1000) return ""; 23 | if (milliHz < 1000000) 24 | return "" + (milliHz / 1000) + "Hz"; 25 | else 26 | return "" + (milliHz / 1000000) + "kHz"; 27 | } 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/Errors.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.androidmusicplayer; 7 | 8 | /** This class contains error codes and methods to format then as text. */ 9 | public class Errors 10 | { 11 | public static final int ERR_OK = 0; 12 | public static final int ERR_PL_EMPTY = 1; 13 | public static final int ERR_PL_RANGE = 2; 14 | public static final int ERR_NO_ALBUMS = 3; 15 | public static final int ERR_ALREADY_AT_START_OF_PLAYLIST = 4; 16 | public static final int ERR_ALREADY_AT_END_OF_PLAYLIST = 5; 17 | public static final int ERR_IO = 6; 18 | 19 | /** Gets the string corresponding to an error code. */ 20 | public static String perror (int errorCode) 21 | { 22 | switch (errorCode) 23 | { 24 | case ERR_OK: return "OK"; 25 | case ERR_PL_EMPTY: return "Playlist empty"; 26 | case ERR_PL_RANGE: return "Playlist index out of range"; 27 | case ERR_NO_ALBUMS: return "No albums found in media catalogue"; 28 | case ERR_ALREADY_AT_START_OF_PLAYLIST: 29 | return "Already at start of playlist"; 30 | case ERR_ALREADY_AT_END_OF_PLAYLIST: 31 | return "Already at end of playlist"; 32 | case ERR_IO: 33 | return "IO Error"; 34 | } 35 | return "Unknown error"; 36 | } 37 | 38 | 39 | 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /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/PlayerException.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 | 9 | public class PlayerException extends Exception 10 | { 11 | private int code = 0; 12 | 13 | public PlayerException (int code, String message) 14 | { 15 | super (message); 16 | this.code = code; 17 | } 18 | 19 | public PlayerException (int code) 20 | { 21 | super (Errors.perror (code)); 22 | this.code = code; 23 | } 24 | 25 | public int getCode() { return code; } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/PlayerIOException.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 | 9 | public class PlayerIOException extends PlayerException 10 | { 11 | public PlayerIOException (String message) 12 | { 13 | super (Errors.ERR_IO, message); 14 | } 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/PlaylistEmptyException.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 | 9 | public class PlaylistEmptyException extends PlayerException 10 | { 11 | public PlaylistEmptyException () 12 | { 13 | super (Errors.ERR_PL_EMPTY); 14 | } 15 | 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/PlaylistIndexOutOfRangeException.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 | 9 | public class PlaylistIndexOutOfRangeException extends PlayerException 10 | { 11 | public PlaylistIndexOutOfRangeException () 12 | { 13 | super (Errors.ERR_PL_RANGE); 14 | } 15 | 16 | } 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/RemoteControlReceiver.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.io.*; 10 | import java.net.*; 11 | import android.content.*; 12 | import android.media.MediaPlayer; 13 | import android.util.Log; 14 | import android.view.*; 15 | import android.media.*; 16 | import android.annotation.*; 17 | 18 | @SuppressLint("StaticFieldLeak") 19 | public class RemoteControlReceiver extends BroadcastReceiver 20 | { 21 | protected static Player player = null; 22 | 23 | public static void setPlayer (Player _player) 24 | { 25 | player = _player; 26 | } 27 | 28 | @Override 29 | public void onReceive(Context context, Intent intent) 30 | { 31 | try 32 | { 33 | if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) 34 | { 35 | KeyEvent event = (KeyEvent)intent.getParcelableExtra 36 | (Intent.EXTRA_KEY_EVENT); 37 | if (event.getAction() == event.ACTION_DOWN) 38 | { 39 | if (KeyEvent.KEYCODE_MEDIA_PLAY == event.getKeyCode()) 40 | { 41 | player.play(); 42 | } 43 | else if (KeyEvent.KEYCODE_MEDIA_NEXT == event.getKeyCode()) 44 | { 45 | player.playNextInPlaylist(); 46 | } 47 | else if (KeyEvent.KEYCODE_MEDIA_PAUSE == event.getKeyCode()) 48 | { 49 | player.pause(); 50 | } 51 | else if (KeyEvent.KEYCODE_MEDIA_STOP == event.getKeyCode()) 52 | { 53 | player.stop(); 54 | } 55 | else if (KeyEvent.KEYCODE_MEDIA_PREVIOUS == event.getKeyCode()) 56 | { 57 | player.playPrevInPlaylist(); 58 | } 59 | } 60 | } 61 | } 62 | catch (PlayerException e) 63 | { 64 | Log.w ("AMS", "Caught exception in remote control handler: " + e); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/SearchSpec.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 | 9 | public class SearchSpec 10 | { 11 | private String text; 12 | 13 | public SearchSpec (String text) 14 | { 15 | this.text = text; 16 | } 17 | 18 | public String getText() { return text; } 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/androidmusicplayer/TrackInfo.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 | 9 | /** A simple data structure for audio track information. */ 10 | public class TrackInfo 11 | { 12 | public String uri; 13 | public String title; 14 | public String artist; 15 | public String album; 16 | public String composer; 17 | public String trackNumber; 18 | public String genre; 19 | 20 | /** Constructor takes a uri argument to ensure that at least the uri is 21 | set, even if nothing else is. */ 22 | public TrackInfo (String uri) 23 | { 24 | this.uri = uri; 25 | } 26 | 27 | private TrackInfo () 28 | { 29 | } 30 | 31 | /** If we don't have a proper title for the item, strip the path and 32 | extension from the URI and use that instead. */ 33 | public static String makeTitleFromUri (String uri) 34 | { 35 | int p = uri.lastIndexOf ('/'); 36 | if (p >= 0) 37 | uri = uri.substring (p + 1); 38 | p = uri.lastIndexOf ('.'); 39 | if (p > 0) 40 | uri = uri.substring (0, p); 41 | return uri; 42 | } 43 | 44 | } 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/java/net/kevinboone/textutils/EscapeUtils.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.textutils; 8 | 9 | public class EscapeUtils 10 | { 11 | /** For data sent back to the browser, which will have strings enclosed 12 | in single quotes, convert ' to \'. */ 13 | public static String escapeJSON (String s) 14 | { 15 | StringBuffer sb = new StringBuffer(); 16 | int l = s.length(); 17 | for (int i = 0; i < l; i++) 18 | { 19 | char c = s.charAt(i); 20 | if (c == '\'') 21 | sb.append ("\\'"); 22 | else 23 | sb.append (c); 24 | } 25 | return new String (sb); 26 | } 27 | 28 | /** Escape single-quotes in SQL data values. */ 29 | public static String escapeSQL (String s) 30 | { 31 | StringBuffer sb = new StringBuffer(); 32 | int l = s.length(); 33 | for (int i = 0; i < l; i++) 34 | { 35 | char c = s.charAt(i); 36 | if (c == '\'') 37 | sb.append ("''"); 38 | else 39 | sb.append (c); 40 | } 41 | return new String (sb); 42 | } 43 | 44 | } 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-ldpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-ldpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_next.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_pause.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_play.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_prev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_prev.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_settings.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_shutdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_shutdown.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/button_stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/button_stop.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/app/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/res/raw/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Android music player 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 19 | 22 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | 63 | 88 | 89 |
42 | 43 | 45 | 46 | 47 | 51 | 52 | 53 | 60 | 61 |
48 | 49 | 50 |
54 | 55 | 56 | 57 | 58 | 59 |
62 |
64 | 65 | 66 | 69 | 70 | 71 | 74 | 75 | 76 | 79 | 80 | 81 | 84 | 85 |
67 | 68 |
72 | 73 |
77 | 78 |
82 | 83 |
86 | 87 |
90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 | 98 |
99 |
100 | 101 | %%BODY%% 102 | 103 |
104 | Home | 105 | Files | 106 | Albums | 107 | Genres | 108 | Artists | 109 | Playlist 110 |

111 | 112 |

113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Android Music Server 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | api.example.com(to be adjusted) 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/xml/preferences.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 17 | 18 | 27 | 28 | 37 | 38 | 47 | 48 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | repositories { 4 | google() 5 | maven { 6 | url "https://maven.google.com" 7 | } 8 | jcenter() 9 | } 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:7.4.0' 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | google() 18 | jcenter() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /fastlane/metadata/android/de/short_description.txt: -------------------------------------------------------------------------------- 1 | ein Webinterface zum Abspielen von Musik auf Android-Geräten -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Kevin's Music Server for Android provides a web browser interface to control playback of audio files stored on most modern (4.x-11.0) Android devices. This allows the Android device to be used as a music server, in multi-room audio applications, among other things. I normally keep my Android phone docked, with a permanent USB connection to an audio DAC. This arrangement produces good quality audio playback, but I don't always have the pone within reach. It's awkward to fiddle with the little screen when it's docked, anyway. Providing a web interface – albeit a crude one – allows me to control playback from a web browser. 2 | 3 | Audio tracks can be selected using the browser from a list of albums, or directly from the filesystem (but see notes below). You can restrict the album listing to particular genres or particular artists rather than displaying all albums on the same page. Album cover art images can be displayed. 4 | 5 |
Features: 6 | 7 | * Simple web interface – works on most desktop web browsers and many mobile browsers 8 | * Integrates with the Android media catalogue – browse by album, artist, genre, composer, or track 9 | * Supports file/folder browsing 10 | * Media catalogue text search 11 | * Equalizer 12 | * Cover art (both baked-in and album-folder images) 13 | * Playback control by headset or remote control 14 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.jpg -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/fastlane/metadata/android/en-US/images/phoneScreenshots/androidmusicserverscreenshot2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | a web interface to music playback on Android devices -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinboone/androidmusicserver/9a559806f3555b1ad7afac7b46b5862e47cfbc46/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Jan 14 11:39:33 GMT 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /local.properties: -------------------------------------------------------------------------------- 1 | ## This file must *NOT* be checked into Version Control Systems, 2 | # as it contains information specific to your local configuration. 3 | # 4 | # Location of the SDK. This is only used by Gradle. 5 | # 6 | #Sat Jan 14 11:39:33 GMT 2023 7 | sdk.dir=/home/kevin/Android/Sdk 8 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------