diff --git a/.classpath b/.classpath deleted file mode 100644 index d0073947d..000000000 --- a/.classpath +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..3071e5de4 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,17 @@ +name: Android CI + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: Build with Gradle + run: ./gradlew build diff --git a/.gitignore b/.gitignore index d9e0f5e7d..302bdc329 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,22 @@ .\#* *~ *.apk +build bin gen +out local.properties build.properties -src/com/danga/squeezer/IServiceCallback.java -src/com/danga/squeezer/ISqueezeService.java -src/com/danga/squeezer/R.java +squeezer.properties +crashlytics.properties +key.json +key.p12 +.classpath +/ant.properties +.settings/ +*.iml +*.patch +.idea +.gradle +com_crashlytics_export_strings.xml +.directory diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..39c989494 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "libs/eventbus"] + path = libs/eventbus + url = https://github.com/yuzeh/EventBus.git diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs deleted file mode 100644 index 92a5637b7..000000000 --- a/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,5 +0,0 @@ -#Sun Aug 09 17:35:43 PDT 2009 -eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.5 -org.eclipse.jdt.core.compiler.compliance=1.5 -org.eclipse.jdt.core.compiler.source=1.5 diff --git a/.tx/config b/.tx/config new file mode 100644 index 000000000..d770c755c --- /dev/null +++ b/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com + +[squeezer.stringsxml] +file_filter = Squeezer/src/main/res/values-/strings.xml +lang_map = da_DK:da, de_DE:de, en_GB: en-rGB, nl_NL:nl +source_file = Squeezer/src/main/res/values/strings.xml +source_lang = en +type = ANDROID + diff --git a/AndroidManifest.xml b/AndroidManifest.xml deleted file mode 100644 index 2a346424a..000000000 --- a/AndroidManifest.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..eb0268d2d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,138 @@ +# How to contribute + +Your contributions to Squeezer are very welcome. Exactly how to do that +depends on what you want to do. + +## Reporting bugs and feature requests + +Please use the +[issues page](https://github.com/nikclayton/android-squeezer/issues) to +report bugs or suggest new features. + +It's appreciated if you take the time to see if someone else has already +reported it, and if so, add a comment to their note. + +## Translations + +The easiest way to contribute, especially if you are not a programmer, +is to help translate Squeezer's interface in to different languages. + +There's more information on how to do that at +[Translating Squeezer](https://github.com/nikclayton/android-squeezer/wiki/Translating-Squeezer). + +## Small bug fixes + +If you've discovered a bug and want to fix it, or you'd like to have a go +at fixing a bug that's already been reported, please go right ahead. + +You can also review the +[list of open bugs](https://github.com/nikclayton/android-squeezer/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3Abug) +if you want inspiration for something to work on. + +Please see the [Co-ordination](#Co-ordination) section if you think the +fix is going to be particularly complex, or if it touches lots of +files. The [How to contribute code](#How-to-contribute-code) section +has technical details on how to contribute code. + +## Larger features + +Contributing larger features to Squeezer is also very welcome. For these +please review the [Co-ordination](#Co-ordination) section, and let us +know what you plan on working on, so we don't end up duplicating too much +effort. + +Please see the [How to contribute code](#How-to-contribute-code) section +for technical details. + +## Co-ordination + +There's a mailing list, android-squeezer@googlegroups.com. + +To subscribe, go to the +[web interface](https://groups.google.com/forum/#!forum/android-squeezer). + +Say "Hi", and let us know what you'd like to work on. + +## How to contribute code + +This guide assumes you have already downloaded and installed the Android +SDK, in to a directory referred to as $SDK. + +### Fetch the code + +Follow [GitHub's instructions](https://help.github.com/articles/fork-a-repo) +for forking the repository. + +### Checkout the code + +We (roughly) follow the branching model laid out in the +[A successful Git branching model](http://nvie.com/posts/a-successful-git-branching-model/) +blog post. + +Specifically: + +* The `master` branch is generally kept pristine. No development work + happens here. + +* The `develop` branch is for small bug fixes or other cleanups that need + no more than a single commit to complete. + +* All other work happens on separate branches, branched from `develop`. + +* When those branches are complete and ready for release they are merged on + `develop`. + +* New releases are prepared by creating a release branch from `develop` and + working there, before merging changes from the release back to `develop` + and `master`. + +### Starting work + +Suppose you want to start work on contributing a new feature. After fetching +the repository checkout a new local branch from develop with: + + git checkout -b new-branch-name develop + +Then work on `new-branch-name`, pushing it up to GitHub as appropriate. Feel +free to have discussions on the mailing list as you're working. + +### Keeping up to date with `develop` + +As you're working other changes may be happening on the origin `develop` +branch. Please use `git rebase` to pull in changes from `develop` to ensure +your branch is up to date and that the future merge back in to `develop` is +straightforward. To do that (assuming you have no open changes on your +current branch): + +``` +git checkout develop # Checkout the develop branch +git pull # Fetch the most recent code +git checkout - # Checkout the previous branch +git rebase develop # Rebase current branch from develop +``` + +### Android Studio configuration + +* Run Android Studio + +* If you have no projects open then choose "Import project" from the dialog + that appears. + + If you already have a project open then choose File > Import Project... + +* In the "Select File or Directory to Import" dialog that appears, navigate + to the directory that you fetched the Squeezer source code in to and + select the build.gradle file that ships with Squeezer. + +* In the "Import Project from Gradle" dialog tick "Use auto-import" and + make sure that "Use gradle wrapper (recommended)" is selected. + +* Copy `ide/intellij/codestyles/AndroidStyle.xml` to Android Studio's config + directory. + + - Linux: `~/.AndroidStudioPreview/config/codestyles` + - OS X: `~/Library/Preferences/AndroidStudioPreview/codestyles` + - Windows: `~/.AndroidStudioPreview/config/codestyles` + +* Go to Settings (or Preferences in Mac OS X) > Code Style > Java, select + "AndroidStyle", as well as Code Style > XML and select "AndroidStyle". diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c33078f35 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. \ No newline at end of file diff --git a/NEWS b/NEWS new file mode 100644 index 000000000..eabbe1f4c --- /dev/null +++ b/NEWS @@ -0,0 +1,615 @@ +2.2.1 +===== + +* Current playlist control; Possibility to undo a clear current playlist. + Remove a track by swiping left or right. Touch and hold to move a track + +* Download confirmation and option to disable downloads + +* Fix an issue with where Android 10 would falsely adds .mp3 to filenames + +* Work around an issue with recent Spotty versions, where only one search + result is displayed + +* Switch radio buttons properly + +* Bug and stability fixes + + +2.2.0 +===== + +* Download of local music + +* Automatic connection on app startup + +* Home Screen Widget, contributed by Charlie Hayes + +* Material Design + +* Support RTL interface + + +2.1.0 +===== + +* Button for global search in the top bar. + +* Access current playlist and volume control from the Now Playing screen. + +* Radial volume control to enable finer control of the volume. + +* Navigate back from the Now Playing or current playlist screens via the + home button or swipe down. To indicate the function of the home button, + specifically that it doesn't navigate home, set a close icon for the + current playlist and a down arrow for the now playing screen. + +* Control LMS settings playtrackalbum and defeatDestructiveTouchToPlay. + These settings control what happens when you select a playable item. + Access via three-dot menu on the players screen. + +* Work around issues in LMS which causes the context menu and + defeatDestructiveTouchToPlay to not work properly. + + +2.0.1 +===== + +* Separate list layout settings for home menu items and Slimbrowseitems. + +* Notification shows button for next song instead of the previous one. + +* Fix artwork display in the Spotty plugin. + +* Option to easy switch between list and grid layouts. + +* Ignore icon based popup message when in the Now Playing screen. + +* Handle invalid server address. + +* Fix some reported crashes. + + +2.0.0 +===== + +* Rewrite Squeezer to use the more modern CometD protocol to connect to the + server. + +* Full support for Apps/Plugins. + +* Connect to mysqueezebox.com. + +* Additional browse modes, Album Artists, All Artists and Composers. + +* Support for extra information and options for Artists/albums/Tracks via + the context menu. + +* Library Views and Remote Music Libraries. + +* Add French translation. Contributed by Olivier Sirol. + + +1.5.4 +===== + +* Show a button on the notification to disconnect Squeezer (and remove the + notification). + +* Disconnect Squeezer when it's swiped from the recent apps list. + + +1.5.3 +===== + +* Always show a notification while connected to a server. This is a + requirement of Android. + +* Show lyrics if provided by the server. + +* Show artist instead of album in notifications. + +* Fix race condition in display of favorite. + +* Fix display problems on earlier Android versions. + + +1.5.2 +===== + +* Update to support Android Pie. + +* Support notification settings. + +* Ask for permissions at runtime. + +* Add/remove music from favorites. + + +1.5.1 +===== + +* Fix assorted potential crashes. + + +1.5.0 +===== + +* Download enhancements: choose whether to store downloaded music on/off the + device's removable storage (if it has one); choose the folder structure + that's used. + +* Enable next/previous track support on some streaming services, like + Spotify. Submitted by gerrelt_m@hotmail.com. + +* Include artist information in the "Now playing" section. Submitted by + Frieder Schrempf + +* Support searching in plugins that provide that feature. Submitted by + original@flautz.de. + +* Include additional music player controls on devices with larger screens, + or when in landscape orientation. + +* Sort the list of players alphabetically. Submitted by + gerrelt_m@hotmail.com. + +* Fixed an issue where Squeezer would appear to hang after a period of + inactivity on Android devices that support Doze mode. + +* Fixed memory leak on devices running Android Marshmallow and below. + +* Increase the amount of space that can be used by the image cache. + +* Assorted small UI fixes and improvements. + + +1.4.1 +===== + +* Fixed a crash when scrobbling song information. Contributed by + HumbleBeeBumbleBeeDebugs@gmail.com. + +* Workaround an issue on some devices where the currently playing item is + not highlighted in the playlist. Also add menu item to select the current + song. Contributed by spiritcroc@gmail.com. + +* Fixed a crash when retrieving playlists for alarms. + +* Fixed poor UI in Internet Radio search. + +* Show search results in a list or a grid depending on user preferences. + +* Show a dialog to allow the user to choose what happens when tapping a song + or an album. + +* Provide "Play from here" option when looking at albums, playlists, + artists, genres, and years. + +* Provide an option to disable notifications entirely. + + +1.4.0 +===== + +* New support for managing alarms on players. Most of the code by Kurt. + +* Improved the support for apps, contributed by Christer Wendel. + +* Reduced risk of out-of-memory crashes when loading large album artwork. + +* Fixed a bug that prevented changing a player's sleep time. + +* Fixed a crash on Samsung devices. + +* Fixed potential crash when viewing the list of players. + + +1.3.1 +===== + +* Remote and plugin song artwork is now supported in notification messages, + based on code submitted by Christer Wendel, + https://github.com/nikclayton/android-squeezer/pull/128. + +* A partial translation in to Turkish, submitted by + https://www.transifex.com/accounts/profile/echelon/. + +* Improved the efficiency of the image cache. + +* Fixed UI issue with spinners on older devices. + +* Fixed issue with track information not updating on some devices when the + song changes, https://github.com/nikclayton/android-squeezer/issues/125. + +* Fixed a crash on rooted devices. + +* Fixed bug where the UI would not update after selecting a different player + (https://github.com/nikclayton/android-squeezer/issues/121). + +* Fixed crash caused by race condition when connecting to a server. + + +1.3.0 +===== + +* Support richer notifications with playback controls from the notification + and lock screen. Based on work from Matthew Schmidt + (https://github.com/maxpower47). Includes support for notifications on + Android Wear devices. + +* Fixed bug where song artwork could stop loading. + +* Fixed crash that could occur when scanning for servers. + +* Fixed issue where opening a playlist and leaving Squeezer while music is + playing would result in the playlist being at the wrong point when + returning to Squeezer. + + +1.2.7 +===== + +* Support skipping tracks in remote streams that have that feature, such as + Slacker Radio. Code from Sean Wilson (https://github.com/swilson). + +* Simplified the configuration process for new users. Squeezer now + automatically searches for servers on the local network, saves connection + information per-server, and numerous other fixes. + +* Changed saving files to remove non-VFAT compatible characters from + filenames to make it easier to export them to Windows and similar systems. + +* Fixed issue where some plugin icons were not showing. + +* Fixed issue where Internet radio stations were not working when connected + to servers running version 7.8 or above. + +* Fixed issue where the UI could stop showing player progress. + + +1.2.6 +===== + +* Better handle the connection lifecycle with the server, which should + reduce opportunities for crashing. + +* Add theme-specific icons for the view-option dialogs. + +* Bring the Danish translation up to date. + + +1.2.5 +===== + +* Fixed potential crash if the connected players change while a player's + context menu is open. + +* Fixed potential crash when setAdapter() is called from the wrong thread. + + +1.2.4 +===== + +* Fixed potential crash when resuming Squeezer. + +* Fixed bug with players not showing on devices running Android 2.1 and + 2.2.x. + + +1.2.3 +===== + +* Fixed bug that hid playlist management options. + +* Fixed crash when all players disconnect and a player context menu is open. + +* Fixed tracknames not scrolling properly on the "Now playing" screen. + +* Fixed crash if a player's identifier is not its MAC address. + +* Fixed crashes caused by initialising the UI before the connection to the + server completes. + + +1.2.2 +===== + +* Fixed potential crash after connecting to the server. + +* Fixed potential crash downloading music folder items from servers older + than version 7.6. + +* Fixed potential crash when viewing the current playlist. + + +1.2.1 +===== + +* Fixed potential crash when the notification message is updated. + +* Fixed potential crash when disconnecting and reconnecting to the server. + + +1.2.0 +===== + +* Support creating and managing groups of players. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=4. + +* Change the default colour theme to light with a dark action bar. + +* Fixed a bug where returning to Squeezer from some other application (using + the "Recent Apps" button) would result in incomplete information being + displayed. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=79. + +* Fixed potential crash when no players are connected to the server. + + +1.1.1 +===== + +* Fixed potential crash when browsing music folders. + + +1.1.0 +===== + +* Support for "My Apps" installed via mysqueezebox.com. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=26, + https://code.google.com/p/android-squeezer/issues/detail?id=46. + +* Show the current player name in the Action Bar at the top of the screen, + with a drop-down menu allowing you to easily switch between players. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=53. + +* Enhance the display of available players. You can now rename a player, + adjust the volume, set a sleep duration, and power on/off players directly + from the list. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=47, + https://code.google.com/p/android-squeezer/issues/detail?id=63, + https://code.google.com/p/android-squeezer/issues/detail?id=64. + +* Support downloading all songs by album, artist, genre, year, or music + folder. This uses features only available in Android 2.3 (Gingerbread) and + above. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=55. + +* Add newly downloaded files to the device's media library when they are + downloaded. + +* Integration with SqueezePlayer (http://www.squeezeplayer.com/). Squeezer + can automatically start SqueezePlayer when Squeezer is started. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=48. + +* A grid layout when viewing the song list on larger devices. + +* When viewing albums containing songs by a searched-for artist, filter the + list of displayed songs to show those by that artist. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=51. + +* The Dutch translation is fully up to date, thanks to spamba@gmail.com. + +* Save album sort-order preference on the server. + +* Fixed a bug where Squeezer was forgetting the active player. + +* Fixed a bug where Squeezer was not updating the power status of a + powered-off player. + +* Fixed a bug where song information for songs being remotely streamed would + not update when the song changes. + +* Fixed a bug where the current playlist was not updated after the current + player changed. + +* Fixed a bug when determining whether album artwork is available. + +* Fixed https://code.google.com/p/android/issues/detail?id=63570, a bug in + Android 4.4.2 when tasks are re-ordered. + +* Internal restructuring to make Squeezer faster and use less memory. + +* Fixed assorted null pointer bugs. + +* Use Crashlytics instead of ACRA for crash reporting. + + +1.0.2 +===== + +* Include a small progress indicator just above the mini-player. + +* Tapping items when browsing music folders will follow your song + preferences. Fixes + https://code.google.com/p/android-squeezer/issues/detail?id=52. + +* Added an option (disabled by default) to include your e-mail address in + error reports. If enabled and you send an error report the developers may + contact you for more details. + +* Fixed a crash where beginBroadcast() was being called twice. + + +1.0.1 +===== + +* Fixed crash after a fragment was detached from an activity. + +* Fixed crash when zero plugin items are returned by the SqueezeServer. + +* Fixed crash when an IO error occurs processing artwork. + +* Fixed crash when some versions of the SqueezeServer don't return play + state (play, pause, stop). + + +1.0.0 +===== + +* A complete refresh of the UI to match modern Android guidelines. The logo + is by Reddit user http://www.reddit.com/user/PdtS. + +* A persistent mini-player on all screens (see what's playing, quickly + pause/play the track). + +* A grid layout when viewing albums on larger devices (and it's selectable + on smaller devices). + +* Moving through the UI is now smoother than ever. + +* Viewing an album shows all the tracks on that album, with additional track + information. + +* User preferences to control the order in which albums, tracks, and other + information is shown. + +* Proper support for orientation changes -- flipping your device no longer + sends you back to the start of the list. + +* The action to take when tapping an item (like a song or an album) is now + configurable -- no more inadvertently tapping a song and having it start + playing if that's not what you want. + +* Voice support for searches. + +* A German translation, contributed by Olaf Sebelin + (https://github.com/osebelin). + +* Login support, for password protected servers. + +* Only show the "Tips" dialog after successfully connecting to a server. + +* This change log view + + +0.9.1 +===== + +* Disable song download functionality when not connected to a server. + +* Disable "Download playing song" functionality when no song is playing. + +* Fixed crash when scanning for servers on Android devices running + Honeycomb. + +* Reduced (possibly eliminated) the potential for out-of-memory errors on + low-end devices when downloading album artwork. + +* Improvements to smoothness when scrolling through lists that show album + artwork. + + +0.9 +=== + +* Support for browsing and playing "New Music" on the Squeezeserver. + +* You can now download tracks to your device from the "Now Playing", "Music + Folders" screens, and everywhere a song is shown. + +* Significantly sped up downloading album artwork from the Squeezeserver, + and made scrolling through songs and albums much smoother. + +* Highlight the currently playing track when viewing playlists. + +* Simplified options when scrobbling, and link to suitable apps in Google + Play if the user tries to scrobble without an app installed. + +* Fixed a memory leak when scanning for servers, and made the scanning + faster. + +* Fixed a bug when moving songs in playlists. + + +0.8 +=== + +* Support browsing and playing favourites on the Squeezeserver. + +* Add a startup tip about how to control volume. + +* Fixed a crash caused by trying to scrobble when a song wasn't playing. + + +0.7.1 +===== + +* Increased the speed of network scanning by a factor of ~ 12. If multiple + servers are present on the network then show their configured names, when + choosing between them, instead of IP addresses. + +* Added a Dutch translation, contributed by Sebastian van Winkel. + +* Fixed a bug where changing the server's address would not be picked up, + and Squeezer would continue to try and use the old server address. + +* Fixed a crash when the device has no active network connections. + +* Fixed a crash when displaying the "Enabling Wi-Fi" dialog. + + +0.7 +=== + +* Added support for browsing the Squeezeserver's music folder (if the server + includes this feature). + +* Added support for scanning the local network to find compatible + Squeezeservers, as an alternative to entering IP addresses by hand. + +* Fixed a crash caused by pressing the "back" key while the "Connecting..." + dialog was showing. + +* Fixed a crash when renaming or deleting playlists. + +* Fixed a bug where duplicated items might appear on the menu. + + +0.6.2 +===== + +* Fixed crashes on orientation change, and assorted Honeycomb crashes after + incorrect API selection. + + +0.6.1 +===== + +* Included higher resolution icons for devices with a higher display + density, such as the Galaxy Nexus. + +* Fixed a rare crash when releasing the Wi-Fi lock. + +* The hardware volume controls now work from all activities, not just the + Now Playing activity. This fixes + http://code.google.com/p/android-squeezer/issues/detail?id=20. + +* The "Settings" menu option is now visible in all activities, not just the + Now Playing activity. This fixes + http://code.google.com/p/android-squeezer/issues/detail?id=23. + + +0.6 +=== + +* Browse the library: Full support for browsing by artist, album, song, + genre, or year. Also use Internet radio stations. + +* Search: Search your library by artist, album, song, and genre. + +* Playlists: Create, modify, and delete playlists on the server. + +* Scrobble the currently playing track to Last.fm. + +* "Now playing" UI: Larger album artwork, and the current artist and album + are now links to searches for more songs by the same artist / from the + same album. There is also a "landscape" optimised UI. + +* "Scrub" through the currently playing track using the seekbar. + +* Control the player's volume using your device's hardware volume keys. + +* Error reporting. If Squeezer encounters an error you can (optionally) + submit an error report to the developers. \ No newline at end of file diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 000000000..935f739b5 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,435 @@ +# Squeezer Privacy Policy + +This Application collects some Personal Data from its Users. + +## Owner and Data Controller + +Squeezer Open Source Project + +*Owner contact email*: privacy@squeezer.org.uk + +## Types of Data collected + +Among the types of Personal Data that this Application collects, by +itself or through third parties, there are: Cookies, Usage Data, +unique device identifiers for advertising (Google Advertiser ID or +IDFA, for example) and geographic position. + +Complete details on each type of Personal Data collected are provided +in the dedicated sections of this privacy policy or by specific +explanation texts displayed prior to the Data collection. Personal +Data may be freely provided by the User, or, in case of Usage Data, +collected automatically when using this Application. + +Unless specified otherwise, all Data requested by this Application is +mandatory and failure to provide this Data may make it impossible for +this Application to provide its services. In cases where this +Application specifically states that some Data is not mandatory, Users +are free not to communicate this Data without consequences to the +availability or the functioning of the Service. + +Users who are uncertain about which Personal Data is mandatory are +welcome to contact the Owner. Any use of Cookies - or of other +tracking tools - by this Application or by the owners of third-party +services used by this Application serves the purpose of providing the +Service required by the User, in addition to any other purposes +described in the present document and in the Cookie Policy, if +available. + +Users are responsible for any third-party Personal Data obtained, +published or shared through this Application and confirm that they +have the third party's consent to provide the Data to the Owner. + +## Mode and place of processing the Data + +### Methods of processing + +The Owner takes appropriate security measures to prevent unauthorized +access, disclosure, modification, or unauthorized destruction of the +Data. + +The Data processing is carried out using computers and/or IT enabled +tools, following organizational procedures and modes strictly related +to the purposes indicated. In addition to the Owner, in some cases, +the Data may be accessible to certain types of persons in charge, +involved with the operation of this Application (administration, +sales, marketing, legal, system administration) or external parties +(such as third-party technical service providers, mail carriers, +hosting providers, IT companies, communications agencies) appointed, +if necessary, as Data Processors by the Owner. The updated list of +these parties may be requested from the Owner at any time. + +### Legal basis of processing + +The Owner may process Personal Data relating to Users if one of the +following applies: + +- Users have given their consent for one or more specific + purposes. Note: Under some legislations the Owner may be allowed to + process Personal Data until the User objects to such processing + ("opt-out"), without having to rely on consent or any other of the + following legal bases. This, however, does not apply, whenever the + processing of Personal Data is subject to European data protection + law; + +- provision of Data is necessary for the performance of an agreement + with the User and/or for any pre-contractual obligations thereof; + +- processing is necessary for compliance with a legal obligation to + which the Owner is subject; + +- processing is related to a task that is carried out in the public + interest or in the exercise of official authority vested in the + Owner; + +- processing is necessary for the purposes of the legitimate interests + pursued by the Owner or by a third party. + +In any case, the Owner will gladly help to clarify the specific legal +basis that applies to the processing, and in particular whether the +provision of Personal Data is a statutory or contractual requirement, +or a requirement necessary to enter into a contract. + +### Place + +The Data is processed at the Owner's operating offices and in any +other places where the parties involved in the processing are located. + +Depending on the User's location, data transfers may involve +transferring the User's Data to a country other than their own. To +find out more about the place of processing of such transferred Data, +Users can check the section containing details about the processing of +Personal Data. + +Users are also entitled to learn about the legal basis of Data +transfers to a country outside the European Union or to any +international organization governed by public international law or set +up by two or more countries, such as the UN, and about the security +measures taken by the Owner to safeguard their Data. + +If any such transfer takes place, Users can find out more by checking +the relevant sections of this document or inquire with the Owner using +the information provided in the contact section. + +### Retention time + +Personal Data shall be processed and stored for as long as required by +the purpose they have been collected for. + +Therefore: + +- Personal Data collected for purposes related to the performance of a + contract between the Owner and the User shall be retained until such + contract has been fully performed. + +- Personal Data collected for the purposes of the Owner's legitimate + interests shall be retained as long as needed to fulfill such + purposes. Users may find specific information regarding the + legitimate interests pursued by the Owner within the relevant + sections of this document or by contacting the Owner. + +The Owner may be allowed to retain Personal Data for a longer period +whenever the User has given consent to such processing, as long as +such consent is not withdrawn. Furthermore, the Owner may be obliged +to retain Personal Data for a longer period whenever required to do so +for the performance of a legal obligation or upon order of an +authority. + +Once the retention period expires, Personal Data shall be +deleted. Therefore, the right to access, the right to erasure, the +right to rectification and the right to data portability cannot be +enforced after expiration of the retention period. + +## The purposes of processing + +The Data concerning the User is collected to allow the Owner to +provide its Services, as well as for the following purposes: Analytics +and Infrastructure monitoring. + +Users can find further detailed information about such purposes of +processing and about the specific Personal Data used for each purpose +in the respective sections of this document. + +## Detailed information on the processing of Personal Data + +Personal Data is collected for the following purposes and using the +following services: + +### Analytics + +The services contained in this section enable the Owner to monitor and +analyze web traffic and can be used to keep track of User behavior. + +#### Google Analytics (Google Inc.) + +Google Analytics is a web analysis service provided by Google +Inc. ("Google"). Google utilizes the Data collected to track and +examine the use of this Application, to prepare reports on its +activities and share them with other Google services. + +Google may use the Data collected to contextualize and personalize the +ads of its own advertising network. + +Personal Data collected: Cookies and Usage Data. + +Place of processing: US - [Privacy +Policy](https://www.google.com/intl/en/policies/privacy/) - [Opt +Out](https://tools.google.com/dlpage/gaoptout?hl=en). + +#### Google Tag Manager (Google) + +Google Tag Manager is an analytics service provided by Google, Inc. +Personal Data collected: Cookies and Usage Data. + +Place of processing: US - [Privacy +Policy](https://www.google.com/intl/policies/privacy/). + +#### Fabric Answers (Google LLC) + +Fabric Answers is an analytics service provided by Crashlytics, a +business division of Google Inc. + +In order to understand Google's use of Data, consult Google's [partner +policy](https://www.google.com/policies/privacy/partners/). This +service is designed for mobile apps analytics and can collect various +information about your phone, highlighted in the Fabric Answers +privacy policy. + + +In particular, this Application uses identifiers for mobile devices +(including Android Advertising ID or Advertising Identifier for iOS, +respectively) and technologies similar to cookies to run the Fabric +Answers service. Fabric Answers may share Data with other tools +provided by Fabric/Crashlytics, such as Crashlytics or Twitter. The +User may check this privacy policy to find a detailed explanation +about the other tools used by the Owner. Users may opt-out of certain +Fabric Answers features through applicable device settings, such as +the device advertising settings for mobile phones or by following the +instructions in other Fabric related sections of this privacy policy, +if available. + +Personal Data collected: Cookies, unique device identifiers for +advertising (Google Advertiser ID or IDFA, for example) and Usage +Data. + +Place of processing: United States - [Privacy +Policy](https://answers.io/img/onepager/privacy.pdf). + +### Infrastructure monitoring + +This type of service allows this Application to monitor the use and +behavior of its components so its performance, operation, maintenance +and troubleshooting can be improved. + +Which Personal Data are processed depends on the characteristics and +mode of implementation of these services, whose function is to filter +the activities of this Application. + +#### Crashlytics (Google Inc.) + +Crashlytics is a monitoring service provided by Google Inc. + +Personal Data collected: geographic position, unique device +identifiers for advertising (Google Advertiser ID or IDFA, for +example) and various types of Data as specified in the privacy policy +of the service. + +Place of processing: US - [Privacy +Policy](https://try.crashlytics.com/terms/privacy-policy.pdf). + +## The rights of Users + +Users may exercise certain rights regarding their Data processed by +the Owner. + +In particular, Users have the right to do the following: + +- **Withdraw their consent at any time.** Users have the right to + withdraw consent where they have previously given their consent to + the processing of their Personal Data. + +- **Object to processing of their Data.** Users have the right to object + to the processing of their Data if the processing is carried out on + a legal basis other than consent. Further details are provided in + the dedicated section below. + +- **Access their Data.** Users have the right to learn if Data is being + processed by the Owner, obtain disclosure regarding certain aspects + of the processing and obtain a copy of the Data undergoing + processing. + +- **Verify and seek rectification.** Users have the right to verify the + accuracy of their Data and ask for it to be updated or corrected. + +- **Restrict the processing of their Data.** Users have the right, under + certain circumstances, to restrict the processing of their Data. In + this case, the Owner will not process their Data for any purpose + other than storing it. + +- **Have their Personal Data deleted or otherwise removed.** Users have + the right, under certain circumstances, to obtain the erasure of + their Data from the Owner. + +- **Receive their Data and have it transferred to another controller.** + Users have the right to receive their Data in a structured, commonly + used and machine readable format and, if technically feasible, to + have it transmitted to another controller without any + hindrance. This provision is applicable provided that the Data is + processed by automated means and that the processing is based on the + User's consent, on a contract which the User is part of or on + pre-contractual obligations thereof. + +- **Lodge a complaint.** Users have the right to bring a claim before + their competent data protection authority. + +### Details about the right to object to processing + +Where Personal Data is processed for a public interest, in the +exercise of an official authority vested in the Owner or for the +purposes of the legitimate interests pursued by the Owner, Users may +object to such processing by providing a ground related to their +particular situation to justify the objection. + +Users must know that, however, should their Personal Data be processed +for direct marketing purposes, they can object to that processing at +any time without providing any justification. To learn, whether the +Owner is processing Personal Data for direct marketing purposes, Users +may refer to the relevant sections of this document. + +### How to exercise these rights + +Any requests to exercise User rights can be directed to the Owner +through the contact details provided in this document. These requests +can be exercised free of charge and will be addressed by the Owner as +early as possible and always within one month. + +## Additional information about Data collection and processing + +### Legal action + +The User's Personal Data may be used for legal purposes by the Owner +in Court or in the stages leading to possible legal action arising +from improper use of this Application or the related Services. + +The User declares to be aware that the Owner may be required to reveal +personal data upon request of public authorities. + +### Additional information about User's Personal Data + +In addition to the information contained in this privacy policy, this +Application may provide the User with additional and contextual +information concerning particular Services or the collection and +processing of Personal Data upon request. + +### System logs and maintenance + +For operation and maintenance purposes, this Application and any +third-party services may collect files that record interaction with +this Application (System logs) use other Personal Data (such as the IP +Address) for this purpose. + +### Information not contained in this policy + +More details concerning the collection or processing of Personal Data +may be requested from the Owner at any time. Please see the contact +information at the beginning of this document. + +### How "Do Not Track" requests are handled + +This Application does not support "Do Not Track" requests. To +determine whether any of the third-party services it uses honor the +"Do Not Track" requests, please read their privacy policies. + +### Changes to this privacy policy + +The Owner reserves the right to make changes to this privacy policy at +any time by giving notice to its Users on this page and possibly +within this Application and/or - as far as technically and legally +feasible - sending a notice to Users via any contact information +available to the Owner. It is strongly recommended to check this page +often, referring to the date of the last modification listed at the +bottom. + +Should the changes affect processing activities performed on the basis +of the User's consent, the Owner shall collect new consent from the +User, where required. + +## Definitions and legal references + +#### Personal Data (or Data) + +Any information that directly, indirectly, or in connection with other +information - including a personal identification number - allows for +the identification or identifiability of a natural person. + +### Usage Data + +Information collected automatically through this Application (or +third-party services employed in this Application), which can include: +the IP addresses or domain names of the computers utilized by the +Users who use this Application, the URI addresses (Uniform Resource +Identifier), the time of the request, the method utilized to submit +the request to the server, the size of the file received in response, +the numerical code indicating the status of the server's answer +(successful outcome, error, etc.), the country of origin, the features +of the browser and the operating system utilized by the User, the +various time details per visit (e.g., the time spent on each page +within the Application) and the details about the path followed within +the Application with special reference to the sequence of pages +visited, and other parameters about the device operating system and/or +the User's IT environment. + +### User + +The individual using this Application who, unless otherwise specified, +coincides with the Data Subject. + +### Data Subject + +The natural person to whom the Personal Data refers. + +### Data Processor (or Data Supervisor) + +The natural or legal person, public authority, agency or other body +which processes Personal Data on behalf of the Controller, as +described in this privacy policy. + +### Data Controller (or Owner) + +The natural or legal person, public authority, agency or other body +which, alone or jointly with others, determines the purposes and means +of the processing of Personal Data, including the security measures +concerning the operation and use of this Application. The Data +Controller, unless otherwise specified, is the Owner of this +Application. + +### This Application + +The means by which the Personal Data of the User is collected and processed. + +### Service + +The service provided by this Application as described in the relative +terms (if available) and on this site/application. + +### European Union (or EU) + +Unless otherwise specified, all references made within this document +to the European Union include all current member states to the +European Union and the European Economic Area. + +### Cookies + +Small piece of data stored in the User's device. + +## Legal information + +This privacy statement has been prepared based on provisions of +multiple legislations, including Art. 13/14 of Regulation (EU) +2016/679 (General Data Protection Regulation). + +This privacy policy relates solely to this Application, if not stated +otherwise within this document. + +Latest update: September 18, 2018 + diff --git a/README.md b/README.md new file mode 100644 index 000000000..c853189f6 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +Squeezer +======== + +Control your SqueezeCenter ("Slimserver") and SqueezeBox (or multiple squeezeboxes) +from your Android phone. + +Features include: + +* Now playing, artwork, seeking within tracks, volume control. + +* Browse the library by album artist, all artists, composers, album, genre, year, playlist, favorites, or new music + +* Browse the music folders (if supported by the server). + +* Full library search. + +* Supports Plugins/Apps + +* Library Views and Remote Music Libraries + +* Download of local music; track, album, artist, genre, year, playlist and music folder + +* Manage players (names, synchronisation groups, sleep, alarms) + +* Internet radio support (browse, staff picks, search). + +* Home Screen Widgets + +* Alarm Clock management + +* Add/remove music from favorites + +* Automatic discovery of local servers + +* Connect to mysqueezebox.com + +* Control playback from your Android Wear device + +Squeezer is free, and open source. For more information, to file a feature request, +or to contribute, see the +[project home page](https://nikclayton.github.io/android-squeezer/). \ No newline at end of file diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 000000000..51b7bc702 --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,82 @@ +# Releasing Squeezer + +## Create a release branch from the develop branch. + +The name of the branch is release-x.y.z, according to the release number. + + git checkout -b release-x.y.z develop + +## Make 1-n releases from the branch + +Repeat the following process for each release. Beta versions are named +x.y.z-beta-n, where n starts at 1. + +### Update the version numbers. + +Edit `Squeezer/build.gradle`. Edit the `versionCode` and `versionName` +values. + +### Update the release notes. + +Edit `Squeezer/src/main/res/xml/changelog_master.xml` with the details. +Run `git log master..develop` to see what's changed + +### Update the `produktion.txt` or `beta.txt` release-note files. + +Run `./gradlew generateWhatsNew` to update the files. + +### Update the `NEWS` file. + +Run `./gradlew generateNews` to update the file. + +### Generate and test the release APK + +From the top level directory, run: + + ./gradlew build + ./gradlew installRelease + +Verify that the version number in the About dialog is correct and that +Squeezer works correctly. + +### Update the screenshots (if necessary). + +Take new screenshots for market/screenshots. + +### Commit the changes + + git commit -a -m "Prepare for release x.y.z." + +### Upload to Google Play (beta, and production) + + ./gradlew publishRelease + +### Upload to Amazon Appstore + +- Go to https://developer.amazon.com/home.html, signed in as + android.squeezer@gmail.com. + +- Find the existing entry for Squeezer, and upload the new APK. + +- Include the contents of `production.txt` for this release in the "Recent Changes" + section. + +## Post production-release steps + +Carry out the following steps when the production release has been posted, +and the release branch is no longer necessary. + +### Merge the changes back to the master branch and tag the release. + + git checkout master + git merge --no-ff release-x.y.z + git tag -a x.y.z -m "Code for the x.y.z release." + +### Merge the changes back to the develop branch. + + git checkout develop + git merge --no-ff release-x.y.z + +### Delete the release branch + + git branch -d release-x.y.z diff --git a/Squeezer/build.gradle b/Squeezer/build.gradle new file mode 100644 index 000000000..9752a8674 --- /dev/null +++ b/Squeezer/build.gradle @@ -0,0 +1,215 @@ +buildscript { + repositories { + maven { url 'https://plugins.gradle.org/m2/' } + } + dependencies { + classpath 'com.github.triplet.gradle:play-publisher:3.0.0' + } +} + +plugins { + id 'com.android.application' + id 'com.github.triplet.play' version '2.3.0' +} + +apply plugin: 'uk.org.ngo.gradle.whatsnew' +apply plugin: 'uk.org.ngo.gradle.slimstrings' + +dependencies { + implementation fileTree(dir: 'libs', include: '*.jar') + implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'androidx.palette:palette:1.0.0' + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10' + + // Android support libraries + // Note: these libraries require the "Google Repository" and "Android + // Support Repository" to be installed via the SDK manager. + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.annotation:annotation:1.1.0' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'com.google.android.material:material:1.2.1' + implementation 'androidx.preference:preference:1.1.1' + + // Third party libraries + implementation 'com.google.guava:guava:28.2-android' + + // findbugs is required for Proguard to work with Guava. + implementation 'com.google.code.findbugs:jsr305:3.0.2' + + // EventBus, https://github.com/greenrobot/EventBus. + implementation 'de.greenrobot:eventbus:2.4.1' + + // Changelogs, see https://github.com/cketti/ckChangeLog. + implementation 'de.cketti.library.changelog:ckchangelog:1.2.0' + + // KitKat time picker + implementation 'com.nineoldandroids:library:2.4.0' + implementation project(':libs:datetimepicker') + + // Radial SeekBar + implementation 'com.sdsmdg.harjot:croller:1.0.7' + + // Comet client + implementation 'org.cometd.java:cometd-java-client:3.1.11' + implementation 'org.slf4j:slf4j-android:1.7.30' + + // JVM tests + testImplementation 'junit:junit:4.12' +} + +android { + compileSdkVersion rootProject.compileSdkVersion + + def gitHash = "git rev-parse --short HEAD".execute().text.trim() + def hasModifiedDeletedOrOtherFiles = !"git ls-files -mdo --exclude-standard".execute().text.trim().isEmpty() + def hasStagedFiles = !"git diff-index --no-ext-diff --name-only --cached HEAD".execute().text.trim().isEmpty() + def dirtyWorkingCopy = hasModifiedDeletedOrOtherFiles || hasStagedFiles + def gitDescription = dirtyWorkingCopy ? "${gitHash}-dirty" : gitHash + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + + buildConfigField "String", "GIT_DESCRIPTION", "\"${gitDescription}\"" + + versionCode 89 + versionName "2.2.1" + + vectorDrawables.useSupportLibrary = true + multiDexEnabled true + } + + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + // Downgrade missing translations to non-fatal severity. + warning 'MissingTranslation' + warning 'ImpliedQuantity' + lintConfig file("lint.xml") + } + + signingConfigs { + if (project.hasProperty("Squeezer.properties") + && file(project.property("Squeezer.properties")).exists()) { + Properties props = new Properties() + props.load(new FileInputStream(file(project.property("Squeezer.properties")))) + release { + storeFile file("keystore") + storePassword props['key.store.password'] + keyAlias "squeezer" + keyPassword props['key.alias.password'] + } + } else { + release { + storeFile file("squeezer-local-release-key.keystore") + storePassword "squeezer" + keyAlias "squeezer" + keyPassword "squeezer" + } + } + } + + buildTypes { + debug { + ext.enableCrashlytics = false + } + + release { + minifyEnabled true + shrinkResources true + signingConfig signingConfigs.release + // You could use 'proguardFile "proguard.cfg"' here and get the + // same effect, but this ensures that any changes to + // proguard-android-optimize.txt are automatically included. + proguardFile getDefaultProguardFile('proguard-android-optimize.txt') + proguardFile "proguard-crashlytics.cfg" + proguardFile "proguard-eventbus.cfg" + proguardFile "proguard-guava.cfg" + proguardFile "proguard-cometd.cfg" + proguardFile "proguard-squeezer.cfg" + } + } +} + +def publishTrack() { + switch (android.defaultConfig.versionName) { + case ~/.*-beta-\d+/: + return 'beta' + case ~/\d+\.\d+\.\d+/: + return 'production' + } + throw new IllegalArgumentException("versionName '${android.defaultConfig.versionName}' is not valid") +} + +whatsnew { + changelogPath = 'Squeezer/src/main/res/xml/changelog_master.xml' + newsPath = 'NEWS' + if (publishTrack() == 'beta') { + whatsnewPath = 'Squeezer/src/main/play/release-notes/en-US/beta.txt' + } else { + whatsnewPath = 'Squeezer/src/main/play/release-notes/en-US/production.txt' + } +} + +play { + serviceAccountCredentials = file('key.json') + track = publishTrack() +} + +// To update/add server strings copy relevant strings.txt files from slimserver and squeezeplay +// to the serverstrings folder and update the 2 tables below; 'files' (if necessary) and 'strings'. +// run: './gradlew updateSlimStrings' +slimstrings { + files = [ + 'serverstrings/slimserver/strings.txt', + 'serverstrings/squeezeplay/global_strings.txt' + ] + strings = [ + 'HOME', + 'SWITCH_TO_EXTENDED_LIST', + 'SWITCH_TO_GALLERY', + 'SLEEP', + 'SLEEP_CANCEL', + 'X_MINUTES', + 'SLEEPING_IN', + 'SLEEP_AT_END_OF_SONG', + 'ALARM', + 'ALARM_ALARM_REPEAT', + 'ALARM_SHORT_DAY_0', + 'ALARM_SHORT_DAY_1', + 'ALARM_SHORT_DAY_2', + 'ALARM_SHORT_DAY_3', + 'ALARM_SHORT_DAY_4', + 'ALARM_SHORT_DAY_5', + 'ALARM_SHORT_DAY_6', + 'ALARM_DELETING', + 'ALARM_ALL_ALARMS', + 'MORE', + 'SETTINGS', + 'SCREEN_SETTINGS', + 'ADVANCED_SETTINGS', + 'EXTRAS', + 'SETUP_PLAYTRACKALBUM', + 'SETUP_PLAYTRACKALBUM_DESC', + 'SETUP_PLAYTRACKALBUM_0', + 'SETUP_PLAYTRACKALBUM_1', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_DESC', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_0', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_1', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_2', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_3', + 'SETUP_DEFEAT_DESTRUCTIVE_TTP_4', + 'NO_PLAYER_FOUND', + 'CLEAR_PLAYLIST', + 'JIVE_POPUP_REMOVING_FROM_PLAYLIST' + ] +} diff --git a/Squeezer/gradle.properties b/Squeezer/gradle.properties new file mode 100644 index 000000000..a00fedad5 --- /dev/null +++ b/Squeezer/gradle.properties @@ -0,0 +1 @@ +Squeezer.properties=squeezer.properties diff --git a/Squeezer/keystore b/Squeezer/keystore new file mode 100644 index 000000000..25edd9749 Binary files /dev/null and b/Squeezer/keystore differ diff --git a/Squeezer/lint.xml b/Squeezer/lint.xml new file mode 100644 index 000000000..48c8afd42 --- /dev/null +++ b/Squeezer/lint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Squeezer/proguard-cometd.cfg b/Squeezer/proguard-cometd.cfg new file mode 100644 index 000000000..5d5817bbd --- /dev/null +++ b/Squeezer/proguard-cometd.cfg @@ -0,0 +1,12 @@ +# Proguard configuration for Comet client + +-dontwarn org.codehaus.jackson.** +-dontwarn com.fasterxml.jackson.databind.** +-dontwarn java.nio.file.** +-dontwarn java.nio.channels.** +-dontwarn java.awt.** +-dontwarn javax.imageio.** +-dontwarn java.io.** +-dontwarn javax.servlet.** +-dontwarn javax.net.** +-dontwarn org.slf4j.** diff --git a/Squeezer/proguard-crashlytics.cfg b/Squeezer/proguard-crashlytics.cfg new file mode 100644 index 000000000..a697911c0 --- /dev/null +++ b/Squeezer/proguard-crashlytics.cfg @@ -0,0 +1,2 @@ +# See http://support.crashlytics.com/knowledgebase/articles/202926-android-studio-and-intellij-with-proguard +-keepattributes SourceFile,LineNumberTable \ No newline at end of file diff --git a/Squeezer/proguard-eventbus.cfg b/Squeezer/proguard-eventbus.cfg new file mode 100644 index 000000000..46bf9776b --- /dev/null +++ b/Squeezer/proguard-eventbus.cfg @@ -0,0 +1,3 @@ +-keepclassmembers class ** { + public void onEvent*(**); +} \ No newline at end of file diff --git a/Squeezer/proguard-guava.cfg b/Squeezer/proguard-guava.cfg new file mode 100644 index 000000000..71f9864b0 --- /dev/null +++ b/Squeezer/proguard-guava.cfg @@ -0,0 +1,7 @@ +# Proguard configuration for Guava +# +# Based on https://code.google.com/p/guava-libraries/wiki/UsingProGuardWithGuava. + +-dontwarn sun.misc.Unsafe +-dontwarn com.google.common.collect.MinMaxPriorityQueue +-dontwarn com.google.common.util.concurrent.** diff --git a/Squeezer/proguard-squeezer.cfg b/Squeezer/proguard-squeezer.cfg new file mode 100644 index 000000000..7706a9a45 --- /dev/null +++ b/Squeezer/proguard-squeezer.cfg @@ -0,0 +1,22 @@ +# No sense in obfuscating names since the code is freely available, +# and it makes debugging a little trickier +-keepnames class uk.org.ngo.squeezer.** { + ; +} + +# Explicitly keep the model class constructors. +# Without this you get NoSuchMethodExceptions when creating model objects. +-keep public class uk.org.ngo.squeezer.model.** { + (java.lang.String); + (java.util.Map); + (android.os.Parcel); +} + +# Needed to support the reflection in BaseItemView. +-keepattributes Signature + +# Strip out certain logging calls. +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); +} diff --git a/Squeezer/proguard.cfg b/Squeezer/proguard.cfg new file mode 100644 index 000000000..2e5a6b7bf --- /dev/null +++ b/Squeezer/proguard.cfg @@ -0,0 +1,70 @@ +# This is a configuration file for ProGuard. +# http://proguard.sourceforge.net/index.html#manual/usage.html + +# Optimizations: If you don't want to optimize, use the +# proguard-android.txt configuration file instead of this one, which +# turns off the optimization flags. Adding optimization introduces +# certain risks, since for example not all optimizations performed by +# ProGuard works on all versions of Dalvik. The following flags turn +# off various optimizations known to have issues, but the list may not +# be complete or up to date. (The "arithmetic" optimization can be +# used if you are only targeting Android 2.0 or later.) Make sure you +# test thoroughly if you go this route. +-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* +-optimizationpasses 5 +-allowaccessmodification +-dontpreverify + +# The remainder of this file is identical to the non-optimized version +# of the Proguard configuration file (except that the other file has +# flags to turn off optimization). + +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-verbose + +-keepattributes *Annotation* +-keep public class com.google.vending.licensing.ILicensingService +-keep public class com.android.vending.licensing.ILicensingService + +# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native +-keepclasseswithmembernames class * { + native ; +} + +# keep setters in Views so that animations can still work. +# see http://proguard.sourceforge.net/manual/examples.html#beans +-keepclassmembers public class * extends android.view.View { + void set*(***); + *** get*(); +} + +# We want to keep methods in Activity that could be used in the XML attribute onClick +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} + +# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +-keepclassmembers class **.R$* { + public static ; +} + +# The support library contains references to newer platform versions. +# Don't warn about those in case this app is linking against an older +# platform version. We know about them, and they are safe. +-dontwarn android.support.** + +# Squeezer customisations +-include proguard-crashlytics.cfg +-include proguard-eventbus.cfg +-include proguard-guava.cfg +-include proguard-squeezer.cfg diff --git a/Squeezer/squeezer-local-release-key.keystore b/Squeezer/squeezer-local-release-key.keystore new file mode 100644 index 000000000..bb4f93195 Binary files /dev/null and b/Squeezer/squeezer-local-release-key.keystore differ diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/framework/ItemAdapterTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/framework/ItemAdapterTest.java new file mode 100644 index 000000000..e9487bfbd --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/framework/ItemAdapterTest.java @@ -0,0 +1,192 @@ +package uk.org.ngo.squeezer.test.framework; + +import android.test.ActivityInstrumentationTestCase2; + +import java.util.Arrays; +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.ItemAdapter; +import uk.org.ngo.squeezer.itemlist.ArtistListActivity; +import uk.org.ngo.squeezer.itemlist.ArtistView; +import uk.org.ngo.squeezer.model.Artist; + +public class ItemAdapterTest extends ActivityInstrumentationTestCase2 { + + private ItemAdapter artistItemAdapter; + private Artist[] artists; + int pageSize; + + public ItemAdapterTest() { + super(null, ArtistListActivity.class); + artists = getArtists(); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + artistItemAdapter = new ItemAdapter(new ArtistView(getActivity())); + pageSize = getInstrumentation().getTargetContext().getResources().getInteger(R.integer.PageSize); + + // Test that the adapter is initially empty + assertEquals(0, artistItemAdapter.getCount()); + } + + @Override + protected void tearDown() throws Exception { + artistItemAdapter.clear(); + super.tearDown(); + } + + public void testUpdate() { + // Test adding all items at once + artistItemAdapter.update(artists.length, 0, Arrays.asList(artists)); + assertEquals(artists.length, artistItemAdapter.getCount()); + for (int i = 0; i < artists.length; i++) { + assertEquals(artists[i], artistItemAdapter.getItem(i)); + } + + // Add the items in pages roughly as we we do it when loading pages sequentially + addFirstPage(); + for (int pos = pageSize; pos < artists.length; pos += pageSize) { + int end = pos + pageSize <= artists.length ? pos + pageSize : artists.length; + artistItemAdapter.update(artists.length, pos, Arrays.asList(artists).subList(pos, end)); + for (int i = 0; i < artistItemAdapter.getCount(); i++) { + assertEquals(i < end ? artists[i] : null, artistItemAdapter.getItem(i)); + } + } + + List selectedPages = Arrays.asList(5, 2, 3, 4, 1); + + // Add the items in pages roughly as we we do when the scrolls the list randomly + addFirstPage(); + for (int p = 0; p < selectedPages.size(); p++) { + int pos = selectedPages.get(p) * pageSize; + int end = pos+pageSize <= artists.length ? pos + pageSize : artists.length; + artistItemAdapter.update(artists.length, pos, Arrays.asList(artists).subList(pos, end)); + for (int i = 0; i < artistItemAdapter.getCount(); i++) { + int page = i / pageSize; + boolean pageAdded = (page == 0) || selectedPages.subList(0, p+1).contains(page); + assertEquals(pageAdded ? artists[i] : null, artistItemAdapter.getItem(i)); + } + } + + } + + private void addFirstPage() { + artistItemAdapter.clear(); + artistItemAdapter.update(artists.length, 0, Arrays.asList(artists[0])); + assertEquals(artists.length, artistItemAdapter.getCount()); + for (int i = 0; i < artistItemAdapter.getCount(); i++) { + assertEquals(i == 0 ? artists[i] : null, artistItemAdapter.getItem(i)); + } + + artistItemAdapter.update(artists.length, 1, Arrays.asList(artists).subList(1, pageSize)); + assertEquals(artists.length, artistItemAdapter.getCount()); + for (int i = 0; i < artistItemAdapter.getCount(); i++) { + assertEquals(i < pageSize ? artists[i] : null, artistItemAdapter.getItem(i)); + } + } + + public void testRemoveItem() { + // For all items remove the first item. + // For each item removed assert the resulting list is correct. + artistItemAdapter.clear(); + artistItemAdapter.update(artists.length, 0, Arrays.asList(artists)); + for (int i = 1; i <= artists.length; i++) { + artistItemAdapter.removeItem(0); + assertEquals(artists.length - i, artistItemAdapter.getCount()); + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + assertEquals(artists[i+j], artistItemAdapter.getItem(j)); + } + } + + // For all items remove the last item. + // For each item removed assert the resulting list is correct. + artistItemAdapter.update(artists.length, 0, Arrays.asList(artists)); + for (int i = 1; i <= artists.length; i++) { + artistItemAdapter.removeItem(artists.length - i); + assertEquals(artists.length - i, artistItemAdapter.getCount()); + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + assertEquals(artists[j], artistItemAdapter.getItem(j)); + } + } + } + + public void testInsertItem() { + // Continuously add the first item, and test the resulting list + artistItemAdapter.clear(); + for (int i = artists.length-1; i >= 0; i--) { + artistItemAdapter.insertItem(0, artists[i]); + assertEquals(artists.length - i, artistItemAdapter.getCount()); + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + assertEquals(artists[i+j], artistItemAdapter.getItem(j)); + } + } + + // Continuously add the last item, and test the resulting list + artistItemAdapter.clear(); + for (int i = 0; i < artists.length; i++) { + artistItemAdapter.insertItem(i, artists[i]); + assertEquals(i+1, artistItemAdapter.getCount()); + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + assertEquals(artists[j], artistItemAdapter.getItem(j)); + } + } + } + + public void testSelectedItems() { + List selectedItems = Arrays.asList(19, 20, 22, 2, 1, 0, 105, 106, 107, 108, 100, + 101, 102, 103, 104, 3, 4, 5, 6, 7, 8, 9, 10, 11, 70, 72, 75, 80, 71, 79, 73, 54, 52, + 50, 60, 59, 57, 55); + + // Check findItem + artistItemAdapter.update(artists.length, 0, Arrays.asList(artists)); + for (Integer selectedItem : selectedItems) { + int pos = artistItemAdapter.findItem(artists[selectedItem]); + assertEquals(selectedItem.intValue(), pos); + } + + // Remove selected item, testing the resulting list + for (int i = 0; i < selectedItems.size(); i++) { + int pos = artistItemAdapter.findItem(artists[selectedItems.get(i)]); + artistItemAdapter.removeItem(pos); + assertEquals(artists.length - i - 1, artistItemAdapter.getCount()); + int skipCount = 0; + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + List subList = selectedItems.subList(0, i + 1); + while (subList.contains(Integer.valueOf(artists[j + skipCount].getId()))) + skipCount++; + assertEquals(artists[j + skipCount], artistItemAdapter.getItem(j)); + } + } + + // Insert selected item, testing the resulting list + for (int i = 0; i < selectedItems.size(); i++) { + int pos = 0; + while (pos < artistItemAdapter.getCount() && + Integer.valueOf(artistItemAdapter.getItem(pos).getId()) < selectedItems.get(i)) + pos++; + artistItemAdapter.insertItem(pos, artists[selectedItems.get(i)]); + assertEquals(artists.length - selectedItems.size() + i + 1, artistItemAdapter.getCount()); + int skipCount = 0; + for (int j = 0; j < artistItemAdapter.getCount(); j++) { + List subList = selectedItems.subList(i+1, selectedItems.size()); + while (subList.contains(Integer.valueOf(artists[j + skipCount].getId()))) + skipCount++; + assertEquals(artists[j + skipCount], artistItemAdapter.getItem(j)); + } + } + } + + private Artist[] getArtists() { + int N = 109; + Artist[] result = new Artist[N]; + + for (int i = 0; i < N; i++) { + result[i] = new Artist(String.valueOf(i), "Artist " + i); + } + + return result; + } +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMock.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMock.java new file mode 100644 index 000000000..1b667124b --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMock.java @@ -0,0 +1,205 @@ + +package uk.org.ngo.squeezer.test.mock; + +import android.util.Log; + +import junit.framework.AssertionFailedError; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; + +import uk.org.ngo.squeezer.itemlist.dialog.ViewDialog.AlbumsSortOrder; + +/** + * Emulates LMS for testing purposes + *

+ * Each instance will wait for an incoming connection, accept it, read commands from the + * inputstream, and reply to the outputstream, until the connection is broken or the exit command is + * received, at which point the connection is terminated. + *

+ * To make a new connection a new instance must be started. + * + * @author Kurt Aaholst + */ +public class SqueezeboxServerMock extends Thread { + + private static final String TAG = SqueezeboxServerMock.class.getSimpleName(); + + public static final int CLI_PORT = 9091; + + private final Object serverReadyMonitor = new Object(); + + private boolean accepting; + + public static class Starter { + + public SqueezeboxServerMock start() { + SqueezeboxServerMock server = new SqueezeboxServerMock(this); + server.start(); + synchronized (server.serverReadyMonitor) { + while (!server.accepting) { + try { + server.serverReadyMonitor.wait(2000); + if (!server.accepting) { + throw new AssertionFailedError("Expected the mock server to start"); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted while waiting for the mock server to start"); + } + } + } + return server; + } + + public Starter username(String username) { + this.username = username; + return this; + } + + public Starter password(String password) { + this.password = password; + return this; + } + + public Starter canRandomplay(boolean canRandomplay) { + this.canRandomplay = canRandomplay; + return this; + } + + public Starter canMusicFolder(boolean canMusicFolder) { + this.canMusicFolder = canMusicFolder; + return this; + } + + public Starter albumsSortOrder(AlbumsSortOrder albumsSortOrder) { + this.albumsSortOrder = albumsSortOrder; + return this; + } + + private String username; + + private String password; + + private boolean canRandomplay = true; + + private boolean canMusicFolder = true; + + private AlbumsSortOrder albumsSortOrder = AlbumsSortOrder.album; + } + + public static Starter starter() { + return new Starter(); + } + + private SqueezeboxServerMock(Starter starter) { + username = starter.username; + password = starter.password; + canRandomplay = starter.canRandomplay; + canMusicFolder = starter.canMusicFolder; + albumsSortOrder = starter.albumsSortOrder; + } + + private String username; + + private String password; + + private boolean canMusicFolder; + + private boolean canRandomplay; + + private AlbumsSortOrder albumsSortOrder; + + @Override + public void run() { + ServerSocket serverSocket; + Socket socket; + BufferedReader in; + PrintWriter out; + try { + // Establish server socket + serverSocket = new ServerSocket(CLI_PORT); + serverSocket.setReuseAddress(true); + + // Wait for incoming connection + Log.d(TAG, "Mock server listening on port: " + serverSocket.getLocalPort()); + synchronized (serverReadyMonitor) { + accepting = true; + serverReadyMonitor.notifyAll(); + } + socket = serverSocket.accept(); + + Log.d(TAG, "Mock server connected to: " + socket.getRemoteSocketAddress()); + in = new BufferedReader(new InputStreamReader(socket.getInputStream()), 128); + out = new PrintWriter(socket.getOutputStream(), true); + } catch (IOException e) { + throw new Error(e); + } + + boolean loggedIn = (username == null || password == null); + + while(! Thread.interrupted()) { + // read data from Socket + String line; + try { + line = in.readLine(); + } catch (IOException e) { + line = null; + } + Log.d(TAG, "Mock server got: " + line); + if (line == null) { + break; // Client disconnected + } + + String[] tokens = line.split(" "); + + if ("login".equals(tokens[0])) { + out.println(tokens[0] + ' ' + tokens[1] + " ******"); + if (username != null && password != null) { + if (tokens.length < 2 || !username.equals(tokens[1])) { + break; + } + if (tokens.length < 3 || !password.equals(tokens[2])) { + break; + } + } + loggedIn = true; + } else { + if (!loggedIn) { + break; + } + + if ("exit".equals(line)) { + out.println(line); + break; + } else if ("listen 1".equals(line)) { + //Just ignore, mock doesn't support server side events + out.println("listen 1"); + } else if ("can musicfolder ?".equals(line)) { + out.println("can musicfolder " + (canMusicFolder ? 1 : 0)); + } else if ("can randomplay ?".equals(line)) { + out.println("can randomplay " + (canRandomplay ? 1 : 0)); + } else if ("pref httpport ?".equals(line)) { + out.println("pref httpport 9092"); + } else if ("pref jivealbumsort ?".equals(line)) { + out.println("pref jivealbumsort " + albumsSortOrder); + } else if ("version ?".equals(line)) { + out.println("version 7.7.2"); + } else if ("players".equals(tokens[0])) { + //TODO implement + } else { + out.println(line); + } + } + } + try { + Log.d(TAG, "Mock server closing socket"); + socket.close(); + serverSocket.close(); + } catch (IOException e) {} + } + +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMockTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMockTest.java new file mode 100644 index 000000000..cd28b40df --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/mock/SqueezeboxServerMockTest.java @@ -0,0 +1,194 @@ +package uk.org.ngo.squeezer.test.mock; + +import junit.framework.TestCase; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.UnknownHostException; + +import uk.org.ngo.squeezer.itemlist.dialog.ViewDialog.AlbumsSortOrder; + +/** + * This is a test of the mock server. + *

+ * It doesn't really test any Squeezer functionally, but it is handy when developing or debugging + * the mock server. + * + * @author Kurt Aaholst + */ +public class SqueezeboxServerMockTest extends TestCase { + + public void testDefaults() { + SqueezeboxServerMock.starter().start(); + + try { + SocketAddress sa = new InetSocketAddress("localhost", SqueezeboxServerMock.CLI_PORT); + Socket socket = new Socket(); + + socket.connect(sa, 10 * 1000); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()), + 128); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("login testuser testpassword"); + assertEquals("login testuser ******", in.readLine()); + + out.println("abcd"); + assertEquals("abcd", in.readLine()); + + out.println("version"); + assertEquals("version", in.readLine()); + + out.println("can musicfolder ?"); + out.println("can randomplay ?"); + out.println("pref httpport ?"); + out.println("pref jivealbumsort ?"); + out.println("version ?"); + assertEquals("can musicfolder 1", in.readLine()); + assertEquals("can randomplay 1", in.readLine()); + assertEquals("pref httpport 9092", in.readLine()); + assertEquals("pref jivealbumsort album", in.readLine()); + assertEquals("version 7.7.2", in.readLine()); + + out.println("exit"); + assertEquals("exit", in.readLine()); + + assertNull(in.readLine()); + + in.close(); + out.close(); + socket.close(); + } catch (UnknownHostException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void testSettings() { + SqueezeboxServerMock.starter().canRandomplay(false).albumsSortOrder(AlbumsSortOrder.artflow) + .start(); + + try { + SocketAddress sa = new InetSocketAddress("localhost", SqueezeboxServerMock.CLI_PORT); + Socket socket = new Socket(); + + socket.connect(sa, 10 * 1000); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()), + 128); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("can musicfolder ?"); + out.println("can randomplay ?"); + out.println("pref httpport ?"); + out.println("pref jivealbumsort ?"); + out.println("version ?"); + assertEquals("can musicfolder 1", in.readLine()); + assertEquals("can randomplay 0", in.readLine()); + assertEquals("pref httpport 9092", in.readLine()); + assertEquals("pref jivealbumsort artflow", in.readLine()); + assertEquals("version 7.7.2", in.readLine()); + + out.println("exit"); + assertEquals("exit", in.readLine()); + + assertNull(in.readLine()); + + in.close(); + out.close(); + socket.close(); + } catch (UnknownHostException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void testAuthentication() { + SqueezeboxServerMock.starter().username("user").password("1234").start(); + + try { + SocketAddress sa = new InetSocketAddress("localhost", SqueezeboxServerMock.CLI_PORT); + Socket socket = new Socket(); + + socket.connect(sa, 10 * 1000); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()), + 128); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("login user 1234"); + assertEquals("login user ******", in.readLine()); + + out.println("exit"); + assertEquals("exit", in.readLine()); + + assertNull(in.readLine()); + + in.close(); + out.close(); + socket.close(); + } catch (UnknownHostException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void testAuthenticationFailure() { + SqueezeboxServerMock.starter().username("user").password("1234").start(); + + try { + SocketAddress sa = new InetSocketAddress("localhost", SqueezeboxServerMock.CLI_PORT); + Socket socket = new Socket(); + + socket.connect(sa, 10 * 1000); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()), + 128); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("login user wrongpassword"); + assertEquals("login user ******", in.readLine()); + + assertNull(in.readLine()); + + in.close(); + out.close(); + socket.close(); + } catch (UnknownHostException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void testAuthenticatedServer() { + SqueezeboxServerMock.starter().username("user").password("1234").start(); + + try { + SocketAddress sa = new InetSocketAddress("localhost", SqueezeboxServerMock.CLI_PORT); + Socket socket = new Socket(); + + socket.connect(sa, 10 * 1000); + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()), + 128); + PrintWriter out = new PrintWriter(socket.getOutputStream(), true); + + out.println("version ?"); + assertNull(in.readLine()); + + in.close(); + out.close(); + socket.close(); + } catch (UnknownHostException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/model/SqueezerSongTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/model/SqueezerSongTest.java new file mode 100644 index 000000000..a13738542 --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/model/SqueezerSongTest.java @@ -0,0 +1,83 @@ +package uk.org.ngo.squeezer.test.model; + +import com.google.common.collect.ImmutableMap; + +import android.test.AndroidTestCase; + +import java.util.HashMap; + +import uk.org.ngo.squeezer.model.Album; +import uk.org.ngo.squeezer.model.Song; + +public class SqueezerSongTest extends AndroidTestCase { + + Song song1, song2, song3; + + HashMap record1, record2, record3; + + /** + * Verify that the equals() method compares correctly against nulls, other item types, and is + * reflexive (a = a), symmetric (a = b && b = a), and transitive (a = b && b = c && a = c). + */ + public void testEquals() { + record1 = new HashMap<>(); + record2 = new HashMap<>(); + record3 = new HashMap<>(); + + song1 = new Song(record1); + song2 = new Song(record2); + song3 = new Song(record3); + + assertTrue("A song equals itself (reflexive)", song1.equals(song1)); + + assertTrue("Two songs with empty IDs are equal", song1.equals(song2)); + assertTrue("... and is symmetric", song2.equals(song1)); + + assertTrue("Identical songs have the same hashcode", song1.hashCode() == song2.hashCode()); + + Album album1 = new Album(record1); + assertFalse("Null song does not equal a null album", song1.equals(album1)); + assertFalse("... and is symmetric", album1.equals(song1)); + + song1 = new Song(ImmutableMap.of("id", "1")); + song2 = new Song(ImmutableMap.of("id", "2")); + assertFalse("Songs with different IDs are different", song1.equals(song2)); + assertFalse("... and is symmetric", song2.equals(song1)); + + song1 = new Song(ImmutableMap.of("id", "1")); + song2 = new Song(ImmutableMap.of("id", 1)); + song3 = new Song(ImmutableMap.of("id", 1)); + assertTrue("Songs with the same ID are equivalent", song1.equals(song2)); + assertTrue("... and is symmetric", song2.equals(song1)); + assertTrue("... and is transitive (1)", song2.equals(song3)); + assertTrue("... and is transitive (2)", song1.equals(song3)); + assertTrue("Identical songs have the same hashcode", song1.hashCode() == song2.hashCode()); + + song1 = new Song(ImmutableMap.of("id", 1, "title", "Song 1")); + song2 = new Song(ImmutableMap.of("id", "1", "title", "Song 1")); + song3 = new Song(ImmutableMap.of("id", 1, "title", "Song 1")); + assertTrue("Songs with the same ID/Title are equivalent", song1.equals(song2)); + assertTrue("... and is symmetric", song2.equals(song1)); + assertTrue("... and is transitive (1)", song2.equals(song3)); + assertTrue("... and is transitive (2)", song1.equals(song3)); + assertTrue("Identical songs have the same hashcode", song1.hashCode() == song2.hashCode()); + + song1 = new Song(ImmutableMap.of("id", "1", "title", "Song 1")); + song2 = new Song(ImmutableMap.of("id", "1", "title", "Song 2")); + song3 = new Song(ImmutableMap.of("id", "1", "title", "Song 3")); + assertFalse("Songs that differ by title are different", song1.equals(song2)); + assertFalse("... and is symmetric", song2.equals(song1)); + assertFalse("... and is transitive (1)", song2.equals(song3)); + assertFalse("... and is transitive (2)", song1.equals(song3)); + + song1 = new Song(ImmutableMap.of("id", 1, "title", "Song")); + song2 = new Song(ImmutableMap.of("id", 21,"title", "Song")); + assertFalse("Songs with same name but different IDs are different", song1.equals(song2)); + assertFalse("... and is symmetric", song2.equals(song1)); + + song1 = new Song(ImmutableMap.of("id", "1", "title", "Song")); + album1.setId("1"); + assertFalse("Songs and albums with the same ID are different", song1.equals(album1)); + assertFalse("... and is symmetric", album1.equals(song1)); + } +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/server/SqueezeServiceTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/server/SqueezeServiceTest.java new file mode 100644 index 000000000..c30f16158 --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/server/SqueezeServiceTest.java @@ -0,0 +1,224 @@ + +package uk.org.ngo.squeezer.test.server; + +import android.content.Intent; +import android.test.ServiceTestCase; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import uk.org.ngo.squeezer.itemlist.dialog.ViewDialog; +import uk.org.ngo.squeezer.service.ConnectionState; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.service.event.ConnectionChanged; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.test.mock.SqueezeboxServerMock; + +/** + * To test interactions with the server: + *

    + *
  1. Create a mock server configured appropriately for the test.
  2. + *
  3. Set a desired + * {@link uk.org.ngo.squeezer.service.ConnectionState.ConnectionStates} in + * {@link #mWantedState}.
  4. + *
  5. Connect to the mock server.
  6. + *
  7. Wait on {@link #mLockWantedState}. Execution will continue when the server + * reaches the same state as is in {@link #mWantedState}.
  8. + *
  9. At this point {@link #mActualConnectionStates} contains an ordered list of the + * connection state transitions the service has been through.
  10. + *
+ */ +public class SqueezeServiceTest extends ServiceTestCase { + private static final String TAG = "SqueezeServiceTest"; + + /** Number of milliseconds to wait for a particular event to occur before aborting. */ + private static final int TIMEOUT_IN_MS = 5000; + + public SqueezeServiceTest() { + super(SqueezeService.class); + } + + /** Wait until the server has reached this state. */ + @ConnectionState.ConnectionStates + private int mWantedState; + + /** + * Lock object, will be notified when the server reaches the same state as + * {@link #mWantedState}. + */ + private final Object mLockWantedState = new Object(); + + /** List of the states the server has transition through. */ + private final List mActualConnectionStates = new ArrayList(); + + /** + * Lock object, will be notified when the service completes the handshake with the + * server. + */ + private final Object mLockHandshakeComplete = new Object(); + + /** The last successful handshake-complete event. */ + private HandshakeComplete mLastHandshakeCompleteEvent; + + private ISqueezeService mService; + + @Override + public void setUp() throws Exception { + super.setUp(); + mService = (ISqueezeService) bindService(new Intent(getSystemContext(), SqueezeService.class)); + mService.getEventBus().register(this); + } + + protected void tearDown() throws Exception { + mService.getEventBus().unregister(this); + shutdownService(); + super.tearDown(); + } + + /** + * Verify that connecting to a non-existent server fails. + * + * @throws InterruptedException + */ + public void testConnectionFailure() throws InterruptedException { + SqueezeboxServerMock.starter().start(); + mWantedState = ConnectionState.CONNECTION_FAILED; + + mService.startConnect("localhost", 80, 0, "test", "test"); + + synchronized(mLockWantedState) { + mLockWantedState.wait(TIMEOUT_IN_MS); + } + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_FAILED), mActualConnectionStates); + } + + /** + * Verify that connecting to an existing server succeeds. + * + * @throws InterruptedException + */ + public void testConnect() throws InterruptedException { + SqueezeboxServerMock.starter().start(); + + mService.startConnect("localhost", SqueezeboxServerMock.CLI_PORT, 0, "test", "test"); + + synchronized (mLockHandshakeComplete) { + mLockHandshakeComplete.wait(TIMEOUT_IN_MS); + } + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_COMPLETED, + ConnectionState.LOGIN_STARTED, + ConnectionState.LOGIN_COMPLETED), mActualConnectionStates); + + assertTrue(mLastHandshakeCompleteEvent.canMusicFolders); + assertTrue(mLastHandshakeCompleteEvent.canRandomPlay); + assertEquals(ViewDialog.AlbumsSortOrder.album.name(), + mService.preferredAlbumSort()); + + // Check that disconnecting only generates one additional DISCONNECTED event. + mService.disconnect(); + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_COMPLETED, + ConnectionState.LOGIN_STARTED, + ConnectionState.LOGIN_COMPLETED, + ConnectionState.DISCONNECTED), mActualConnectionStates); + } + + /** + * Verify that connecting to an existing server that uses password authentication, + * using the correct password, succeeds. + * + * @throws InterruptedException + */ + public void testConnectProtectedServer() throws InterruptedException { + SqueezeboxServerMock.starter().username("user").password("1234").start(); + + mService.startConnect("localhost", SqueezeboxServerMock.CLI_PORT, 0, "user", "1234"); + + synchronized (mLockHandshakeComplete) { + mLockHandshakeComplete.wait(TIMEOUT_IN_MS); + } + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_COMPLETED, + ConnectionState.LOGIN_STARTED, + ConnectionState.LOGIN_COMPLETED), mActualConnectionStates); + + assertTrue(mLastHandshakeCompleteEvent.canMusicFolders); + assertTrue(mLastHandshakeCompleteEvent.canRandomPlay); + assertEquals(ViewDialog.AlbumsSortOrder.album.name(), + mService.preferredAlbumSort()); + + // Check that disconnecting only generates one additional DISCONNECTED event. + mService.disconnect(); + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_COMPLETED, + ConnectionState.LOGIN_STARTED, + ConnectionState.LOGIN_COMPLETED, + ConnectionState.DISCONNECTED), mActualConnectionStates); + } + + /** + * Verify that connecting to an existing server that uses password authentication, + * using an incorrect username / password fails. + * + * @throws InterruptedException + */ + public void testAuthenticationFailure() throws InterruptedException { + SqueezeboxServerMock.starter().username("user").password("1234").start(); + mWantedState = ConnectionState.LOGIN_FAILED; + + mService.startConnect("localhost", SqueezeboxServerMock.CLI_PORT, 0, "test", "test"); + + synchronized (mLockWantedState) { + mLockWantedState.wait(TIMEOUT_IN_MS); + } + + assertEquals(Arrays.asList( + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTION_STARTED, + ConnectionState.CONNECTION_COMPLETED, + ConnectionState.LOGIN_STARTED, + ConnectionState.LOGIN_FAILED), mActualConnectionStates); + } + + public void onEvent(ConnectionChanged event) { + mActualConnectionStates.add(event.connectionState); + + // If the desired state is DISCONNECTED then ignore it the very first time it + // appears, as it's the initial state. + if (mWantedState == ConnectionState.DISCONNECTED && mActualConnectionStates.size() == 1) { + return; + } + + if (event.connectionState == mWantedState) { + synchronized (mLockWantedState) { + mLockWantedState.notify(); + } + } + } + + public void onEvent(HandshakeComplete event) { + mLastHandshakeCompleteEvent = event; + synchronized (mLockHandshakeComplete) { + mLockHandshakeComplete.notify(); + } + } +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/ImageCacheTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/ImageCacheTest.java new file mode 100644 index 000000000..c5ce770db --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/ImageCacheTest.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.test.util; + +import junit.framework.TestCase; + +import uk.org.ngo.squeezer.util.ImageCache; + +public class ImageCacheTest extends TestCase { + /** Verify that hashKeyForDisk returns correct MD5 checksums. */ + public void testHashKeyForDisk() { + assertEquals("acbd18db4cc2f85cedef654fccc4a4d8", ImageCache.hashKeyForDisk("foo")); + assertEquals("37b51d194a7513e45b56f6524f2d51f2", ImageCache.hashKeyForDisk("bar")); + assertEquals("73feffa4b7f6bb68e44cf984c85f6e88", ImageCache.hashKeyForDisk("baz")); + } +} diff --git a/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/UtilTest.java b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/UtilTest.java new file mode 100644 index 000000000..ce377ecce --- /dev/null +++ b/Squeezer/src/androidTest/java/uk/org/ngo/squeezer/test/util/UtilTest.java @@ -0,0 +1,90 @@ +package uk.org.ngo.squeezer.test.util; + +import junit.framework.TestCase; + +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicReference; + +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.JiveItem; + +public class UtilTest extends TestCase { + + public void testAtomicReferenceUpdated() { + AtomicReference atomicString = new AtomicReference<>(); + assertFalse(Util.atomicReferenceUpdated(atomicString, null)); + assertNull(atomicString.get()); + assertTrue(Util.atomicReferenceUpdated(atomicString, "test")); + assertEquals("test", atomicString.get()); + assertFalse(Util.atomicReferenceUpdated(atomicString, "test")); + assertEquals("test", atomicString.get()); + assertTrue(Util.atomicReferenceUpdated(atomicString, "change")); + assertEquals("change", atomicString.get()); + assertTrue(Util.atomicReferenceUpdated(atomicString, null)); + assertNull(atomicString.get()); + assertTrue(Util.atomicReferenceUpdated(atomicString, "change")); + assertEquals("change", atomicString.get()); + assertTrue(Util.atomicReferenceUpdated(atomicString, null)); + assertNull(atomicString.get()); + assertFalse(Util.atomicReferenceUpdated(atomicString, null)); + assertNull(atomicString.get()); + + AtomicReference atomicItem = new AtomicReference<>(); + JiveItem album = new JiveItem(new HashMap()); + album.setId("1"); + album.setName("Album"); + CurrentPlaylistItem song = new CurrentPlaylistItem(new HashMap()); + song.setId("1"); + + assertFalse(Util.atomicReferenceUpdated(atomicItem, null)); + assertNull(atomicItem.get()); + assertTrue(Util.atomicReferenceUpdated(atomicItem, album)); + assertEquals(album, atomicItem.get()); + + album.setName("new_name"); + assertFalse(Util.atomicReferenceUpdated(atomicItem, album)); + assertEquals(album, atomicItem.get()); + + assertTrue(Util.atomicReferenceUpdated(atomicItem, song)); + assertEquals(song, atomicItem.get()); + + album.setId("2"); + assertTrue(Util.atomicReferenceUpdated(atomicItem, album)); + assertEquals(album, atomicItem.get()); + + assertTrue(Util.atomicReferenceUpdated(atomicItem, null)); + assertNull(atomicItem.get()); + } + + public void testParseInt() { + assertEquals(2, Util.parseDecimalIntOrZero("2")); + assertEquals(0, Util.parseDecimalIntOrZero("2x")); + assertEquals(2, Util.parseDecimalIntOrZero("2.0")); + assertEquals(2, Util.parseDecimalIntOrZero("2.9")); + assertEquals(0, Util.parseDecimalIntOrZero(null)); + assertEquals(-2, Util.parseDecimalIntOrZero("-2")); + assertEquals(0, Util.parseDecimalIntOrZero("2,0")); + + assertEquals(123456789, Util.parseDecimalInt("123456789", -1)); + assertEquals(-1, Util.parseDecimalInt("0x8", -1)); + } + + public void testTimeString() { + assertEquals("0:00", Util.formatElapsedTime(0)); + assertEquals("0:01", Util.formatElapsedTime(1)); + assertEquals("0:10", Util.formatElapsedTime(10)); + assertEquals("0:59", Util.formatElapsedTime(59)); + assertEquals("1:00", Util.formatElapsedTime(60)); + assertEquals("1:01", Util.formatElapsedTime(61)); + assertEquals("1:59", Util.formatElapsedTime(119)); + assertEquals("2:00", Util.formatElapsedTime(120)); + assertEquals("2:01", Util.formatElapsedTime(121)); + assertEquals("18:39", Util.formatElapsedTime(1119)); + assertEquals("19:59", Util.formatElapsedTime(1199)); + assertEquals("20:00", Util.formatElapsedTime(1200)); + assertEquals("20:01", Util.formatElapsedTime(1201)); + assertEquals("20:11", Util.formatElapsedTime(1211)); + } +} diff --git a/Squeezer/src/androidTest/res/drawable-hdpi/ic_launcher.png b/Squeezer/src/androidTest/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 000000000..96a442e5b Binary files /dev/null and b/Squeezer/src/androidTest/res/drawable-hdpi/ic_launcher.png differ diff --git a/Squeezer/src/androidTest/res/drawable-ldpi/ic_launcher.png b/Squeezer/src/androidTest/res/drawable-ldpi/ic_launcher.png new file mode 100644 index 000000000..99238729d Binary files /dev/null and b/Squeezer/src/androidTest/res/drawable-ldpi/ic_launcher.png differ diff --git a/Squeezer/src/androidTest/res/drawable-mdpi/ic_launcher.png b/Squeezer/src/androidTest/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 000000000..359047dfa Binary files /dev/null and b/Squeezer/src/androidTest/res/drawable-mdpi/ic_launcher.png differ diff --git a/Squeezer/src/androidTest/res/drawable-xhdpi/ic_launcher.png b/Squeezer/src/androidTest/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 000000000..71c6d760f Binary files /dev/null and b/Squeezer/src/androidTest/res/drawable-xhdpi/ic_launcher.png differ diff --git a/Squeezer/src/androidTest/res/values/strings.xml b/Squeezer/src/androidTest/res/values/strings.xml new file mode 100644 index 000000000..bd8131894 --- /dev/null +++ b/Squeezer/src/androidTest/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + SqueezerTestTest + + diff --git a/Squeezer/src/main/AndroidManifest.xml b/Squeezer/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d50c357d0 --- /dev/null +++ b/Squeezer/src/main/AndroidManifest.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/ConnectActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/ConnectActivity.java new file mode 100644 index 000000000..c2a35bbef --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/ConnectActivity.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2013 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.IntDef; + +import android.view.View; + +import com.google.android.material.textfield.TextInputLayout; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import uk.org.ngo.squeezer.dialog.ServerAddressView; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.itemlist.HomeActivity; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; + +/** + * An activity for when the user is not connected to a Squeezeserver. + *

+ * Provide a UI for connecting to the configured server, launch HomeMenuActivity when the user + * connects. + */ +public class ConnectActivity extends BaseActivity { + + @IntDef({MANUAL_DISCONNECT, CONNECTION_FAILED, LOGIN_FAILED, INVALID_URL}) + @Retention(RetentionPolicy.SOURCE) + private @interface DisconnectionReasons {} + private static final int MANUAL_DISCONNECT = 0; + private static final int CONNECTION_FAILED = 1; + private static final int LOGIN_FAILED = 2; + private static final int INVALID_URL = 3; + + private static final String EXTRA_DISCONNECTION_REASON = "reason"; + + private ServerAddressView serverAddressView; + + @DisconnectionReasons private int mDisconnectionReason = MANUAL_DISCONNECT; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle extras = getIntent().getExtras(); + if (extras != null) { + //noinspection ResourceType + mDisconnectionReason = extras.getInt(EXTRA_DISCONNECTION_REASON); + } + + setContentView(R.layout.disconnected); + serverAddressView = findViewById(R.id.server_address_view); + setErrorMessageFromReason(mDisconnectionReason); + } + + /** + * Show this activity. + *

+ * Flags are set to clear the previous activities, as trying to go back while disconnected makes + * no sense. + *

+ * The pending transition is overridden to animate the activity in place, rather than having it + * appear to move in from off-screen. + * + * @param disconnectionReason identifies why the activity is being shown. + */ + private static void show(Activity activity, @DisconnectionReasons int disconnectionReason) { + // If the activity is already running then make sure the header message is appropriate + // and stop, as there's no need to start another instance of the activity. + if (activity instanceof ConnectActivity) { + ((ConnectActivity) activity).setErrorMessageFromReason(disconnectionReason); + return; + } + + final Intent intent = new Intent(activity, ConnectActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + intent.putExtra(EXTRA_DISCONNECTION_REASON, disconnectionReason); + activity.startActivity(intent); + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); + } + + /** + * Set the text and visibility of the optional header message that's shown to the user + * based on the reason for the activity being shown. + * + * @param disconnectionReason The reason. + */ + @SuppressLint("SwitchIntDef") + private void setErrorMessageFromReason(@DisconnectionReasons int disconnectionReason) { + TextInputLayout serverAddress = serverAddressView.findViewById(R.id.server_address_til); + TextInputLayout userName = serverAddressView.findViewById(R.id.username_til); + serverAddress.setError(null); + userName.setError(null); + + + switch (disconnectionReason) { + case CONNECTION_FAILED: + serverAddress.setError(getString(R.string.connection_failed_text)); + break; + case LOGIN_FAILED: + userName.setError(getString(R.string.login_failed_text)); + break; + case INVALID_URL: + serverAddress.setError(getString(R.string.invalid_url_text)); + break; + } + } + + /** + * Show this activity. + * @see #show(android.app.Activity) + */ + public static void show(Activity activity) { + show(activity, MANUAL_DISCONNECT); + } + + /** + * Show this activity on login failure. + * @see #show(android.app.Activity) + */ + public static void showLoginFailed(Activity activity) { + show(activity, LOGIN_FAILED); + } + + public static void showConnectionFailed(Activity activity) { + show(activity, CONNECTION_FAILED); + } + + public static void showInvalidUrl(Activity activity) { + show(activity, INVALID_URL); + } + + /** + * Act on the user requesting a server connection through the activity's UI. + * + * @param view The view the user pressed. + */ + public void onUserInitiatesConnect(View view) { + serverAddressView.savePreferences(); + NowPlayingFragment fragment = (NowPlayingFragment) getSupportFragmentManager() + .findFragmentById(R.id.now_playing_fragment); + fragment.startVisibleConnection(); + } + + public void onEventMainThread(HandshakeComplete event) { + final Intent intent = new Intent(this, HomeActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/IconRowAdapter.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/IconRowAdapter.java new file mode 100644 index 000000000..24442348c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/IconRowAdapter.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Simple list adapter to display corresponding lists of images and labels. + * + * @author Kurt Aaholst + */ +public class IconRowAdapter extends BaseAdapter { + + private final Activity activity; + + private final int rowLayout = R.layout.list_item; + + private final int iconId = R.id.icon; + + private final int textId = R.id.text1; + + /** + * Rows to display in the list. + */ + private List mRows = new ArrayList(); + + @Override + public int getCount() { + return mRows.size(); + } + + public int getImage(int position) { + return mRows.get(position).getIcon(); + } + + @Override + public CharSequence getItem(int position) { + return mRows.get(position).getText(); + } + + @Override + public long getItemId(int position) { + return mRows.get(position).getId(); + } + + /** + * Creates an IconRowAdapter where the id of each item corresponds to its index in + * items. + *

+ * items and icons must be the same size. + * + * @param context + * @param items Item text. + * @param images Image resources. + */ + public IconRowAdapter(Activity context, CharSequence[] items, int[] icons) { + this.activity = context; + + // Convert to a list of IconRow. + for (int i = 0; i < items.length; i++) { + mRows.add(new IconRow(i, items[i], icons[i])); + } + } + + /** + * Creates an IconRowAdapter from the list of rows. + * + * @param context + * @param rows Rows to appear in the list. + */ + public IconRowAdapter(Activity context, List rows) { + this.activity = context; + mRows = rows; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View row = getActivity().getLayoutInflater().inflate(rowLayout, parent, false); + TextView text1 = (TextView) row.findViewById(textId); + ImageView icon = (ImageView) row.findViewById(iconId); + + text1.setText(mRows.get(position).getText()); + icon.setImageResource(mRows.get(position).getIcon()); + + return row; + } + + public Activity getActivity() { + return activity; + } + + /** + * Helper class to represent a row. Each row has an identifier, a string, and an icon. + *

+ * The identifier should be unique across all rows in a given {@link IconRowAdapter}, and will + * be used as the id parameter to the OnItemClickListener. + */ + public static class IconRow { + + private long mId; + + private CharSequence mText; + + private int mIcon; + + IconRow(long id, CharSequence text, int icon) { + mId = id; + mText = text; + mIcon = icon; + } + + public long getId() { + return mId; + } + + public void setId(long id) { + mId = id; + } + + public CharSequence getText() { + return mText; + } + + public void setText(String text) { + mText = text; + } + + public int getIcon() { + return mIcon; + } + + public void setIcon(int icon) { + mIcon = icon; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java new file mode 100644 index 000000000..001f06ed3 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/IntEditTextPreference.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.preference.EditTextPreference; + +public class IntEditTextPreference extends EditTextPreference { + + public IntEditTextPreference(Context context) { + super(context); + } + + public IntEditTextPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public IntEditTextPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected String getPersistedString(String defaultReturnValue) { + return String.valueOf(getPersistedInt(0)); + } + + @Override + protected boolean persistString(String value) { + return persistInt(Util.getInt(value)); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java new file mode 100644 index 000000000..0fc6eca49 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingActivity.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.MotionEvent; + +import androidx.appcompat.app.ActionBar; +import androidx.core.view.GestureDetectorCompat; + +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.widget.OnSwipeListener; + +public class NowPlayingActivity extends BaseActivity { + private GestureDetectorCompat mDetector; + + /** + * Called when the activity is first created. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.now_playing); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setHomeAsUpIndicator(R.drawable.ic_action_down); + } + + mDetector = new GestureDetectorCompat(this, new OnSwipeListener() { + @Override + public boolean onSwipeDown() { + finish(); + return true; + } + }); + + ignoreIconMessages = true; + } + + public static void show(Context context) { + final Intent intent = new Intent(context, NowPlayingActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + + if (!(context instanceof Activity)) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); + if (context instanceof Activity) { + ((Activity) context).overridePendingTransition(R.anim.slide_in_up, android.R.anim.fade_out); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onTouchEvent(MotionEvent event){ + mDetector.onTouchEvent(event); + return super.onTouchEvent(event); + } + + @Override + public void onPause() { + if (isFinishing()) { + overridePendingTransition(android.R.anim.fade_in, R.anim.slide_out_down); + } + super.onPause(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java new file mode 100644 index 000000000..2783ae2e8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/NowPlayingFragment.java @@ -0,0 +1,1125 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import android.os.IBinder; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.core.view.GestureDetectorCompat; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.appcompat.app.ActionBar; +import android.util.AttributeSet; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.SeekBar.OnSeekBarChangeListener; +import android.widget.Spinner; +import android.widget.TextView; + +import com.google.common.base.Joiner; +import com.google.common.base.Strings; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import uk.org.ngo.squeezer.dialog.AboutDialog; +import uk.org.ngo.squeezer.dialog.EnableWifiDialog; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.itemlist.AlarmsActivity; +import uk.org.ngo.squeezer.itemlist.CurrentPlaylistActivity; +import uk.org.ngo.squeezer.itemlist.HomeActivity; +import uk.org.ngo.squeezer.itemlist.PlayerListActivity; +import uk.org.ngo.squeezer.itemlist.JiveItemListActivity; +import uk.org.ngo.squeezer.itemlist.JiveItemViewLogic; +import uk.org.ngo.squeezer.itemlist.PlayerViewLogic; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.model.PlayerState.RepeatStatus; +import uk.org.ngo.squeezer.model.PlayerState.ShuffleStatus; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ConnectionState; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.service.event.ConnectionChanged; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.service.event.HomeMenuEvent; +import uk.org.ngo.squeezer.service.event.MusicChanged; +import uk.org.ngo.squeezer.service.event.PlayStatusChanged; +import uk.org.ngo.squeezer.service.event.PlayersChanged; +import uk.org.ngo.squeezer.service.event.PowerStatusChanged; +import uk.org.ngo.squeezer.service.event.RegisterSqueezeNetwork; +import uk.org.ngo.squeezer.service.event.RepeatStatusChanged; +import uk.org.ngo.squeezer.service.event.ShuffleStatusChanged; +import uk.org.ngo.squeezer.service.event.SongTimeChanged; +import uk.org.ngo.squeezer.util.ImageFetcher; +import uk.org.ngo.squeezer.widget.OnSwipeListener; + +public class NowPlayingFragment extends Fragment { + + private static final String TAG = "NowPlayingFragment"; + + private BaseActivity mActivity; + private JiveItemViewLogic pluginViewDelegate; + + @Nullable + private ISqueezeService mService = null; + + private TextView albumText; + + private TextView artistAlbumText; + + private TextView artistText; + + private TextView trackText; + + @Nullable + private View btnContextMenu; + + private TextView currentTime; + + private TextView totalTime; + + private MenuItem menu_item_disconnect; + + private JiveItem globalSearch; + private MenuItem menu_item_search; + + private MenuItem menu_item_players; + + private MenuItem menu_item_toggle_power; + private MenuItem menu_item_sleep; + private MenuItem menu_item_sleep_at_end_of_song; + private MenuItem menu_item_cancel_sleep; + + private MenuItem menu_item_alarm; + + private ImageButton playPauseButton; + + @Nullable + private ImageButton nextButton; + + @Nullable + private ImageButton prevButton; + + private ImageButton shuffleButton; + + private ImageButton repeatButton; + + private ImageView albumArt; + + /** In full-screen mode, shows the current progress through the track. */ + private SeekBar seekBar; + + /** In mini-mode, shows the current progress through the track. */ + private ProgressBar mProgressBar; + + // Updating the seekbar + private boolean updateSeekBar = true; + + private Button volumeButton; + + private Button playlistButton; + + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + ConnectivityManager connMgr = (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = connMgr.getNetworkInfo(ConnectivityManager.TYPE_WIFI); + if (networkInfo.isConnected()) { + Log.v(TAG, "Received WIFI connected broadcast"); + if (!isConnected()) { + // Requires a serviceStub. Else we'll do this on the service + // connection callback. + if (mService != null && !isManualDisconnect(context)) { + Log.v(TAG, "Initiated connect on WIFI connected"); + startVisibleConnection(); + } + } + } + } + }; + + /** Dialog displayed while connecting to the server. */ + private ProgressDialog connectingDialog = null; + + /** + * Shows the "connecting" dialog if it's not already showing. + */ + @UiThread + private void showConnectingDialog() { + if (connectingDialog == null || !connectingDialog.isShowing()) { + Preferences preferences = new Preferences(mActivity); + Preferences.ServerAddress serverAddress = preferences.getServerAddress(); + + connectingDialog = ProgressDialog.show(mActivity, + getText(R.string.connecting_text), + getString(R.string.connecting_to_text, preferences.getServerName(serverAddress)), + true, false); + } + } + + /** + * Dismisses the "connecting" dialog if it's showing. + */ + @UiThread + private void dismissConnectingDialog() { + if (connectingDialog != null && connectingDialog.isShowing()) { + connectingDialog.dismiss(); + } + connectingDialog = null; + } + + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + Log.v(TAG, "ServiceConnection.onServiceConnected()"); + NowPlayingFragment.this.onServiceConnected((ISqueezeService) binder); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + private boolean mFullHeightLayout; + + /** + * Called before onAttach. Pull out the layout spec to figure out which layout to use later. + */ + @Override + public void onInflate(@NonNull Activity activity, @NonNull AttributeSet attrs, Bundle savedInstanceState) { + super.onInflate(activity, attrs, savedInstanceState); + + int layout_height = attrs.getAttributeUnsignedIntValue( + "http://schemas.android.com/apk/res/android", + "layout_height", 0); + + mFullHeightLayout = (layout_height == ViewGroup.LayoutParams.MATCH_PARENT); + } + + @Override + public void onAttach(@NonNull Activity activity) { + super.onAttach(activity); + mActivity = (BaseActivity) activity; + pluginViewDelegate = new JiveItemViewLogic(mActivity); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + mActivity.bindService(new Intent(mActivity, SqueezeService.class), serviceConnection, + Context.BIND_AUTO_CREATE); + Log.d(TAG, "did bindService; serviceStub = " + mService); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View v; + + if (mFullHeightLayout) { + v = inflater.inflate(R.layout.now_playing_fragment_full, container, false); + + artistText = v.findViewById(R.id.artistname); + albumText = v.findViewById(R.id.albumname); + shuffleButton = v.findViewById(R.id.shuffle); + repeatButton = v.findViewById(R.id.repeat); + currentTime = v.findViewById(R.id.currenttime); + totalTime = v.findViewById(R.id.totaltime); + seekBar = v.findViewById(R.id.seekbar); + volumeButton = v.findViewById(R.id.volume); + playlistButton = v.findViewById(R.id.playlist); + + final BaseItemView.ViewHolder viewHolder = new BaseItemView.ViewHolder(v); + viewHolder.contextMenuButton.setOnClickListener(view -> { + CurrentPlaylistItem currentSong = getCurrentSong(); + // This extra check is if user pressed the button before visibility is set to GONE + if (currentSong != null) { + pluginViewDelegate.showContextMenu(viewHolder, currentSong); + } + }); + btnContextMenu = viewHolder.contextMenuButtonHolder; + btnContextMenu.setTag(viewHolder); + } else { + v = inflater.inflate(R.layout.now_playing_fragment_mini, container, false); + + mProgressBar = v.findViewById(R.id.progressbar); + artistAlbumText = v.findViewById(R.id.artistalbumname); + } + + albumArt = v.findViewById(R.id.album); + trackText = v.findViewById(R.id.trackname); + playPauseButton = v.findViewById(R.id.pause); + + // May or may not be present in the layout, depending on orientation, + // screen width, and so on. + nextButton = v.findViewById(R.id.next); + prevButton = v.findViewById(R.id.prev); + + // Marquee effect on TextViews only works if they're focused. + trackText.requestFocus(); + + playPauseButton.setOnClickListener(view -> { + if (mService == null) { + return; + } + mService.togglePausePlay(); + }); + + if (nextButton != null) { + nextButton.setOnClickListener(view -> { + if (mService == null) { + return; + } + mService.nextTrack(); + }); + } + + if (prevButton != null) { + prevButton.setOnClickListener(view -> { + if (mService == null) { + return; + } + mService.previousTrack(); + }); + } + + if (mFullHeightLayout) { + /* + * TODO: Simplify these following the notes at + * http://developer.android.com/resources/articles/ui-1.6.html. + * Maybe. because the TextView resources don't support the + * android:onClick attribute. + */ + shuffleButton.setOnClickListener(view -> { + if (mService == null) { + return; + } + mService.toggleShuffle(); + }); + + repeatButton.setOnClickListener(view -> { + if (mService == null) { + return; + } + mService.toggleRepeat(); + }); + + volumeButton.setOnClickListener(view -> mActivity.showVolumePanel()); + + playlistButton.setOnClickListener(view -> CurrentPlaylistActivity.show(mActivity)); + + seekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() { + CurrentPlaylistItem seekingSong; + + // Update the time indicator to reflect the dragged thumb + // position. + @Override + public void onProgressChanged(SeekBar s, int progress, boolean fromUser) { + if (fromUser) { + currentTime.setText(Util.formatElapsedTime(progress)); + } + } + + // Disable updates when user drags the thumb. + @Override + public void onStartTrackingTouch(SeekBar s) { + seekingSong = getCurrentSong(); + updateSeekBar = false; + } + + // Re-enable updates. If the current song is the same as when + // we started seeking then jump to the new point in the track, + // otherwise ignore the seek. + @Override + public void onStopTrackingTouch(SeekBar s) { + CurrentPlaylistItem thisSong = getCurrentSong(); + + updateSeekBar = true; + + if (seekingSong == thisSong) { + setSecondsElapsed(s.getProgress()); + } + } + }); + } else { + final GestureDetectorCompat detector = new GestureDetectorCompat(mActivity, new OnSwipeListener() { + // Clicking on the layout goes to NowPlayingActivity. + @Override + public boolean onSingleTapUp(MotionEvent e) { + NowPlayingActivity.show(mActivity); + return true; + } + + // Swipe up on the layout goes to NowPlayingActivity. + @Override + public boolean onSwipeUp() { + NowPlayingActivity.show(mActivity); + return true; + } + }); + v.setOnTouchListener((view, event) -> detector.onTouchEvent(event)); + } + + return v; + } + + @UiThread + private void updatePlayPauseIcon(@PlayerState.PlayState String playStatus) { + playPauseButton + .setImageResource((PlayerState.PLAY_STATE_PLAY.equals(playStatus)) ? + R.drawable.ic_action_pause + : R.drawable.ic_action_play); + } + + @UiThread + private void updateShuffleStatus(ShuffleStatus shuffleStatus) { + if (mFullHeightLayout && shuffleStatus != null) { + shuffleButton.setImageResource(shuffleStatus.getIcon()); + } + } + + @UiThread + private void updateRepeatStatus(RepeatStatus repeatStatus) { + if (mFullHeightLayout && repeatStatus != null) { + repeatButton.setImageResource(repeatStatus.getIcon()); + } + } + + @UiThread + private void updatePlayerMenuItems() { + // The fragment may no longer be attached to the parent activity. If so, do nothing. + if (!isAdded()) { + return; + } + + Player player = getActivePlayer(); + PlayerState playerState = player != null ? player.getPlayerState() : null; + String playerName = player != null ? player.getName() : ""; + + if (menu_item_toggle_power != null) { + if (playerState != null && player.isCanpoweroff()) { + menu_item_toggle_power.setTitle(getString(playerState.isPoweredOn() ? R.string.menu_item_poweroff : R.string.menu_item_poweron, playerName)); + menu_item_toggle_power.setVisible(true); + } else { + menu_item_toggle_power.setVisible(false); + } + } + + if (menu_item_cancel_sleep != null) { + menu_item_cancel_sleep.setVisible(playerState != null && playerState.getSleepDuration() != 0); + } + + if (menu_item_sleep_at_end_of_song != null) { + menu_item_sleep_at_end_of_song.setVisible(playerState != null && playerState.isPlaying()); + } + } + + /** + * Manages the list of connected players in the action bar. + * + * @param players A list of players to show. May be empty (use {@code + * Collections.<Player>emptyList()}) but not null. + * @param activePlayer The currently active player. May be null. + */ + @UiThread + private void updatePlayerDropDown(@NonNull Collection players, + @Nullable Player activePlayer) { + if (!isAdded()) { + return; + } + + // Only include players that are connected to the server. + List connectedPlayers = new ArrayList<>(); + for (Player player : players) { + if (player.getConnected()) { + connectedPlayers.add(player); + } + } + Collections.sort(connectedPlayers); // sort players alphabetically by player name + + ActionBar actionBar = mActivity.getSupportActionBar(); + + // If there are multiple players connected then show a spinner allowing the user to + // choose between them. + if (connectedPlayers.size() > 1) { + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setDisplayShowCustomEnabled(true); + actionBar.setCustomView(R.layout.action_bar_custom_view); + Spinner spinner = (Spinner) actionBar.getCustomView(); + final Context actionBarContext = actionBar.getThemedContext(); + final ArrayAdapter playerAdapter = new ArrayAdapter( + actionBarContext, android.R.layout.simple_spinner_dropdown_item, + connectedPlayers) { + @Override + public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) { + return Util.getActionBarSpinnerItemView(actionBarContext, convertView, parent, + getItem(position).getName()); + } + + @Override + public @NonNull View getView(int position, View convertView, @NonNull ViewGroup parent) { + return Util.getActionBarSpinnerItemView(actionBarContext, convertView, parent, + getItem(position).getName()); + } + }; + spinner.setAdapter(playerAdapter); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (!playerAdapter.getItem(position).equals(mService.getActivePlayer())) { + mService.setActivePlayer(playerAdapter.getItem(position)); + updateUiFromPlayerState(mService.getActivePlayerState()); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + if (activePlayer != null) { + spinner.setSelection(playerAdapter.getPosition(activePlayer)); + } + } else { + // 0 or 1 players, disable the spinner, and either show the sole player in the + // action bar, or the app name if there are no players. + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayShowCustomEnabled(false); + + if (connectedPlayers.size() == 1) { + actionBar.setTitle(connectedPlayers.get(0).getName()); + } else { + actionBar.setTitle(R.string.app_name); + } + } + } + + protected void onServiceConnected(@NonNull ISqueezeService service) { + Log.v(TAG, "Service bound"); + mService = service; + + maybeRegisterCallbacks(mService); + + // Assume they want to connect (unless manually disconnected). + if (!isConnected()) { + if (isManualDisconnect(mActivity)) { + ConnectActivity.show(mActivity); + + } else { + startVisibleConnection(); + } + } + } + + @Override + public void onResume() { + super.onResume(); + Log.d(TAG, "onResume..."); + + if (mService != null) { + maybeRegisterCallbacks(mService); + } + + if (new Preferences(mActivity).isAutoConnect()) { + mActivity.registerReceiver(broadcastReceiver, new IntentFilter( + ConnectivityManager.CONNECTIVITY_ACTION)); + } + } + + /** + * Keep track of whether callbacks have been registered + */ + private boolean mRegisteredCallbacks; + + /** + * This is called when the service is first connected, and whenever the activity is resumed. + */ + private void maybeRegisterCallbacks(@NonNull ISqueezeService service) { + if (!mRegisteredCallbacks) { + service.getEventBus().registerSticky(this); + + mRegisteredCallbacks = true; + } + } + + @UiThread + private void updateTimeDisplayTo(int secondsIn, int secondsTotal) { + if (mFullHeightLayout) { + if (updateSeekBar) { + if (seekBar.getMax() != secondsTotal) { + seekBar.setMax(secondsTotal); + totalTime.setText(Util.formatElapsedTime(secondsTotal)); + } + seekBar.setProgress(secondsIn); + currentTime.setText(Util.formatElapsedTime(secondsIn)); + } + } else { + if (mProgressBar.getMax() != secondsTotal) { + mProgressBar.setMax(secondsTotal); + } + mProgressBar.setProgress(secondsIn); + } + } + + /** + * Update the UI based on the player state. Call this when the active player + * changes. + * + * @param playerState the player state to reflect in the UI. + */ + @UiThread + private void updateUiFromPlayerState(@NonNull PlayerState playerState) { + updateSongInfo(playerState); + + updatePlayPauseIcon(playerState.getPlayStatus()); + updateShuffleStatus(playerState.getShuffleStatus()); + updateRepeatStatus(playerState.getRepeatStatus()); + updatePlayerMenuItems(); + } + + /** + * Joins elements together with ' - ', skipping nulls. + */ + protected static final Joiner mJoiner = Joiner.on(" - ").skipNulls(); + + /** + * Update the UI when the song changes, either because the track has changed, or the + * active player has changed. + * + * @param playerState the player state for the song. + */ + @UiThread + private void updateSongInfo(@NonNull PlayerState playerState) { + updateTimeDisplayTo((int)playerState.getCurrentTimeSecond(), + playerState.getCurrentSongDuration()); + + CurrentPlaylistItem song = playerState.getCurrentSong(); + if (song == null) { + // Create empty song if this is called (via _HandshakeComplete) before status is received + song = new CurrentPlaylistItem(new HashMap<>()); + } + + // TODO handle button remapping (buttons in status response) + if (!song.getTrack().isEmpty()) { + trackText.setText(song.getTrack()); + + // don't remove rew and fwd for remote tracks, because a single track playlist + // is not an indication that fwd and rwd are invalid actions + if ((playerState.getCurrentPlaylistTracksNum() == 1) && !playerState.isRemote()) { + disableButton(nextButton); + disableButton(prevButton); + if (btnContextMenu != null) { + btnContextMenu.setVisibility(View.GONE); + } + } else { + enableButton(nextButton); + enableButton(prevButton); + if (btnContextMenu != null) { + btnContextMenu.setVisibility(View.VISIBLE); + } + } + + if (mFullHeightLayout) { + artistText.setText(song.getArtist()); + albumText.setText(song.getAlbum()); + totalTime.setText(Util.formatElapsedTime(playerState.getCurrentSongDuration())); + } else { + artistAlbumText.setText(mJoiner.join( + Strings.emptyToNull(song.getArtist()), + Strings.emptyToNull(song.getAlbum()))); + } + } else { + trackText.setText(""); + if (mFullHeightLayout) { + artistText.setText(""); + albumText.setText(""); + btnContextMenu.setVisibility(View.GONE); + } else { + artistAlbumText.setText(""); + } + } + + if (!song.hasArtwork()) { + albumArt.setImageDrawable(song.getIconDrawable(mActivity, R.drawable.icon_album_noart_fullscreen)); + } else { + ImageFetcher.getInstance(mActivity).loadImage(song.getIcon(), albumArt); + } + } + + /** + * Enable a button, which may be null. + * + * @param button the button to enable. + */ + private static void enableButton(@Nullable ImageButton button) { + setButtonState(button, true); + } + + /** + * Disable a button, which may be null. + * + * @param button the button to enable. + */ + private static void disableButton(@Nullable ImageButton button) { + setButtonState(button, false); + } + + /** + * Sets the state of a button to either enabled or disabled. Enabled buttons + * are active and have a 1.0 alpha, disabled buttons are inactive and have a + * 0.25 alpha. {@code button} may be null, in which case nothing happens. + * + * @param button the button to affect + * @param state the desired state, {@code true} to enable {@code false} to disable. + */ + private static void setButtonState(@Nullable ImageButton button, boolean state) { + if (button == null) { + return; + } + + button.setEnabled(state); + button.setAlpha(state ? 1.0f : 0.25f); + } + + private boolean setSecondsElapsed(int seconds) { + return mService != null && mService.setSecondsElapsed(seconds); + } + + private PlayerState getPlayerState() { + if (mService == null) { + return null; + } + return mService.getPlayerState(); + } + + private Player getActivePlayer() { + if (mService == null) { + return null; + } + return mService.getActivePlayer(); + } + + private CurrentPlaylistItem getCurrentSong() { + PlayerState playerState = getPlayerState(); + return playerState != null ? playerState.getCurrentSong() : null; + } + + private boolean isConnected() { + return mService != null && mService.isConnected(); + } + + private boolean isConnectInProgress() { + return mService != null && mService.isConnectInProgress(); + } + + @Override + public void onPause() { + Log.d(TAG, "onPause..."); + + dismissConnectingDialog(); + + if (new Preferences(mActivity).isAutoConnect()) { + mActivity.unregisterReceiver(broadcastReceiver); + } + + if (mRegisteredCallbacks) { + mService.getEventBus().unregister(this); + mRegisteredCallbacks = false; + } + + pluginViewDelegate.resetContextMenu(); + + super.onPause(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mService != null) { + mActivity.unbindService(serviceConnection); + } + } + + /** + * @see Fragment#onCreateOptionsMenu(android.view.Menu, + * android.view.MenuInflater) + */ + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { + // I confess that I don't understand why using the inflater passed as + // an argument here doesn't work -- but if you do it crashes without + // a stracktrace on API 7. + MenuInflater i = mActivity.getMenuInflater(); + i.inflate(R.menu.now_playing_fragment, menu); + PlayerViewLogic.inflatePlayerActions(mActivity, i, menu); + + menu_item_search = menu.findItem(R.id.menu_item_search); + menu_item_disconnect = menu.findItem(R.id.menu_item_disconnect); + + menu_item_toggle_power = menu.findItem(R.id.toggle_power); + menu_item_sleep = menu.findItem(R.id.sleep); + menu_item_sleep_at_end_of_song = menu.findItem(R.id.end_of_song); + menu_item_cancel_sleep = menu.findItem(R.id.cancel_sleep); + + menu_item_players = menu.findItem(R.id.menu_item_players); + menu_item_alarm = menu.findItem(R.id.menu_item_alarm); + } + + /** + * Sets the state of assorted option menu items based on whether or not there is a connection to + * the server, and if so, whether any players are connected. + */ + @Override + public void onPrepareOptionsMenu(@NonNull Menu menu) { + boolean connected = isConnected(); + + // These are all set at the same time, so one check is sufficient + if (menu_item_disconnect != null) { + // Set visibility and enabled state of menu items that are not player-specific. + menu_item_search.setVisible(globalSearch != null); + menu_item_disconnect.setVisible(connected); + + // Set visibility and enabled state of menu items that are player-specific and + // require a connection to the server. + boolean haveConnectedPlayers = connected && mService != null + && !mService.getPlayers().isEmpty(); + + menu_item_players.setVisible(haveConnectedPlayers); + menu_item_alarm.setVisible(haveConnectedPlayers); + menu_item_sleep.setVisible(haveConnectedPlayers); + } + + // Don't show the item to go to players if in PlayersActivity. + if (mActivity instanceof PlayerListActivity && menu_item_players != null) { + menu_item_players.setVisible(false); + } + + // Don't show the item to go to alarms if in AlarmsActivity. + if (mActivity instanceof AlarmsActivity && menu_item_alarm != null) { + menu_item_alarm.setVisible(false); + } + + updatePlayerMenuItems(); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (PlayerViewLogic.doPlayerAction(mService, item, getActivePlayer())) { + return true; + } + + switch (item.getItemId()) { + case R.id.menu_item_search: + JiveItemListActivity.show(mActivity, globalSearch, globalSearch.goAction); + return true; + case R.id.menu_item_settings: + SettingsActivity.show(mActivity); + return true; + case R.id.menu_item_disconnect: + new Preferences(mActivity).setManualDisconnect(true); + mService.disconnect(); + return true; + case R.id.menu_item_players: + PlayerListActivity.show(mActivity); + return true; + case R.id.menu_item_alarm: + AlarmsActivity.show(mActivity); + return true; + case R.id.menu_item_about: + new AboutDialog().show(getFragmentManager(), "AboutDialog"); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /** + * Has the user manually disconnected from the server? + * + * @return true if they have, false otherwise. + */ + private boolean isManualDisconnect(Context context) { + return new Preferences(context).isManualDisconnect(); + } + + public void startVisibleConnection() { + Log.v(TAG, "startVisibleConnection"); + if (mService == null) { + return; + } + + Preferences preferences = new Preferences(mActivity); + + // If we are configured to automatically connect on Wi-Fi availability + // we will also give the user the opportunity to enable Wi-Fi + if (preferences.isAutoConnect()) { + WifiManager wifiManager = (WifiManager) mActivity + .getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (!wifiManager.isWifiEnabled()) { + FragmentManager fragmentManager = getFragmentManager(); + if (fragmentManager == null) { + Log.i(TAG, "fragment manager is null so we can't show EnableWifiDialog"); + return; + } + + FragmentTransaction ft = fragmentManager.beginTransaction(); + Fragment prev = fragmentManager.findFragmentByTag(EnableWifiDialog.TAG); + if (prev != null) { + ft.remove(prev); + } + ft.addToBackStack(null); + + // Create and show the dialog. + DialogFragment enableWifiDialog = new EnableWifiDialog(); + enableWifiDialog.show(ft, EnableWifiDialog.TAG); + return; + // When a Wi-Fi connection is made this method will be called again by the + // broadcastReceiver + } + } + + if (!preferences.hasServerConfig()) { + // Set up a server connection, if it is not present + ConnectActivity.show(mActivity); + return; + } + + if (isConnectInProgress()) { + Log.v(TAG, "Connection is already in progress, connecting aborted"); + return; + } + mService.startConnect(); + } + + + @MainThread + public void onEventMainThread(ConnectionChanged event) { + Log.d(TAG, "ConnectionChanged: " + event); + + // The fragment may no longer be attached to the parent activity. If so, do nothing. + if (!isAdded()) { + return; + } + + // Handle any of the reasons for disconnection, clear the dialog and show the + // ConnectActivity. + if (event.connectionState == ConnectionState.DISCONNECTED) { + dismissConnectingDialog(); + ConnectActivity.show(mActivity); + return; + } + + if (event.connectionState == ConnectionState.CONNECTION_FAILED) { + dismissConnectingDialog(); + switch (event.connectionError) { + case LOGIN_FALIED: + ConnectActivity.showLoginFailed(mActivity); + break; + case INVALID_URL: + ConnectActivity.showInvalidUrl(mActivity); + break; + case START_CLIENT_ERROR: + case CONNECTION_ERROR: + ConnectActivity.showConnectionFailed(mActivity); + break; + } + return; + } + + if (event.connectionState == ConnectionState.RECONNECT) { + dismissConnectingDialog(); + HomeActivity.show(mActivity); + return; + } + + // Any other event means that a connection is in progress or completed. + // Show the the dialog if appropriate. + if (event.connectionState != ConnectionState.CONNECTION_COMPLETED) { + showConnectingDialog(); + } + + // Ensure that option menu item state is adjusted as appropriate. + getActivity().supportInvalidateOptionsMenu(); + + disableButton(nextButton); + disableButton(prevButton); + + if (mFullHeightLayout) { + shuffleButton.setEnabled(false); + repeatButton.setEnabled(false); + volumeButton.setEnabled(false); + playlistButton.setEnabled(false); + + albumArt.setImageResource(R.drawable.icon_album_noart_fullscreen); + shuffleButton.setImageResource(0); + repeatButton.setImageResource(0); + updatePlayerDropDown(Collections.emptyList(), null); + artistText.setText(getText(R.string.disconnected_text)); + btnContextMenu.setVisibility(View.GONE); + currentTime.setText("--:--"); + totalTime.setText("--:--"); + seekBar.setEnabled(false); + seekBar.setProgress(0); + } else { + albumArt.setImageResource(R.drawable.icon_pending_artwork); + mProgressBar.setEnabled(false); + mProgressBar.setProgress(0); + } + } + + @MainThread + public void onEventMainThread(HandshakeComplete event) { + // Event might arrive before this fragment has connected to the service (e.g., + // the activity connected before this fragment did). + // XXX: Verify that this is possible, since the fragment can't register for events + // until it's connected to the service. + if (mService == null) { + return; + } + + Log.d(TAG, "Handshake complete"); + + dismissConnectingDialog(); + + enableButton(nextButton); + enableButton(prevButton); + if (mFullHeightLayout) { + shuffleButton.setEnabled(true); + repeatButton.setEnabled(true); + seekBar.setEnabled(true); + volumeButton.setEnabled(true); + playlistButton.setEnabled(true); + } else { + mProgressBar.setEnabled(true); + } + + PlayerState playerState = getPlayerState(); + + // May be no players connected. + // TODO: These views should be cleared if there's no player connected. + if (playerState == null) + return; + + updateUiFromPlayerState(playerState); + } + + @MainThread + public void onEventMainThread(@SuppressWarnings("unused") RegisterSqueezeNetwork event) { + // We're connected but the controller needs to register with the server + JiveItemListActivity.register(mActivity); + } + + @MainThread + public void onEventMainThread(MusicChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updateSongInfo(event.playerState); + } + } + + @MainThread + public void onEventMainThread(PlayersChanged event) { + updatePlayerDropDown(event.players.values(), mService.getActivePlayer()); + } + + @MainThread + public void onEventMainThread(PlayStatusChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updatePlayPauseIcon(event.playStatus); + } + } + + @MainThread + public void onEventMainThread(PowerStatusChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updatePlayerMenuItems(); + } + } + + @MainThread + public void onEventMainThread(HomeMenuEvent event) { + globalSearch = null; + for (JiveItem menuItem : event.menuItems) { + if ("globalSearch".equals(menuItem.getId()) && menuItem.goAction != null) { + globalSearch = menuItem; + break; + } + } + if (menu_item_search != null) { + menu_item_search.setVisible(globalSearch != null); + } + } + + @MainThread + public void onEventMainThread(RepeatStatusChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updateRepeatStatus(event.repeatStatus); + } + } + + @MainThread + public void onEventMainThread(ShuffleStatusChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updateShuffleStatus(event.shuffleStatus); + } + } + + @MainThread + public void onEventMainThread(SongTimeChanged event) { + if (event.player.equals(mService.getActivePlayer())) { + updateTimeDisplayTo(event.currentPosition, event.duration); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java new file mode 100644 index 000000000..59f0d1b9a --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Preferences.java @@ -0,0 +1,476 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.util.Log; + +import java.util.Random; +import java.util.UUID; + +import uk.org.ngo.squeezer.download.DownloadFilenameStructure; +import uk.org.ngo.squeezer.download.DownloadPathStructure; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout; +import uk.org.ngo.squeezer.util.ThemeManager; + +public final class Preferences { + private static final String TAG = Preferences.class.getSimpleName(); + + public static final String NAME = "Squeezer"; + + // Old setting for connect via the CLI protocol + private static final String KEY_CLI_SERVER_ADDRESS = "squeezer.serveraddr"; + + // Squeezebox server address (host:port) + private static final String KEY_SERVER_ADDRESS = "squeezer.server_addr"; + + // Do we connect to mysqueezebox.com + private static final String KEY_SQUEEZE_NETWORK = "squeezer.squeeze_network"; + + // Optional Squeezebox Server name + private static final String KEY_SERVER_NAME = "squeezer.server_name"; + + // Optional Squeezebox Server user name + private static final String KEY_USERNAME = "squeezer.username"; + + // Optional Squeezebox Server password + private static final String KEY_PASSWORD = "squeezer.password"; + + // The playerId that we were last connected to. e.g. "00:04:20:17:04:7f" + public static final String KEY_LAST_PLAYER = "squeezer.lastplayer"; + + // Do we automatically try and connect on WiFi availability? + public static final String KEY_AUTO_CONNECT = "squeezer.autoconnect"; + + // Are we disconnected via the options menu? + private static final String KEY_MANUAL_DISCONNECT = "squeezer.manual.disconnect"; + + // Type of notification to show. NOT USED ANYMORE + private static final String KEY_NOTIFICATION_TYPE = "squeezer.notification_type"; + + // Do we scrobble track information? + // Deprecated, retained for compatibility when upgrading. Was an int, of + // either 0 == No scrobbling, 1 == use ScrobbleDroid API, 2 == use SLS API + public static final String KEY_SCROBBLE = "squeezer.scrobble"; + + // Do we scrobble track information (if a scrobble service is available)? + // + // Type of underlying preference is bool / CheckBox + public static final String KEY_SCROBBLE_ENABLED = "squeezer.scrobble.enabled"; + + // Do we send anonymous usage statistics? + public static final String KEY_ANALYTICS_ENABLED = "squeezer.analytics.enabled"; + + // Fade-in period? (0 = disable fade-in) + public static final String KEY_FADE_IN_SECS = "squeezer.fadeInSecs"; + + // What do to when an album is selected in the list view + private static final String KEY_ON_SELECT_ALBUM_ACTION = "squeezer.action.onselect.album"; + + // What do to when a song is selected in the list view + private static final String KEY_ON_SELECT_SONG_ACTION = "squeezer.action.onselect.song"; + + // Preferred album list layout. + private static final String KEY_ALBUM_LIST_LAYOUT = "squeezer.album.list.layout"; + + // Preferred home menu layout. + private static final String KEY_HOME_MENU_LAYOUT = "squeezer.home.menu.layout"; + + // Preferred song list layout. + private static final String KEY_SONG_LIST_LAYOUT = "squeezer.song.list.layout"; + + // Start SqueezePlayer automatically if installed. + public static final String KEY_SQUEEZEPLAYER_ENABLED = "squeezer.squeezeplayer.enabled"; + + // Preferred UI theme. + static final String KEY_ON_THEME_SELECT_ACTION = "squeezer.theme"; + + // Download enabled + static final String KEY_DOWNLOAD_ENABLED = "squeezer.download.enabled"; + + // Download confirmation + static final String KEY_DOWNLOAD_CONFIRMATION = "squeezer.download.confirmation"; + + // Download folder + static final String KEY_DOWNLOAD_USE_SERVER_PATH = "squeezer.download.use_server_path"; + + // Download path structure + static final String KEY_DOWNLOAD_PATH_STRUCTURE = "squeezer.download.path_structure"; + + // Download filename structure + static final String KEY_DOWNLOAD_FILENAME_STRUCTURE = "squeezer.download.filename_structure"; + + // Use SD-card (getExternalMediaDirs) + static final String KEY_DOWNLOAD_USE_SD_CARD_SCREEN = "squeezer.download.use_sd_card.screen"; + static final String KEY_DOWNLOAD_USE_SD_CARD = "squeezer.download.use_sd_card"; + + // Store a "mac id" for this app instance. + private static final String KEY_MAC_ID = "squeezer.mac_id"; + + // Store a unique id for this app instance. + private static final String KEY_UUID = "squeezer.uuid"; + + private final Context context; + private final SharedPreferences sharedPreferences; + private final int defaultCliPort; + private final int defaultHttpPort; + + public Preferences(Context context) { + this(context, context.getSharedPreferences(Preferences.NAME, Context.MODE_PRIVATE)); + } + + public Preferences(Context context, SharedPreferences sharedPreferences) { + this.context = context; + this.sharedPreferences = sharedPreferences; + defaultCliPort = context.getResources().getInteger(R.integer.DefaultCliPort); + defaultHttpPort = context.getResources().getInteger(R.integer.DefaultHttpPort); + } + + private String getStringPreference(String preference) { + final String pref = sharedPreferences.getString(preference, null); + if (pref == null || pref.length() == 0) { + return null; + } + return pref; + } + + public boolean hasServerConfig() { + String bssId = getBssId(); + return (sharedPreferences.contains(prefixed(bssId, KEY_SERVER_ADDRESS)) || + sharedPreferences.contains(KEY_SERVER_ADDRESS)); + } + + public ServerAddress getServerAddress() { + return getServerAddress(KEY_SERVER_ADDRESS, defaultHttpPort); + } + + public ServerAddress getCliServerAddress() { + return getServerAddress(KEY_CLI_SERVER_ADDRESS, defaultCliPort); + } + + private ServerAddress getServerAddress(String setting, int defaultPort) { + ServerAddress serverAddress = new ServerAddress(defaultPort); + + serverAddress.bssId = getBssId(); + + String address = null; + if (serverAddress.bssId != null) { + address = getStringPreference(setting + "_" + serverAddress.bssId); + } + if (address == null) { + address = getStringPreference(setting); + } + serverAddress.setAddress(address, defaultPort); + + serverAddress.squeezeNetwork = sharedPreferences.getBoolean(prefixed(serverAddress.bssId, KEY_SQUEEZE_NETWORK), false); + + return serverAddress; + } + + private String getBssId() { + WifiManager mWifiManager = (WifiManager) context + .getApplicationContext().getSystemService(Context.WIFI_SERVICE); + WifiInfo connectionInfo = mWifiManager.getConnectionInfo(); + return (connectionInfo != null ? connectionInfo.getBSSID() : null); + } + + private String prefixed(String bssId, String setting) { + return (bssId != null ? setting + "_" + bssId : setting); + } + + private String prefix(ServerAddress serverAddress) { + return (serverAddress.bssId != null ? serverAddress.bssId + "_ " : "") + serverAddress.localAddress() + "_"; + } + + public static class ServerAddress { + private static final String SN = "mysqueezebox.com"; + + private String bssId; + public boolean squeezeNetwork; + private String address; // : + private String host; + private int port; + private final int defaultPort; + + private ServerAddress(int defaultPort) { + this.defaultPort = defaultPort; + } + + public void setAddress(String hostPort) { + setAddress(hostPort, defaultPort); + } + + public String address() { + return host() + ":" + port(); + } + + public String localAddress() { + if (address == null) { + return null; + } + + return host + ":" + port; + } + + public String host() { + return (squeezeNetwork ? SN : host); + } + + public String localHost() { + return host; + } + + public int port() { + return (squeezeNetwork ? defaultPort : port); + } + + private void setAddress(String hostPort, int defaultPort) { + // Common mistakes, based on crash reports... + if (hostPort != null) { + if (hostPort.startsWith("Http://") || hostPort.startsWith("http://")) { + hostPort = hostPort.substring(7); + } + + // Ending in whitespace? From LatinIME, probably? + while (hostPort.endsWith(" ")) { + hostPort = hostPort.substring(0, hostPort.length() - 1); + } + } + + address = hostPort; + host = parseHost(); + port = parsePort(defaultPort); + } + + private String parseHost() { + if (address == null) { + return ""; + } + int colonPos = address.indexOf(":"); + if (colonPos == -1) { + return address; + } + return address.substring(0, colonPos); + } + + private int parsePort(int defaultPort) { + if (address == null) { + return defaultPort; + } + int colonPos = address.indexOf(":"); + if (colonPos == -1) { + return defaultPort; + } + try { + return Integer.parseInt(address.substring(colonPos + 1)); + } catch (NumberFormatException unused) { + Log.d(TAG, "Can't parse port out of " + address); + return defaultPort; + } + } + } + + public void saveServerAddress(ServerAddress serverAddress) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(prefixed(serverAddress.bssId, KEY_SERVER_ADDRESS), serverAddress.address); + editor.putBoolean(prefixed(serverAddress.bssId, KEY_SQUEEZE_NETWORK), serverAddress.squeezeNetwork); + editor.apply(); + } + + public String getServerName(ServerAddress serverAddress) { + if (serverAddress.squeezeNetwork) { + return ServerAddress.SN; + } + String serverName = getStringPreference(prefix(serverAddress) + KEY_SERVER_NAME); + return serverName != null ? serverName : serverAddress.host; + } + + public void saveServerName(ServerAddress serverAddress, String serverName) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(prefix(serverAddress) + KEY_SERVER_NAME, serverName); + editor.apply(); + } + + public String getUsername(ServerAddress serverAddress) { + return getStringPreference(prefix(serverAddress) + KEY_USERNAME); + } + + public String getPassword(ServerAddress serverAddress) { + return getStringPreference(prefix(serverAddress) + KEY_PASSWORD); + } + + public void saveUserCredentials(ServerAddress serverAddress, String userName, String password) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(prefix(serverAddress) + KEY_USERNAME, userName); + editor.putString(prefix(serverAddress) + KEY_PASSWORD, password); + editor.apply(); + } + + public String getTheme() { + return getStringPreference(KEY_ON_THEME_SELECT_ACTION); + } + + public void setTheme(ThemeManager.Theme theme) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(Preferences.KEY_ON_THEME_SELECT_ACTION, theme.name()); + editor.apply(); + } + + public boolean isAutoConnect() { + return sharedPreferences.getBoolean(KEY_AUTO_CONNECT, true); + } + + public boolean isManualDisconnect() { + return sharedPreferences.getBoolean(KEY_MANUAL_DISCONNECT, false); + } + + public void setManualDisconnect(boolean manualDisconnect) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putBoolean(Preferences.KEY_MANUAL_DISCONNECT, manualDisconnect); + editor.apply(); + } + + public boolean controlSqueezePlayer(ServerAddress serverAddress) { + return (!serverAddress.squeezeNetwork && sharedPreferences.getBoolean(KEY_SQUEEZEPLAYER_ENABLED, true)); + } + + /** Get the preferred album list layout. */ + public ArtworkListLayout getAlbumListLayout() { + return getListLayout(KEY_ALBUM_LIST_LAYOUT); + } + + public void setAlbumListLayout(ArtworkListLayout artworkListLayout) { + setListLayout(KEY_ALBUM_LIST_LAYOUT, artworkListLayout); + } + + /** Get the preferred home menu layout. */ + public ArtworkListLayout getHomeMenuLayout() { + return getListLayout(KEY_HOME_MENU_LAYOUT); + } + + public void setHomeMenuLayout(ArtworkListLayout artworkListLayout) { + setListLayout(KEY_HOME_MENU_LAYOUT, artworkListLayout); + } + + /** + * Get the preferred layout for the specified preference + *

+ * If the list layout is not selected, a default one is chosen, based on the current screen + * size, on the assumption that the artwork grid is preferred on larger screens. + */ + private ArtworkListLayout getListLayout(String preference) { + String listLayoutString = sharedPreferences.getString(preference, null); + if (listLayoutString == null) { + int screenSize = context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK; + return (screenSize >= Configuration.SCREENLAYOUT_SIZE_LARGE) + ? ArtworkListLayout.grid : ArtworkListLayout.list; + } else { + return ArtworkListLayout.valueOf(listLayoutString); + } + } + + private void setListLayout(String preference, ArtworkListLayout artworkListLayout) { + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(preference, artworkListLayout.name()); + editor.apply(); + } + + /** + * Retrieve a "mac id" for this app instance. + *

+ * If a mac id is previously stored, then use it, otherwise create a new mac id + * store it and return it. + */ + public String getMacId() { + String macId = sharedPreferences.getString(KEY_MAC_ID, null); + if (macId == null) { + macId = generateMacLikeId(); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(Preferences.KEY_MAC_ID, macId); + editor.apply(); + } + return macId; + } + + /** + * As Android (6.0 and above) does not allow acces to the mac id, and mysqueezebox.com requires + * it, this is the best I can think of. + */ + private String generateMacLikeId() { + StringBuilder sb = new StringBuilder(18); + byte[] b = new byte[6]; + new Random().nextBytes(b); + for (int i = 0; i < b.length; i++) { + sb.append(String.format("%02X:", b[i])); + } + sb.deleteCharAt(sb.length() - 1); + return sb.toString(); + } + + /** + * Retrieve a unique id (uuid) for this app instance. + *

+ * If a uuid is previously stored, then use it, otherwise create a new uuid, + * store it and return it. + */ + public String getUuid() { + String uuid = sharedPreferences.getString(KEY_UUID, null); + if (uuid == null) { + //NOTE mysqueezebox.com doesn't accept dash in the uuid + uuid = UUID.randomUUID().toString().replaceAll("-", ""); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(Preferences.KEY_UUID, uuid); + editor.apply(); + } + return uuid; + } + + public boolean isDownloadEnabled() { + return sharedPreferences.getBoolean(KEY_DOWNLOAD_ENABLED, true); + } + + public void setDownloadEnabled(boolean b) { + sharedPreferences.edit().putBoolean(Preferences.KEY_DOWNLOAD_ENABLED, b).apply(); + } + + public boolean isDownloadConfirmation() { + return sharedPreferences.getBoolean(KEY_DOWNLOAD_CONFIRMATION, true); + } + + public void setDownloadConfirmation(boolean b) { + sharedPreferences.edit().putBoolean(Preferences.KEY_DOWNLOAD_CONFIRMATION, b).apply(); + } + + public boolean isDownloadUseServerPath() { + return sharedPreferences.getBoolean(KEY_DOWNLOAD_USE_SERVER_PATH, true); + } + + public DownloadPathStructure getDownloadPathStructure() { + final String string = sharedPreferences.getString(KEY_DOWNLOAD_PATH_STRUCTURE, null); + return (string == null ? DownloadPathStructure.ARTIST_ALBUM: DownloadPathStructure.valueOf(string)); + } + + public DownloadFilenameStructure getDownloadFilenameStructure() { + final String string = sharedPreferences.getString(KEY_DOWNLOAD_FILENAME_STRUCTURE, null); + return (string == null ? DownloadFilenameStructure.NUMBER_TITLE: DownloadFilenameStructure.valueOf(string)); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java new file mode 100644 index 000000000..b2e5df681 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsActivity.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; + +import uk.org.ngo.squeezer.util.ThemeManager; + +public class SettingsActivity extends AppCompatActivity { + + private final ThemeManager mThemeManager = new ThemeManager(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + mThemeManager.onCreate(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.settings); + } + + + @Override + public void onResume() { + super.onResume(); + mThemeManager.onResume(this); + } + + public static void show(Context context) { + final Intent intent = new Intent(context, SettingsActivity.class); + context.startActivity(intent); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java new file mode 100644 index 000000000..4921ac64d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/SettingsFragment.java @@ -0,0 +1,317 @@ +package uk.org.ngo.squeezer; + +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ListView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.preference.CheckBoxPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; + +import uk.org.ngo.squeezer.download.DownloadFilenameStructure; +import uk.org.ngo.squeezer.download.DownloadPathStructure; +import uk.org.ngo.squeezer.framework.EnumWithText; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.util.Scrobble; +import uk.org.ngo.squeezer.util.ThemeManager; + +public class SettingsFragment extends PreferenceFragmentCompat implements + Preference.OnPreferenceChangeListener, SharedPreferences.OnSharedPreferenceChangeListener { + + private final String TAG = "SettingsActivity"; + + private ISqueezeService service = null; + + private IntEditTextPreference fadeInPref; + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + SettingsFragment.this.service = (ISqueezeService) service; + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } + }; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + getActivity().bindService(new Intent(getActivity(), SqueezeService.class), serviceConnection, + Context.BIND_AUTO_CREATE); + Log.d(TAG, "did bindService; service = " + service); + + getPreferenceManager().setSharedPreferencesName(Preferences.NAME); + setPreferencesFromResource(R.xml.preferences, rootKey); + + SharedPreferences sharedPreferences = getPreferenceManager().getSharedPreferences(); + sharedPreferences.registerOnSharedPreferenceChangeListener(this); + Preferences preferences = new Preferences(getActivity(), sharedPreferences); + + fadeInPref = findPreference(Preferences.KEY_FADE_IN_SECS); + fadeInPref.setOnPreferenceChangeListener(this); + updateFadeInSecondsSummary(sharedPreferences.getInt(Preferences.KEY_FADE_IN_SECS, 0)); + + SwitchPreferenceCompat autoConnectPref = findPreference(Preferences.KEY_AUTO_CONNECT); + autoConnectPref.setChecked(sharedPreferences.getBoolean(Preferences.KEY_AUTO_CONNECT, true)); + + fillScrobblePreferences(sharedPreferences); + + fillDownloadPreferences(preferences); + fillThemeSelectionPreferences(); + + SwitchPreferenceCompat startSqueezePlayerPref = findPreference( + Preferences.KEY_SQUEEZEPLAYER_ENABLED); + startSqueezePlayerPref.setChecked(sharedPreferences.getBoolean(Preferences.KEY_SQUEEZEPLAYER_ENABLED, true)); + } + + private void fillScrobblePreferences(SharedPreferences preferences) { + SwitchPreferenceCompat scrobblePref = findPreference(Preferences.KEY_SCROBBLE_ENABLED); + scrobblePref.setOnPreferenceChangeListener(this); + + if (!Scrobble.canScrobble()) { + scrobblePref.setSummaryOff(getString(R.string.settings_scrobble_noapp)); + scrobblePref.setChecked(false); + } else { + scrobblePref.setSummaryOff(getString(R.string.settings_scrobble_off)); + + scrobblePref + .setChecked(preferences.getBoolean(Preferences.KEY_SCROBBLE_ENABLED, false)); + + // If an old KEY_SCROBBLE preference exists, use it, delete it, and + // upgrade it to the new KEY_SCROBBLE_ENABLED preference. + if (preferences.contains(Preferences.KEY_SCROBBLE)) { + boolean enabled = (Integer.parseInt( + preferences.getString(Preferences.KEY_SCROBBLE, "0")) > 0); + scrobblePref.setChecked(enabled); + SharedPreferences.Editor editor = preferences.edit(); + editor.putBoolean(Preferences.KEY_SCROBBLE_ENABLED, enabled); + editor.remove(Preferences.KEY_SCROBBLE); + editor.apply(); + } + } + } + + private void fillDownloadPreferences(Preferences preferences) { + final ListPreference pathStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_PATH_STRUCTURE); + final ListPreference filenameStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE); + + fillEnumPreference(pathStructurePreference, DownloadPathStructure.class, preferences.getDownloadPathStructure()); + fillEnumPreference(filenameStructurePreference, DownloadFilenameStructure.class, preferences.getDownloadFilenameStructure()); + + updateDownloadPreferences(preferences); + } + + private void updateDownloadPreferences(Preferences preferences) { + final SwitchPreferenceCompat downloadEnabled = findPreference(Preferences.KEY_DOWNLOAD_ENABLED); + final CheckBoxPreference downloadConfirmation = findPreference(Preferences.KEY_DOWNLOAD_CONFIRMATION); + final CheckBoxPreference useServerPathPreference = findPreference(Preferences.KEY_DOWNLOAD_USE_SERVER_PATH); + final ListPreference pathStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_PATH_STRUCTURE); + final ListPreference filenameStructurePreference = findPreference(Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE); + final boolean enabled = preferences.isDownloadEnabled(); + final boolean useServerPath = preferences.isDownloadUseServerPath(); + + downloadEnabled.setChecked(enabled); + downloadConfirmation.setChecked(preferences.isDownloadConfirmation()); + useServerPathPreference.setChecked(useServerPath); + + downloadConfirmation.setEnabled(enabled); + useServerPathPreference.setEnabled(enabled); + pathStructurePreference.setEnabled(enabled && !useServerPath); + filenameStructurePreference.setEnabled(enabled && !useServerPath); + } + + private void fillThemeSelectionPreferences() { + ListPreference onSelectThemePref = findPreference(Preferences.KEY_ON_THEME_SELECT_ACTION); + ArrayList entryValues = new ArrayList<>(); + ArrayList entries = new ArrayList<>(); + + for (ThemeManager.Theme theme : ThemeManager.Theme.values()) { + entryValues.add(theme.name()); + entries.add(theme.getText(getActivity())); + } + + onSelectThemePref.setEntryValues(entryValues.toArray(new String[entryValues.size()])); + onSelectThemePref.setEntries(entries.toArray(new String[0])); + onSelectThemePref.setDefaultValue(ThemeManager.getDefaultTheme().name()); + if (onSelectThemePref.getValue() == null) { + onSelectThemePref.setValue(ThemeManager.getDefaultTheme().name()); + } else { + try { + ThemeManager.Theme t = ThemeManager.Theme.valueOf(onSelectThemePref.getValue()); + } catch (Exception e) { + onSelectThemePref.setValue(ThemeManager.getDefaultTheme().name()); + } + } + onSelectThemePref.setOnPreferenceChangeListener(this); + updateListPreferenceSummary(onSelectThemePref, onSelectThemePref.getValue()); + } + + private & EnumWithText> void fillEnumPreference(ListPreference listPreference, Class actionTypes, E defaultValue) { + fillEnumPreference(listPreference, actionTypes.getEnumConstants(), defaultValue); + } + + private & EnumWithText> void fillEnumPreference(ListPreference listPreference, E[] actionTypes, E defaultValue) { + String[] values = new String[actionTypes.length]; + String[] entries = new String[actionTypes.length]; + for (int i = 0; i < actionTypes.length; i++) { + values[i] = actionTypes[i].name(); + entries[i] = actionTypes[i].getText(getActivity()); + } + listPreference.setEntryValues(values); + listPreference.setEntries(entries); + listPreference.setDefaultValue(defaultValue); + if (listPreference.getValue() == null) { + listPreference.setValue(defaultValue.name()); + } + listPreference.setOnPreferenceChangeListener(this); + updateListPreferenceSummary(listPreference, listPreference.getValue()); + } + + @Override + public void onDestroy() { + super.onDestroy(); + getActivity().unbindService(serviceConnection); + } + + private void updateFadeInSecondsSummary(int fadeInSeconds) { + if (fadeInSeconds == 0) { + fadeInPref.setSummary(R.string.disabled); + } else { + fadeInPref.setSummary(fadeInSeconds + " " + getResources() + .getQuantityString(R.plurals.seconds, fadeInSeconds)); + } + } + + /** + * Explicitly set the preference's summary based on the value for the selected item. + *

+ * Work around a bug in ListPreference on devices running earlier API versions (not + * sure when the bug starts) where the preference summary string is not automatically + * updated when the preference changes. See http://stackoverflow.com/a/7018053/775306 + * for details. + * + * @param pref the preference to set + * @param value the preference's value (might not be set yet) + */ + private void updateListPreferenceSummary(ListPreference pref, String value) { + CharSequence[] entries = pref.getEntries(); + int index = pref.findIndexOfValue(value); + if (index != -1) pref.setSummary(entries[index]); + } + + /** + * A preference has been changed by the user, but has not yet been persisted. + */ + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + final String key = preference.getKey(); + Log.v(TAG, "preference change for: " + key); + + if (Preferences.KEY_FADE_IN_SECS.equals(key)) { + updateFadeInSecondsSummary(Util.getInt(newValue.toString())); + } + + if (Preferences.KEY_ON_THEME_SELECT_ACTION.equals(key) || + Preferences.KEY_DOWNLOAD_PATH_STRUCTURE.equals(key) || + Preferences.KEY_DOWNLOAD_FILENAME_STRUCTURE.equals(key)) { + updateListPreferenceSummary((ListPreference) preference, (String) newValue); + } + + // If the user has enabled Scrobbling but we don't think it will work + // pop up a dialog with links to Google Play for apps to install. + if (Preferences.KEY_SCROBBLE_ENABLED.equals(key)) { + if (newValue.equals(true) && !Scrobble.canScrobble()) { + new ScrobbleAppsDialog().show(getFragmentManager(), TAG); + return false; + } + } + + return true; + } + + /** + * A preference has been changed by the user and is going to be persisted. + */ + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + Log.v(TAG, "Preference changed: " + key); + + if (key.equals(Preferences.KEY_DOWNLOAD_USE_SERVER_PATH) || + key.equals(Preferences.KEY_DOWNLOAD_ENABLED) + ) { + updateDownloadPreferences(new Preferences(getActivity(), sharedPreferences)); + } + + if (service != null) { + service.preferenceChanged(key); + } else { + Log.v(TAG, "service is null!"); + } + } + + public static class ScrobbleAppsDialog extends DialogFragment { + @NonNull + @Override + public AlertDialog onCreateDialog(Bundle savedInstanceState) { + final CharSequence[] apps = { + "Last.fm", "ScrobbleDroid", "SLS" + }; + final CharSequence[] urls = { + "fm.last.android", "net.jjc1138.android.scrobbler", + "com.adam.aslfms" + }; + + final int[] icons = { + R.drawable.ic_launcher_lastfm, + R.drawable.ic_launcher_scrobbledroid, R.drawable.ic_launcher_sls + }; + + final View dialogView = LayoutInflater.from(getActivity()).inflate(R.layout.scrobbler_choice_dialog, null); + AlertDialog dialog = new MaterialAlertDialogBuilder(getActivity()) + .setView(dialogView) + .setTitle("Scrobbling applications") + .create(); + + ListView appList = dialogView.findViewById(R.id.scrobble_apps); + appList.setAdapter(new IconRowAdapter(getActivity(), apps, icons)); + + final Context context = dialog.getContext(); + appList.setOnItemClickListener((parent, view, position, id1) -> { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("market://details?id=" + urls[position])); + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.settings_market_not_found, + Toast.LENGTH_SHORT).show(); + } + }); + + return dialog; + } + + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java new file mode 100644 index 000000000..cc48cee0d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Squeezer.java @@ -0,0 +1,28 @@ +package uk.org.ngo.squeezer; + + +import android.content.Context; + +import androidx.multidex.MultiDexApplication; + +// Trick to make the app context useful available everywhere. +// See http://stackoverflow.com/questions/987072/using-application-context-everywhere + +public class Squeezer extends MultiDexApplication { + + private static Squeezer instance; + + public Squeezer() { + instance = this; + } + + public static Context getContext() { + return instance; + } + + @Override + public void onCreate() { + super.onCreate(); + } +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java new file mode 100644 index 000000000..0ac3d75f8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/Util.java @@ -0,0 +1,360 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.content.res.AppCompatResources; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Formatter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +public class Util { + + /** + * {@link java.util.regex.Pattern} that splits strings on colon. + */ + private static final Pattern mColonSplitPattern = Pattern.compile(":"); + + private Util() { + } + + + /** + * Update target, if it's different from newValue. + * + * @return true if target is updated. Otherwise return false. + */ + public static boolean atomicReferenceUpdated(AtomicReference target, T newValue) { + T currentValue = target.get(); + if (currentValue == null && newValue == null) { + return false; + } + if (currentValue == null || !currentValue.equals(newValue)) { + target.set(newValue); + return true; + } + return false; + } + + public static double parseDouble(String value, double defaultValue) { + if (value == null) { + return defaultValue; + } + if (value.length() == 0) { + return defaultValue; + } + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + public static long parseDecimalInt(String value, long defaultValue) { + if (value == null) { + return defaultValue; + } + int decimalPoint = value.indexOf('.'); + if (decimalPoint != -1) { + value = value.substring(0, decimalPoint); + } + if (value.length() == 0) { + return defaultValue; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + @SuppressWarnings("unchecked") + public static Map getRecord(Map record, String recordName) { + return (Map) record.get(recordName); + } + + public static double getDouble(Map record, String fieldName) { + return getDouble(record, fieldName, 0); + } + + public static double getDouble(Map record, String fieldName, double defaultValue) { + return getDouble(record.get(fieldName), defaultValue); + } + + public static double getDouble(Object value, double defaultValue) { + return (value instanceof Number) ? ((Number) value).doubleValue() : parseDouble((String) value, defaultValue); + } + + public static long getLong(Map record, String fieldName) { + return getLong(record, fieldName, 0); + } + + public static long getLong(Map record, String fieldName, long defaultValue) { + return getLong(record.get(fieldName), defaultValue); + } + + public static long getLong(Object value, long defaultValue) { + return (value instanceof Number) ? ((Number) value).intValue() : parseDecimalInt((String) value, defaultValue); + } + + public static int getInt(Map record, String fieldName) { + return getInt(record, fieldName, 0); + } + + public static int getInt(Map record, String fieldName, int defaultValue) { + return getInt(record.get(fieldName), defaultValue); + } + + public static int getInt(Object value, int defaultValue) { + return (value instanceof Number) ? ((Number) value).intValue() : (int) parseDecimalInt((String) value, defaultValue); + } + + public static int getInt(Object value) { + return getInt(value, 0); + } + + public static String getString(Map record, String fieldName) { + return getString(record.get(fieldName), null); + } + + public static String getString(Map record, String fieldName, String defaultValue) { + return getString(record.get(fieldName), defaultValue); + } + + @NonNull + public static String getStringOrEmpty(Map record, String fieldName) { + return getStringOrEmpty(record.get(fieldName)); + } + + @NonNull + public static String getStringOrEmpty(Object value) { + return getString(value, ""); + } + + public static String getString(Object value, String defaultValue) { + if (value == null) return defaultValue; + return (value instanceof String) ? (String) value : value.toString(); + } + + public static String[] getStringArray(Map record, String fieldName) { + return getStringArray((Object[]) record.get(fieldName)); + } + + private static String[] getStringArray(Object[] objects) { + String[] result = new String[objects == null ? 0 : objects.length]; + if (objects != null) { + for (int i = 0; i < objects.length; i++) { + result[i] = getString(objects[i], null); + } + } + return result; + } + + public static Map mapify(String[] tokens) { + Map tokenMap = new HashMap<>(); + for (String token : tokens) { + String[] split = mColonSplitPattern.split(token, 2); + tokenMap.put(split[0], split.length > 1 ? split[1] : null); + } + return tokenMap; + } + + /** + * Make sure the icon/image tag is an absolute URL. + */ + private static final Pattern HEX_PATTERN = Pattern.compile("^\\p{XDigit}+$"); + + @NonNull + public static Uri getImageUrl(String urlPrefix, String imageId) { + if (imageId != null) { + if (HEX_PATTERN.matcher(imageId).matches()) { + // if the iconId is a hex digit, this is a coverid or remote track id(a negative id) + imageId = "/music/" + imageId + "/cover"; + } + + // Make sure the url is absolute + if (!Uri.parse(imageId).isAbsolute()) { + imageId = urlPrefix + (imageId.startsWith("/") ? imageId : "/" + imageId); + } + } + return Uri.parse(imageId != null ? imageId : ""); + } + + @NonNull + public static Uri getImageUrl(Map record, String fieldName) { + return getImageUrl(getString(record, "urlPrefix"), getString(record, fieldName)); + } + + /** + * Make sure the icon/image tag is an absolute URL. + */ + @NonNull + public static Uri getDownloadUrl(String urlPrefix, String trackId) { + return Uri.parse(urlPrefix + "/music/" + trackId + "/download"); + } + + private static final StringBuilder sFormatBuilder = new StringBuilder(); + + private static final Formatter sFormatter = new Formatter(sFormatBuilder, Locale.getDefault()); + + private static final Object[] sTimeArgs = new Object[5]; + + /** + * Formats an elapsed time in the form "M:SS" or "H:MM:SS" for display. + *

+ * Like {@link android.text.format.DateUtils#formatElapsedTime(long)} but without the leading + * zeroes if the number of minutes is < 10. + * + * @param elapsedSeconds the elapsed time, in seconds. + */ + public synchronized static String formatElapsedTime(long elapsedSeconds) { + calculateTimeArgs(elapsedSeconds); + sFormatBuilder.setLength(0); + return sFormatter.format("%2$d:%5$02d", sTimeArgs).toString(); + } + + private static void calculateTimeArgs(long elapsedSeconds) { + sTimeArgs[0] = elapsedSeconds / 3600; + sTimeArgs[1] = elapsedSeconds / 60; + sTimeArgs[2] = (elapsedSeconds / 60) % 60; + sTimeArgs[3] = elapsedSeconds; + sTimeArgs[4] = elapsedSeconds % 60; + } + + /** + * Returns {@code true} if the arguments are equal to each other + * and {@code false} otherwise. + * Consequently, if both arguments are {@code null}, {@code true} + * is returned and if exactly one argument is {@code null}, {@code + * false} is returned. Otherwise, equality is determined by using + * the {@link Object#equals equals} method of the first + * argument. + */ + public static boolean equals(Object a, Object b) { + return (a == b) || (a != null && a.equals(b)); + } + + /** + * @return a view suitable for use as a spinner view. + */ + public static View getSpinnerItemView(Context context, View convertView, ViewGroup parent, + String label) { + return getSpinnerView(context, convertView, parent, label, + android.R.layout.simple_spinner_item); + } + + public static View getActionBarSpinnerItemView(Context context, View convertView, + ViewGroup parent, String label) { + return getSpinnerView(context, convertView, parent, label, + androidx.appcompat.R.layout.support_simple_spinner_dropdown_item); + } + + private static View getSpinnerView(Context context, View convertView, ViewGroup parent, + String label, int layout) { + TextView view; + view = (TextView) (convertView != null + && TextView.class.isAssignableFrom(convertView.getClass()) + ? convertView + : ((LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE)).inflate( + layout, parent, false)); + view.setText(label); + return view; + } + + @NonNull + public static String getBaseName(String fileName) { + String name = new File(fileName).getName(); + int pos = name.lastIndexOf("."); + return (pos > 0) ? name.substring(0, pos) : name; + } + + public static void moveFile(ContentResolver resolver, Uri source, Uri destination) throws IOException { + try (InputStream inputStream = resolver.openInputStream(source); + OutputStream outputStream = resolver.openOutputStream(destination)) { + if (inputStream == null) { + throw new IOException("moveFile: could not open '" + source + "'"); + } + if (outputStream == null) { + throw new IOException("moveFile: could not open '" + destination + "'"); + } + byte[] b = new byte[16384]; + int bytes; + while ((bytes = inputStream.read(b)) > 0) { + outputStream.write(b, 0, bytes); + } + } + int deleted = resolver.delete(source, null, null); + if (deleted != 1) { + throw new IOException("moveFile: try to delete '" + source + "' after copy, expected 1 deleted file but was " + deleted); + } + + } + + public static Bitmap vectorToBitmap(Context context, @DrawableRes int vectorResource) { + return drawableToBitmap(AppCompatResources.getDrawable(context, vectorResource)); + } + + public static Bitmap vectorToBitmap(Context context, @DrawableRes int vectorResource, int alpha) { + Drawable drawable = AppCompatResources.getDrawable(context, vectorResource); + drawable.setAlpha(alpha); + return drawableToBitmap(drawable); + } + + public static Bitmap drawableToBitmap(Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + if(bitmapDrawable.getBitmap() != null) { + return bitmapDrawable.getBitmap(); + } + } + + return drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0 + ? getBitmap(drawable, 1, 1) // Single color bitmap will be created of 1x1 pixel + : getBitmap(drawable, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + + } + + private static Bitmap getBitmap(Drawable drawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java new file mode 100644 index 000000000..db8b6b6cd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/VolumePanel.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2011 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package uk.org.ngo.squeezer; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.Context; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Message; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import com.sdsmdg.harjot.crollerTest.Croller; +import com.sdsmdg.harjot.crollerTest.OnCrollerChangeListener; + +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.service.ISqueezeService; + + +/** + * Implement a custom volume toast view + */ +public class VolumePanel extends Handler implements OnCrollerChangeListener { + + private static final int TIMEOUT_DELAY = 3000; + + private static final int MSG_VOLUME_CHANGED = 0; + + private static final int MSG_TIMEOUT = 2; + + private final BaseActivity mActivity; + + /** + * Dialog displaying the volume panel. + */ + private final Dialog mDialog; + + /** + * View displaying volume sliders. + */ + private final View mView; + + private final TextView mMessage; + private final TextView mLabel; + + private final Croller mSeekbar; + private int mCurrentProgress = 0; + private boolean mTrackingTouch = false; + + @SuppressLint({"InflateParams"}) // OK, as view is passed to Dialog.setView() + public VolumePanel(BaseActivity activity) { + mActivity = activity; + + LayoutInflater inflater = (LayoutInflater) activity + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mView = inflater.inflate(R.layout.volume_adjust, null); + GradientDrawable background = (GradientDrawable) ContextCompat.getDrawable(mActivity, R.drawable.panel_background); + background.setColor(activity.getResources().getColor(activity.getAttributeValue(R.attr.colorSurface))); + background.setStroke( + activity.getResources().getDimensionPixelSize(R.dimen.volume_panel_border_Width), + activity.getResources().getColor(activity.getAttributeValue(R.attr.colorPrimary)) + ); + mView.setBackground(background); + mView.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + resetTimeout(); + return false; + } + }); + + mMessage = mView.findViewById(R.id.message); + mLabel = mView.findViewById(R.id.label); + mSeekbar = mView.findViewById(R.id.level); + + mSeekbar.setOnCrollerChangeListener(this); + + mDialog = new Dialog(mActivity, R.style.VolumePanel) { //android.R.style.Theme_Panel) { + @Override + public boolean onTouchEvent(MotionEvent event) { + if (isShowing() && event.getAction() == MotionEvent.ACTION_OUTSIDE) { + forceTimeout(); + return true; + } + return false; + } + }; + mDialog.setTitle("Volume Control"); + mDialog.setContentView(mView); + + // Set window properties to match other toasts/dialogs. + Window window = mDialog.getWindow(); + window.setGravity(Gravity.TOP); + WindowManager.LayoutParams lp = window.getAttributes(); + lp.token = null; + lp.y = activity.getResources().getDimensionPixelSize(R.dimen.volume_panel_top_margin); + lp.width = WindowManager.LayoutParams.WRAP_CONTENT; + lp.height = WindowManager.LayoutParams.WRAP_CONTENT; + window.setAttributes(lp); + window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH); + } + + public void dismiss() { + removeMessages(MSG_TIMEOUT); + if (mDialog.isShowing()) { + mDialog.dismiss(); + } + } + + private void resetTimeout() { + removeMessages(MSG_TIMEOUT); + sendMessageDelayed(obtainMessage(MSG_TIMEOUT), TIMEOUT_DELAY); + } + + private void forceTimeout() { + removeMessages(MSG_TIMEOUT); + sendMessage(obtainMessage(MSG_TIMEOUT)); + } + + @Override + public void onProgressChanged(Croller croller, int progress) { + if (mCurrentProgress != progress) { + mCurrentProgress = progress; + ISqueezeService service = mActivity.getService(); + if (service != null) { + service.adjustVolumeTo(progress); + } + } + } + + @Override + public void onStartTrackingTouch(Croller croller) { + mTrackingTouch = true; + removeMessages(MSG_TIMEOUT); + } + + @Override + public void onStopTrackingTouch(Croller croller) { + mTrackingTouch = false; + resetTimeout(); + } + + public void postVolumeChanged(int newVolume, String additionalMessage) { + if (hasMessages(MSG_VOLUME_CHANGED)) { + return; + } + obtainMessage(MSG_VOLUME_CHANGED, newVolume, 0, additionalMessage).sendToTarget(); + } + + private void onShowVolumeChanged(int newVolume, String additionalMessage) { + if (mTrackingTouch) { + return; + } + + mCurrentProgress = newVolume; + mSeekbar.setProgress(newVolume); + mMessage.setText(mActivity.getString(R.string.volume, mActivity.getString(R.string.app_name))); + mLabel.setText(additionalMessage); + + if (!mDialog.isShowing() && !mActivity.isFinishing()) { + mDialog.setContentView(mView); + mDialog.show(); + } + + resetTimeout(); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + + case MSG_VOLUME_CHANGED: { + onShowVolumeChanged(msg.arg1, (String) msg.obj); + break; + } + + case MSG_TIMEOUT: { + dismiss(); + break; + } + } + } +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java new file mode 100644 index 000000000..c68bc5a4f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AboutDialog.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.view.View; +import android.widget.TextView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.BuildConfig; +import uk.org.ngo.squeezer.R; + +public class AboutDialog extends DialogFragment { + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + @SuppressLint({"InflateParams"}) + final View view = getActivity().getLayoutInflater().inflate(R.layout.about_dialog, null); + final TextView titleText = view.findViewById(R.id.about_title); + final TextView versionText = view.findViewById(R.id.version_text); + + PackageManager pm = getActivity().getPackageManager(); + PackageInfo info; + try { + info = pm.getPackageInfo(getActivity().getPackageName(), 0); + if (BuildConfig.DEBUG) { + versionText.setText(info.versionName + ' ' + BuildConfig.GIT_DESCRIPTION); + } else { + versionText.setText(info.versionName); + } + } catch (NameNotFoundException e) { + titleText.setText(getString(R.string.app_name)); + } + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, null); + builder.setNeutralButton(R.string.changelog_full_title, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + ChangeLogDialog changeLog = new ChangeLogDialog(getActivity()); + changeLog.getThemedFullLogDialog().show(); + } + }); + builder.setNegativeButton(R.string.dialog_license, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + new LicenseDialog() + .show(getActivity().getSupportFragmentManager(), "LicenseDialog"); + } + }); + return builder.create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java new file mode 100644 index 000000000..972af4fff --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/AlertEventDialog.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +public class AlertEventDialog extends DialogFragment { + private static final String TAG = AlertEventDialog.class.getSimpleName(); + private static final String TITLE_KEY = "TITLE_KEY"; + private static final String MESSAGE_KEY = "MESSAGE_KEY"; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new MaterialAlertDialogBuilder(getActivity()) + .setTitle(getArguments().getString(TITLE_KEY)) + .setMessage(getArguments().getString(MESSAGE_KEY)) + .setPositiveButton(android.R.string.ok, null) + .create(); + } + + public static AlertEventDialog show(FragmentManager fragmentManager, String title, String text) { + AlertEventDialog dialog = new AlertEventDialog(); + + Bundle args = new Bundle(); + args.putString(TITLE_KEY, title); + args.putString(MESSAGE_KEY, text); + dialog.setArguments(args); + + dialog.show(fragmentManager, TAG); + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java new file mode 100644 index 000000000..d9b549da3 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ChangeLogDialog.java @@ -0,0 +1,81 @@ +package uk.org.ngo.squeezer.dialog; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import androidx.appcompat.app.AlertDialog; +import android.webkit.WebView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; + +/** + * Extends ChangeLog to use the v7 support AlertDialog which follows the application theme. + */ +public class ChangeLogDialog extends de.cketti.library.changelog.ChangeLog { + public ChangeLogDialog(final Context context) { + super(context); + } + + public ChangeLogDialog(final Context context, final String css) { + super(context, css); + } + + public ChangeLogDialog(final Context context, final SharedPreferences preferences, final String css) { + super(context, preferences, css); + } + + /** + * Get a themed "What's New" dialog. + * + * @return An AlertDialog displaying the changes since the previous installed version of your + * app (What's New). But when this is the first run of your app including + * {@code ChangeLog} then the full log dialog is show. + */ + public AlertDialog getThemedLogDialog() { + return getThemedDialog(isFirstRunEver()); + } + + /** + * Get a themed dialog with the full change log. + * + * @return An AlertDialog with a full change log displayed. + */ + public AlertDialog getThemedFullLogDialog() { + return getThemedDialog(true); + } + + private AlertDialog getThemedDialog(boolean full) { + WebView wv = new WebView(mContext.getApplicationContext()); + wv.loadDataWithBaseURL(null, getLog(full), "text/html", "UTF-8", null); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(mContext); + builder.setView(wv) + .setCancelable(false) + // OK button + .setPositiveButton( + mContext.getResources().getString(R.string.changelog_ok_button), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // The user clicked "OK" so save the current version code as + // "last version code". + updateVersionInPreferences(); + } + }); + + if (!full) { + // Show "More..." button if we're only displaying a partial change log. + builder.setNegativeButton(R.string.changelog_show_full, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + getThemedFullLogDialog().show(); + } + }); + } + + return builder.create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java new file mode 100644 index 000000000..8aab22871 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/DownloadDialog.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.content.DialogInterface; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.JiveItem; + +public class DownloadDialog extends DialogFragment { + private static final String TAG = DownloadDialog.class.getSimpleName(); + private static final String TITLE_KEY = "TITLE_KEY"; + private DownloadDialogListener callback; + + public DownloadDialog(DownloadDialogListener callback) { + this.callback = callback; + } + + @NonNull + @Override + public AlertDialog onCreateDialog(Bundle savedInstanceState) { + return new MaterialAlertDialogBuilder(getActivity()) + .setTitle(getString(R.string.download_item, getArguments().getString(TITLE_KEY))) + .setMultiChoiceItems(new String[]{getString(R.string.DONT_ASK_AGAIN)}, new boolean[]{false}, (dialogInterface, i, b) -> setNegativeButtonText(b)) + .setPositiveButton(R.string.DOWNLOAD, (dialogInterface, i) -> callback.download(isPersistChecked())) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> callback.cancel(isPersistChecked())) + .create(); + } + + private void setNegativeButtonText(boolean b) { + getDialog().getButton(DialogInterface.BUTTON_NEGATIVE).setText(b ? R.string.disable_downloads : android.R.string.cancel); + } + + private boolean isPersistChecked() { + return getDialog().getListView().isItemChecked(0); + } + + @Override + public AlertDialog getDialog() { + return (AlertDialog) super.getDialog(); + } + + public static DownloadDialog show(FragmentManager fragmentManager, JiveItem item, DownloadDialogListener callback) { + DownloadDialog dialog = new DownloadDialog(callback); + + Bundle args = new Bundle(); + args.putString(TITLE_KEY, item.getName()); + dialog.setArguments(args); + + dialog.show(fragmentManager, TAG); + return dialog; + } + + public interface DownloadDialogListener { + void download(boolean persist); + void cancel(boolean persist); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java new file mode 100644 index 000000000..9fbdd2381 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/EnableWifiDialog.java @@ -0,0 +1,44 @@ +package uk.org.ngo.squeezer.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.net.wifi.WifiManager; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.util.Log; +import android.widget.Toast; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; + +public class EnableWifiDialog extends DialogFragment { + + public static final String TAG = EnableWifiDialog.class.getSimpleName(); + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setTitle(R.string.wifi_disabled_text); + builder.setMessage(R.string.enable_wifi_text); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + WifiManager wifiManager = (WifiManager) getActivity() + .getApplicationContext().getSystemService( + Context.WIFI_SERVICE); + if (!wifiManager.isWifiEnabled()) { + Log.v(TAG, "Enabling Wifi"); + wifiManager.setWifiEnabled(true); + Toast.makeText(getActivity(), R.string.wifi_enabled_text, Toast.LENGTH_LONG) + .show(); + } + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + return builder.create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java new file mode 100644 index 000000000..86be570a3 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/LicenseDialog.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.text.Html; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; + +public class LicenseDialog extends DialogFragment { + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new MaterialAlertDialogBuilder(getActivity()) + .setMessage(Html.fromHtml((String) getText(R.string.license_text))) + .setPositiveButton(android.R.string.ok, null) + .create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java new file mode 100644 index 000000000..1a8817a50 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/NetworkErrorDialogFragment.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +/** + * A dialog for displaying networking error messages received from the server. + *

+ * Activities that host this dialog must implement + * {@link NetworkErrorDialogFragment.NetworkErrorDialogListener} to + * be notified when the user dismisses the dialog. + *

+ * To easily create the dialog displaying a given message call {@link #newInstance(String)} with + * the message to display. + */ +public class NetworkErrorDialogFragment extends DialogFragment { + /** Key used to store the message in the arguments bundle. */ + private static final String MESSAGE_KEY = "message"; + + /** The activity that hosts this dialog. */ + private NetworkErrorDialogListener mListener; + + /** + * Activities hosting this dialog must implement this interface in order to receive + * notifications when the user dismisses the dialog. + */ + public interface NetworkErrorDialogListener { + + /** + * The user has dismissed the dialog. Either by clicking the OK button, or by pressing + * the "Back" button. + * + * @param dialog The dialog that has been dismissed. + */ + void onDialogDismissed(DialogInterface dialog); + } + + /** + * Static factory method for creating an instance that will display the given message. + * + * @param message The message to display in the dialog. + * @return The created dialog fragment. + */ + @NonNull + public static NetworkErrorDialogFragment newInstance(@NonNull String message) { + NetworkErrorDialogFragment fragment = new NetworkErrorDialogFragment(); + + Bundle args = new Bundle(); + args.putString(MESSAGE_KEY, message); + fragment.setArguments(args); + + return fragment; + } + + // Ensure that the containing activity implements NetworkErrorDialogListener. + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + try { + mListener = (NetworkErrorDialogListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException(activity + " must implement NetworkErrorDialogListener"); + } + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + String message = getArguments().getString(MESSAGE_KEY); + if (message == null) { + message = "No message provided."; + } + + return new MaterialAlertDialogBuilder(getActivity()) + .setMessage(message).setPositiveButton(android.R.string.ok, null) + .create(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + mListener.onDialogDismissed(dialog); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java new file mode 100644 index 000000000..b9be742b9 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/ServerAddressView.java @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.util.AttributeSet; +import android.view.View; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.Spinner; +import android.widget.TextView; + +import java.util.Map.Entry; +import java.util.TreeMap; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.util.ScanNetworkTask; + +/** + * Scans the local network for servers, allow the user to choose one, set it as the preferred server + * for this network, and optionally enter authentication information. + *

+ * A new network scan can be initiated manually if desired. + */ +public class ServerAddressView extends LinearLayout implements ScanNetworkTask.ScanNetworkCallback { + private Preferences mPreferences; + private Preferences.ServerAddress mServerAddress; + + private RadioButton mSqueezeNetworkButton; + private RadioButton mLocalServerButton; + private EditText mServerAddressEditText; + private TextView mServerName; + private Spinner mServersSpinner; + private EditText mUserNameEditText; + private EditText mPasswordEditText; + private View mScanResults; + private View mScanProgress; + + private ScanNetworkTask mScanNetworkTask; + + /** Map server names to IP addresses. */ + private TreeMap mDiscoveredServers; + + private ArrayAdapter mServersAdapter; + + public ServerAddressView(final Context context) { + super(context); + initialize(context); + } + + public ServerAddressView(Context context, AttributeSet attrs) { + super(context, attrs); + initialize(context); + } + + private void initialize(final Context context) { + inflate(context, R.layout.server_address_view, this); + if (!isInEditMode()) { + mPreferences = new Preferences(context); + mServerAddress = mPreferences.getServerAddress(); + if (mServerAddress.localAddress() == null) { + Preferences.ServerAddress cliServerAddress = mPreferences.getCliServerAddress(); + if (cliServerAddress.localAddress() != null) { + mServerAddress.setAddress(cliServerAddress.localHost()); + } + } + + mSqueezeNetworkButton = findViewById(R.id.squeezeNetwork); + mLocalServerButton = findViewById(R.id.squeezeServer); + + mServerAddressEditText = findViewById(R.id.server_address); + mUserNameEditText = findViewById(R.id.username); + mPasswordEditText = findViewById(R.id.password); + + final OnClickListener onNetworkSelected = view -> setSqueezeNetwork(view.getId() == R.id.squeezeNetwork); + mSqueezeNetworkButton.setOnClickListener(onNetworkSelected); + mLocalServerButton.setOnClickListener(onNetworkSelected); + + // Set up the servers spinner. + mServersAdapter = new ArrayAdapter<>(context, android.R.layout.simple_spinner_item); + mServersAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mServerName = findViewById(R.id.server_name); + mServersSpinner = findViewById(R.id.found_servers); + mServersSpinner.setAdapter(mServersAdapter); + + mScanResults = findViewById(R.id.scan_results); + mScanProgress = findViewById(R.id.scan_progress); + mScanProgress.setVisibility(GONE); + TextView scanDisabledMessage = findViewById(R.id.scan_disabled_msg); + + setSqueezeNetwork(mServerAddress.squeezeNetwork); + setServerAddress(mServerAddress.localAddress()); + + // Only support network scanning on WiFi. + ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo ni = connectivityManager.getActiveNetworkInfo(); + boolean isWifi = ni != null && ni.getType() == ConnectivityManager.TYPE_WIFI; + if (isWifi) { + scanDisabledMessage.setVisibility(GONE); + startNetworkScan(context); + Button scanButton = findViewById(R.id.scan_button); + scanButton.setOnClickListener(v -> startNetworkScan(context)); + } else { + mScanResults.setVisibility(GONE); + } + } + } + + public void savePreferences() { + mServerAddress.squeezeNetwork = mSqueezeNetworkButton.isChecked(); + String address = mServerAddressEditText.getText().toString(); + mServerAddress.setAddress(address); + mPreferences.saveServerAddress(mServerAddress); + + mPreferences.saveServerName(mServerAddress, getServerName(address)); + + String username = mUserNameEditText.getText().toString(); + String password = mPasswordEditText.getText().toString(); + mPreferences.saveUserCredentials(mServerAddress, username, password); + } + + @Override + protected void onDetachedFromWindow() { + // Stop scanning + if (mScanNetworkTask != null) { + mScanNetworkTask.cancel(); + } + + super.onDetachedFromWindow(); + } + + /** + * Starts scanning for servers. + */ + void startNetworkScan(Context context) { + mScanResults.setVisibility(GONE); + mScanProgress.setVisibility(VISIBLE); + mScanNetworkTask = new ScanNetworkTask(context, this); + new Thread(mScanNetworkTask).start(); + } + + /** + * Called when server scanning has finished. + * @param serverMap Discovered servers, key is the server name, value is the IP address. + */ + public void onScanFinished(TreeMap serverMap) { + mScanResults.setVisibility(VISIBLE); + mServerName.setVisibility(GONE); + mServersSpinner.setVisibility(GONE); + mScanProgress.setVisibility(GONE); + mServersAdapter.clear(); + + if (mScanNetworkTask == null) { + return; + } + + mDiscoveredServers = serverMap; + + mScanNetworkTask = null; + + if (mDiscoveredServers.size() == 0) { + // No servers found, manually enter address + // Populate the edit text widget with current address stored in preferences. + setServerAddress(mServerAddress.localAddress()); + mServerAddressEditText.setEnabled(true); + mServerName.setVisibility(VISIBLE); + } else { + // Show the spinner so the user can choose a server or to manually enter address. + // Don't fire onItemSelected by calling notifyDataSetChanged and + // setSelection(pos, false) before setting OnItemSelectedListener + mServersSpinner.setOnItemSelectedListener(null); + + for (Entry e : mDiscoveredServers.entrySet()) { + mServersAdapter.add(e.getKey()); + } + mServersAdapter.add(getContext().getString(R.string.settings_manual_server_addr)); + mServersAdapter.notifyDataSetChanged(); + + // First look the stored server name in the list of found servers + String addressOfStoredServerName = mDiscoveredServers.get(mPreferences.getServerName(mServerAddress)); + int position = getServerPosition(addressOfStoredServerName); + + // If that fails, look for the stored server address in the list of found servers + if (position < 0) { + position = getServerPosition(mServerAddress.localAddress()); + } + + mServersSpinner.setSelection((position < 0 ? mServersAdapter.getCount() - 1 : position), false); + mServerAddressEditText.setEnabled(position < 0 && !mServerAddress.squeezeNetwork); + + mServersSpinner.setOnItemSelectedListener(new MyOnItemSelectedListener()); + mServersSpinner.setVisibility(VISIBLE); + } + } + + private void setSqueezeNetwork(boolean isSqueezeNetwork) { + mSqueezeNetworkButton.setChecked(isSqueezeNetwork); + mLocalServerButton.setChecked(!isSqueezeNetwork); + setEditServerAddressAvailability(isSqueezeNetwork); + mUserNameEditText.setEnabled(!isSqueezeNetwork); + mPasswordEditText.setEnabled(!isSqueezeNetwork); + } + + private void setServerAddress(String address) { + mServerAddress.setAddress(address); + + mServerAddressEditText.setText(mServerAddress.localAddress()); + mUserNameEditText.setText(mPreferences.getUsername(mServerAddress)); + mPasswordEditText.setText(mPreferences.getPassword(mServerAddress)); + } + + private void setEditServerAddressAvailability(boolean isSqueezeNetwork) { + if (isSqueezeNetwork) { + mServerAddressEditText.setEnabled(false); + } else if (mServersAdapter.getCount() == 0) { + mServerAddressEditText.setEnabled(true); + } else { + mServerAddressEditText.setEnabled(mServersSpinner.getSelectedItemPosition() == mServersSpinner.getCount() - 1); + } + } + + private String getServerName(String ipPort) { + if (mDiscoveredServers != null) + for (Entry entry : mDiscoveredServers.entrySet()) + if (ipPort.equals(entry.getValue())) + return entry.getKey(); + return null; + } + + private int getServerPosition(String host) { + if (host != null && mDiscoveredServers != null) { + int position = 0; + for (Entry entry : mDiscoveredServers.entrySet()) { + if (host.equals(entry.getValue())) + return position; + position++; + } + } + return -1; + } + + /** + * Inserts the selected address in to the edit text widget. + */ + private class MyOnItemSelectedListener implements OnItemSelectedListener { + public void onItemSelected(AdapterView parent, View view, int pos, long id) { + String serverAddress = mDiscoveredServers.get(mServersAdapter.getItem(pos)); + setSqueezeNetwork(false); + setServerAddress(serverAddress); + } + + public void onNothingSelected(AdapterView parent) { + // Do nothing. + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java new file mode 100644 index 000000000..160c10661 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/dialog/TipsDialog.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.dialog; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.DialogInterface.OnKeyListener; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.view.KeyEvent; +import android.view.View; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; + +public class TipsDialog extends DialogFragment implements OnKeyListener { + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + @SuppressLint({"InflateParams"}) + final View view = getActivity().getLayoutInflater().inflate(R.layout.tips_dialog, null); + + return new MaterialAlertDialogBuilder(getActivity()) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setOnKeyListener(this) + .create(); + } + + /* + * Intercept hardware volume control keys to control Squeezeserver volume. + * + * Change the volume when the key is depressed. Suppress the keyUp event, + * otherwise you get a notification beep as well as the volume changing. + * + * TODO: Do this for all the dialog. + */ + @Override + public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + return getActivity().onKeyDown(keyCode, event); + } + + return false; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java new file mode 100644 index 000000000..014f238de --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/CancelDownloadsActivity.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.download; + +import android.app.Activity; +import android.app.DownloadManager; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.util.AsyncTask; + +/** + * An activity which gives the option, using a dialog theme, to cancel pending + * Squeezer downloads. + */ +public class CancelDownloadsActivity extends Activity { + private static final String TAG = CancelDownloadsActivity.class.getSimpleName(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.cancel_downloads); + findViewById(R.id.cancel_button).setOnClickListener(view -> finish()); + findViewById(R.id.ok_button).setOnClickListener(view -> { + cancelDownloads(); + finish(); + }); + } + + private void cancelDownloads() { + Log.i(TAG, "cancelDownloads"); + new CancelDownloadsTask(this).execute(); + } + + static class CancelDownloadsTask extends AsyncTask { + final DownloadDatabase downloadDatabase; + final DownloadManager downloadManager; + + public CancelDownloadsTask(Context context) { + downloadDatabase = new DownloadDatabase(context); + downloadManager =(DownloadManager)context.getSystemService(Context.DOWNLOAD_SERVICE); + } + + @Override + protected Void doInBackground(Void... params) { + downloadDatabase.iterateDownloadEntries(entry -> { + if (entry.downloadId != -1) { + downloadManager.remove(entry.downloadId); + } + downloadDatabase.remove(entry.url); + }); + return null; + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java new file mode 100644 index 000000000..83c018bcc --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadDatabase.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.download; + +import android.app.DownloadManager; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Maintain a queue of download requests. + *

+ * Enqueue a new download request via {@link #enqueueDownload(DownloadManager, String, Uri, String)} and + * call {@link #popDownloadEntry(Context, long)} when a download is completed. + */ +public class DownloadDatabase { + public static final String TAG = DownloadDatabase.class.getSimpleName(); + + private static class DOWNLOAD_DATABASE { + private static final String NAME = "download"; + private static final int VERSION = 5; + + private static class SONG { + private static final String TABLE = "download"; + + private static class COLUMNS { + private static final String DOWNLOAD_ID = "download_id"; + private static final String URL = "url"; + private static final String FILE_NAME = "file_name"; + private static final String CREDENTIALS = "credentials"; + private static final String TITLE = "title"; + private static final String ALBUM = "album"; + private static final String ARTIST = "artist"; + } + } + } + + private final SQLiteDatabase db; + + public DownloadDatabase(Context context) { + db = OpenHelper.getInstance(context).getWritableDatabase(); + } + + private static class OpenHelper extends SQLiteOpenHelper { + + private static final Object mInstanceLock = new Object(); + private static OpenHelper mInstance; + + private OpenHelper(Context context) { + // calls the super constructor, requesting the default cursor + // factory. + super(context, DOWNLOAD_DATABASE.NAME, null, DOWNLOAD_DATABASE.VERSION); + } + + public static OpenHelper getInstance(Context context) { + if (mInstance == null) { + synchronized (mInstanceLock) { + if (mInstance == null) { + mInstance = new OpenHelper(context); + } + } + } + return mInstance; + } + + @Override + public void onCreate(SQLiteDatabase sqLiteDatabase) { + sqLiteDatabase.execSQL("CREATE TABLE " + DOWNLOAD_DATABASE.SONG.TABLE + "(" + + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + " INTEGER, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.URL + " TEXT, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME + " TEXT, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS + " TEXT, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE + " TEXT, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM + " TEXT, " + + DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST + " TEXT)"); + } + + @Override + public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) { + sqLiteDatabase.execSQL("DROP TABLE IF EXISTS " + DOWNLOAD_DATABASE.SONG.TABLE); + // Upgrades just creates a new database. The database keeps track of + // active downloads, so it holds only temporary information. + onCreate(sqLiteDatabase); + } + + } + + /** + * Register a download request. + */ + public void registerDownload(Context context, String credentials, Uri url, @NonNull String fileName, @NonNull String title, String album, String artist) { + // To avoid download manager stops processing our requests due to exceeding the rate + // limit for notifications (because download manager shows a notification), we delay + // enqueuing further download requests until any current enqueued requests is completed. + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + long downloadId = (activeRequests() < 4) ? enqueueDownload(downloadManager, credentials, url, title) : -1; + + ContentValues contentValues = new ContentValues(); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID, downloadId); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.URL, url.toString()); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME, fileName); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS, credentials); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE, title); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM, album); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST, artist); + if (db.insert(DOWNLOAD_DATABASE.SONG.TABLE, null, contentValues) == -1) { + Log.w(TAG, "Could not register download entry for: " + title); + if (downloadId != -1) { + downloadManager.remove(downloadId); + } + } + } + + /** + * Enqueue a download if any pending + */ + private void maybeEnqueueDownload(Context context) { + DownloadEntry entry = null; + + try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE + + " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?", new String[]{String.valueOf(-1)})) { + if (cursor.moveToNext()) { + entry = getDownloadEntry(cursor); + } + } + if (entry != null) { + DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + long downloadId = enqueueDownload(downloadManager, entry.credentials, entry.url, entry.title); + ContentValues contentValues = new ContentValues(); + contentValues.put(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID, downloadId); + db.update(DOWNLOAD_DATABASE.SONG.TABLE, contentValues, DOWNLOAD_DATABASE.SONG.COLUMNS.URL + "=?", + new String[]{String.valueOf(entry.url)}); + } + } + + private long activeRequests() { + try (Cursor cursor = db.rawQuery("select count(*) from " + DOWNLOAD_DATABASE.SONG.TABLE + + " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + " <>?", new String[]{String.valueOf(-1)})) { + if (cursor.moveToNext()) { + return cursor.getLong(0); + } + } + return 0; + } + + private long enqueueDownload(DownloadManager downloadManager, String credentials, Uri url, @NonNull String title) { + String base64EncodedCredentials = Base64.encodeToString(credentials.getBytes(), Base64.NO_WRAP); + DownloadManager.Request request = new DownloadManager.Request(url) + .setTitle(title) + .setVisibleInDownloadsUi(false) + .addRequestHeader("Authorization", "Basic " + base64EncodedCredentials); + long downloadId = downloadManager.enqueue(request); + Log.i(TAG, "download enqueued[" + title + "]: " + downloadId); + return downloadId; + } + + /** + * Search for a previously registered download entry with the supplied id. + * If an entry is found it is returned, the download is unregistered and if any pending + * downloads a new one is enqueued. + * + * @param downloadId Download id + * @return The registered download entry or null if not found + */ + @Nullable + public DownloadEntry popDownloadEntry(Context context, long downloadId) { + DownloadEntry entry = null; + + try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE + + " where " + DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?", + new String[]{String.valueOf(downloadId)})) { + if (cursor.moveToNext()) { + entry = getDownloadEntry(cursor); + } + } + if (entry != null) { + db.delete(DOWNLOAD_DATABASE.SONG.TABLE, DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID + "=?", + new String[]{String.valueOf(downloadId)}); + maybeEnqueueDownload(context); + } + + return entry; + } + + public void iterateDownloadEntries(DownloadHandler callback) { + try (Cursor cursor = db.rawQuery("select * from " + DOWNLOAD_DATABASE.SONG.TABLE, null)) { + while (cursor.moveToNext()) { + callback.handle(getDownloadEntry(cursor)); + } + } + } + + private DownloadEntry getDownloadEntry(Cursor cursor) { + DownloadEntry entry = new DownloadEntry(); + entry.downloadId = cursor.getLong(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.DOWNLOAD_ID)); + entry.url = Uri.parse(cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.URL))); + entry.fileName = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.FILE_NAME)); + entry.credentials = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.CREDENTIALS)); + entry.title = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.TITLE)); + entry.album = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.ALBUM)); + entry.artist = cursor.getString(cursor.getColumnIndex(DOWNLOAD_DATABASE.SONG.COLUMNS.ARTIST)); + return entry; + } + + public void remove(Uri url) { + db.delete(DOWNLOAD_DATABASE.SONG.TABLE, DOWNLOAD_DATABASE.SONG.COLUMNS.URL + "=?", new String[]{url.toString()}); + } + + public static class DownloadEntry { + public long downloadId; + public Uri url; + public String fileName; + public String credentials; + public String title; + public String album; + public String artist; + } + + public interface DownloadHandler { + void handle(DownloadEntry entry); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java new file mode 100644 index 000000000..03c07de05 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadFilenameStructure.java @@ -0,0 +1,63 @@ +package uk.org.ngo.squeezer.download; + +import android.content.Context; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.EnumWithText; +import uk.org.ngo.squeezer.model.Song; + +public enum DownloadFilenameStructure implements EnumWithText{ + NUMBER_TITLE(R.string.download_filename_structure_number_title) { + @Override + public String get(Song song) { + return formatTrackNumber(song.trackNum) + " - " + song.title; + } + }, + ARTIST_TITLE(R.string.download_filename_structure_artist_title) { + @Override + public String get(Song song) { + return song.artist + " - " + song.title; + } + }, + ARTIST_NUMBER_TITLE(R.string.download_filename_structure_artist_number_title) { + @Override + public String get(Song song) { + return song.artist + " - " + formatTrackNumber(song.trackNum) + " - " + song.title; + } + }, + ALBUMARTIST_NUMBER_TITLE(R.string.download_filename_structure_albumartist_number_title) { + @Override + public String get(Song song) { + return song.albumArtist + " - " + formatTrackNumber(song.trackNum) + " - " + song.title; + } + }, + TITLE(R.string.download_filename_structure_title) { + @Override + public String get(Song song) { + return song.title; + } + }, + NUMBER_DOT_ARTIST_TITLE(R.string.download_filename_structure_number_dot_artist_title) { + @Override + public String get(Song song) { + return formatTrackNumber(song.trackNum) + ". " + song.artist + " - " + song.title; + } + }; + + private final int labelId; + + DownloadFilenameStructure(int labelId) { + this.labelId = labelId; + } + + @Override + public String getText(Context context) { + return context.getString(labelId); + } + + public abstract String get(Song song); + + private static String formatTrackNumber(int trackNumber) { + return String.format("%02d", trackNumber); + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java new file mode 100644 index 000000000..6c5fdac80 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadPathStructure.java @@ -0,0 +1,55 @@ +package uk.org.ngo.squeezer.download; + +import android.content.Context; + +import java.io.File; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.EnumWithText; +import uk.org.ngo.squeezer.model.Song; + +public enum DownloadPathStructure implements EnumWithText{ + ARTIST_ARTISTALBUM(R.string.download_path_structure_artist_artistalbum) { + @Override + public String get(Song song) { + return new File(song.artist, song.artist + " - " + song.album).getPath(); + } + }, + ARTIST_ALBUM(R.string.download_path_structure_artist_album) { + @Override + public String get(Song song) { + return new File(song.artist, song.album).getPath(); + } + }, + ARTISTALBUM(R.string.download_path_structure_artistalbum) { + @Override + public String get(Song song) { + return song.artist + " - " + song.album; + } + }, + ALBUM(R.string.download_path_structure_album) { + @Override + public String get(Song song) { + return song.album; + } + }, + ARTIST(R.string.download_path_structure_artist) { + @Override + public String get(Song song) { + return song.artist; + } + }; + + private final int labelId; + + public abstract String get(Song song); + + DownloadPathStructure(int labelId) { + this.labelId = labelId; + } + + @Override + public String getText(Context context) { + return context.getString(labelId); + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java new file mode 100644 index 000000000..06e300fd4 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/download/DownloadStatusReceiver.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.download; + +import android.app.DownloadManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.media.MediaScannerConnection; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.util.Log; + +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.service.SqueezeService; + + +/** + * Handle events from the download manager + *

+ * This class is registered in the manifest. + */ +public class DownloadStatusReceiver extends BroadcastReceiver { + private static final String TAG = DownloadStatusReceiver.class.getSimpleName(); + + @Override + public void onReceive(Context context, Intent intent) { + if (DownloadManager.ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) { + handleUserRequest(context); + } + if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { + handleDownloadComplete(context, intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)); + } + } + + private void handleUserRequest(Context context) { + Log.i(TAG, "Download notification clicked"); + Intent intent = new Intent(context, CancelDownloadsActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + private void handleDownloadComplete(Context context, long id) { + final DownloadDatabase downloadDatabase = new DownloadDatabase(context); + final DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); + final DownloadManager.Query query = new DownloadManager.Query().setFilterById(id); + + Log.i(TAG, "download complete: " + id); + try (Cursor cursor = downloadManager.query(query)) { + if (!cursor.moveToNext()) { + // Download complete events may still come in, even after DownloadManager.remove is + // called, so don't log this + //Logger.logError(TAG, "Download manager does not have an entry for " + id); + return; + } + + int downloadId = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_ID)); + int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)); + int reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)); + String title = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)); + String url = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI)); + Uri local_url = Uri.parse(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))); + Log.i(TAG, "download complete(" + title + "): " + id); + + final DownloadDatabase.DownloadEntry downloadEntry = downloadDatabase.popDownloadEntry(context, downloadId); + if (downloadEntry == null) { + // TODO remote logging + Log.e(TAG, "Download database does not have an entry for " + format(status, reason, title, url, local_url)); + return; + } + if (status != DownloadManager.STATUS_SUCCESSFUL) { + // TODO remote logging + Log.e(TAG, "Unsuccessful download " + format(status, reason, title, url, local_url)); + return; + } + + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + addToMediaStorage(context, downloadEntry, local_url); + } else { + addToMediaLibrary(context, downloadEntry, local_url); + } + } catch (IOException e) { + // TODO remote logging + Log.e(TAG, "IOException moving downloaded file", e); + } + } + } + + private String format(int status, int reason, String title, String url, Uri local_url) { + return "{status:" + status + ", reason:" + reason + ", title:'" + title + "', url:'" + url + "', local url:'" + local_url + "'}"; + } + + private void addToMediaStorage(Context context, DownloadDatabase.DownloadEntry downloadEntry, Uri local_url) throws IOException { + File destinationFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), downloadEntry.fileName); + File destFolder = destinationFile.getParentFile(); + + if (destFolder != null && !destFolder.exists()) { + if (!destFolder.mkdirs()) { + throw new IOException("Cant create folder for '" + destinationFile + "'"); + } + } + Util.moveFile(context.getContentResolver(), local_url, Uri.fromFile(destinationFile)); + + MediaScannerConnection.scanFile( + context.getApplicationContext(), + new String[]{destinationFile.getAbsolutePath()}, + null, + new DownloadOnScanCompletedListener(context, downloadEntry) + ); + } + + @RequiresApi(api = Build.VERSION_CODES.Q) + private void addToMediaLibrary(Context context, DownloadDatabase.DownloadEntry downloadEntry, Uri local_url) throws IOException { + ContentResolver resolver = context.getContentResolver(); + Uri audioCollection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY); + Uri uri = null; + + String[] projection = {MediaStore.Audio.AudioColumns._ID}; + String selection = MediaStore.Audio.AudioColumns.TITLE + "=? and " + MediaStore.Audio.Media.ALBUM + "=? and " + MediaStore.Audio.Media.ARTIST + "=?"; + String[] selectionArgs = new String[]{downloadEntry.title, downloadEntry.album, downloadEntry.artist}; + try (Cursor cursor = resolver.query(audioCollection, projection, selection, selectionArgs, null)) { + if (cursor != null && cursor.moveToFirst()) { + uri = ContentUris.withAppendedId(audioCollection, cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.AudioColumns._ID))); + Log.i(TAG, downloadEntry.title + " found in media library: " + uri); + } + } + if (uri == null) { + File file = new File(downloadEntry.fileName); + File parent = file.getParentFile(); + ContentValues songDetails = new ContentValues(); + songDetails.put(MediaStore.Audio.Media.DISPLAY_NAME, file.getName()); + if (parent != null) { + songDetails.put(MediaStore.Audio.Media.RELATIVE_PATH, new File(Environment.DIRECTORY_MUSIC, parent.getPath()).getPath()); + } + + // Attempt to look up mime type + String mimeType = resolver.getType(local_url); + if (mimeType == null) { + mimeType = Files.probeContentType(file.toPath()); + } + if (mimeType != null) { + songDetails.put(MediaStore.Audio.Media.MIME_TYPE, mimeType); + } + + uri = resolver.insert(audioCollection, songDetails); + Log.i(TAG, downloadEntry.title + " added to media library: " + uri); + } + if (uri == null) { + throw new IOException("Failed to insert downloaded file: " + downloadEntry.fileName); + } + + Util.moveFile(resolver, local_url, uri); + } + + private static class DownloadOnScanCompletedListener implements MediaScannerConnection.OnScanCompletedListener { + private final Context context; + private final DownloadDatabase.DownloadEntry downloadEntry; + + DownloadOnScanCompletedListener(Context context, DownloadDatabase.DownloadEntry downloadEntry) { + this.context = context; + this.downloadEntry = downloadEntry; + } + + @Override + public void onScanCompleted(String path, final Uri uri) { + Log.i(TAG, "onScanCompleted('" + path + "'): " + uri); + if (uri == null) { + // Scanning failed, probably the file format is not supported. + Log.i(TAG, "'" + path + "' could not be added to the media database"); + if (!new File(path).delete()) { + // TODO remote logging + Log.e(TAG, "Could not delete '" + path + "', which could not be added to the media database"); + } + notifyFailedMediaScan(downloadEntry.fileName); + } + } + + private void notifyFailedMediaScan(String fileName) { + String name = Util.getBaseName(fileName); + + // Content intent is required on some API levels even if + // https://developer.android.com/guide/topics/ui/notifiers/notifications.html + // says it's optional + PendingIntent emptyPendingIntent = PendingIntent.getService( + context, + 0, + new Intent(), //Dummy Intent do nothing + 0); + + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, SqueezeService.NOTIFICATION_CHANNEL_ID); + builder.setContentIntent(emptyPendingIntent); + builder.setOngoing(false); + builder.setOnlyAlertOnce(true); + builder.setAutoCancel(true); + builder.setSmallIcon(R.drawable.squeezer_notification); + builder.setTicker(name + " " + context.getString(R.string.NOTIFICATION_DOWNLOAD_MEDIA_SCANNER_ERROR)); + builder.setContentTitle(name); + builder.setContentText(context.getString(R.string.NOTIFICATION_DOWNLOAD_MEDIA_SCANNER_ERROR)); + + final NotificationManagerCompat nm = NotificationManagerCompat.from(context); + nm.notify(SqueezeService.DOWNLOAD_ERROR, builder.build()); + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java new file mode 100644 index 000000000..a00b5ccb5 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseActivity.java @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.Manifest; +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import androidx.annotation.CallSuper; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NavUtils; +import androidx.core.app.TaskStackBuilder; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import android.text.TextUtils; +import android.util.Log; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.MenuItem; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.dialog.AlertEventDialog; +import uk.org.ngo.squeezer.dialog.DownloadDialog; +import uk.org.ngo.squeezer.itemlist.HomeActivity; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.VolumePanel; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.model.DisplayMessage; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.service.event.AlertEvent; +import uk.org.ngo.squeezer.service.event.DisplayEvent; +import uk.org.ngo.squeezer.service.event.PlayerVolume; +import uk.org.ngo.squeezer.util.ImageFetcher; +import uk.org.ngo.squeezer.util.SqueezePlayer; +import uk.org.ngo.squeezer.util.ThemeManager; + +/** + * Common base class for all activities in Squeezer. + * + * @author Kurt Aaholst + */ +public abstract class BaseActivity extends AppCompatActivity { + private static final String CURRENT_DOWNLOAD_ITEM = "CURRENT_DOWNLOAD_ITEM"; + + + private static final String TAG = BaseActivity.class.getName(); + + @Nullable + private ISqueezeService mService = null; + + private final ThemeManager mTheme = new ThemeManager(); + private int mThemeId = ThemeManager.getDefaultTheme().mThemeId; + + /** Records whether the activity has registered on the service's event bus. */ + private boolean mRegisteredOnEventBus; + + private SqueezePlayer squeezePlayer; + + /** Whether volume changes should be ignored. */ + private boolean mIgnoreVolumeChange; + + /** True if bindService() completed. */ + private boolean boundService = false; + + /** Volume control panel. */ + @Nullable + private VolumePanel mVolumePanel; + + /** Set this to true to stop displaying icon-based showBrieflies */ + protected boolean ignoreIconMessages = false; + + /** + * @return The squeezeservice, or null if not bound + */ + @Nullable + public ISqueezeService getService() { + return mService; + } + + public int getThemeId() { + return mThemeId; + } + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + mService = (ISqueezeService) binder; + BaseActivity.this.onServiceConnected(mService); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + protected boolean addActionBar(){ + return true; + } + + @Override + @CallSuper + protected void onCreate(android.os.Bundle savedInstanceState) { + mTheme.onCreate(this); + super.onCreate(savedInstanceState); + + if (addActionBar()) { + // Set the icon as the home button, and display it. + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setHomeAsUpIndicator(R.drawable.ic_action_home); + actionBar.setDisplayHomeAsUpEnabled(true); + } + } + + boundService = bindService(new Intent(this, SqueezeService.class), serviceConnection, + Context.BIND_AUTO_CREATE); + Log.d(TAG, "did bindService; serviceStub = " + getService()); + + if (savedInstanceState != null) + currentDownloadItem = savedInstanceState.getParcelable(CURRENT_DOWNLOAD_ITEM); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putParcelable(CURRENT_DOWNLOAD_ITEM, currentDownloadItem); + super.onSaveInstanceState(outState); + } + + @Override + public void setTheme(int resId) { + super.setTheme(resId); + mThemeId = resId; + } + + @Override + public void onResume() { + super.onResume(); + + mTheme.onResume(this); + + if (mService != null) { + maybeRegisterOnEventBus(mService); + } + + mVolumePanel = new VolumePanel(this); + + // If SqueezePlayer is installed, start it + squeezePlayer = SqueezePlayer.maybeStartControllingSqueezePlayer(this); + + // Ensure that any image fetching tasks started by this activity do not finish prematurely. + ImageFetcher.getInstance(this).setExitTasksEarly(false); + } + + @Override + @CallSuper + public void onPause() { + // At least some Samsung devices call onPause without ensuring that onResume is called + // first, per https://code.google.com/p/android/issues/detail?id=74464, so mVolumePanel + // may be null on those devices. + if (mVolumePanel != null) { + mVolumePanel.dismiss(); + mVolumePanel = null; + } + + if (squeezePlayer != null) { + squeezePlayer.stopControllingSqueezePlayer(); + squeezePlayer = null; + } + if (mRegisteredOnEventBus) { + // If we are not bound to the service, it's process is no longer + // running, so the callbacks are already cleaned up. + if (mService != null) { + mService.getEventBus().unregister(this); + mService.cancelItemListRequests(this); + } + mRegisteredOnEventBus = false; + } + + // Ensure that any pending image fetching tasks are unpaused, and finish quickly. + ImageFetcher imageFetcher = ImageFetcher.getInstance(this); + imageFetcher.setExitTasksEarly(true); + imageFetcher.setPauseWork(false); + + super.onPause(); + } + + /** + * Clear the image memory cache if memory gets low. + */ + @Override + @CallSuper + public void onLowMemory() { + ImageFetcher.onLowMemory(); + } + + @Override + @CallSuper + public void onDestroy() { + super.onDestroy(); + if (boundService) { + unbindService(serviceConnection); + } + } + + /** Fix for https://code.google.com/p/android/issues/detail?id=63570. */ + private boolean mIsRestoredToTop; + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if ((intent.getFlags() | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) > 0) { + mIsRestoredToTop = true; + } + } + + @Override + @TargetApi(Build.VERSION_CODES.KITKAT) + public void finish() { + super.finish(); + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT && !isTaskRoot() + && mIsRestoredToTop) { + // 4.4.2 platform issues for FLAG_ACTIVITY_REORDER_TO_FRONT, + // reordered activity back press will go to home unexpectedly, + // Workaround: move reordered activity current task to front when it's finished. + ActivityManager tasksManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE); + tasksManager.moveTaskToFront(getTaskId(), ActivityManager.MOVE_TASK_NO_USER_ACTION); + } + } + + /** + * Performs any actions necessary after the service has been connected. Derived classes + * should call through to the base class. + *

    + *
  • Invalidates the options menu so that menu items can be adjusted based on + * the state of the service connection.
  • + *
  • Ensures that callbacks are registered.
  • + *
+ * + * @param service The connection to the bound service. + */ + @CallSuper + protected void onServiceConnected(@NonNull ISqueezeService service) { + Log.d(TAG, "onServiceConnected"); + supportInvalidateOptionsMenu(); + maybeRegisterOnEventBus(service); + } + + /** + * Conditionally registers with the service's EventBus. + *

+ * Registration can happen in {@link #onResume()} and {@link + * #onServiceConnected(uk.org.ngo.squeezer.service.ISqueezeService)}, this ensures that it only + * happens once. + * + * @param service The connection to the bound service. + */ + private void maybeRegisterOnEventBus(@NonNull ISqueezeService service) { + if (!mRegisteredOnEventBus) { + service.getEventBus().registerSticky(this); + mRegisteredOnEventBus = true; + } + } + + @Override + @CallSuper + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + Intent upIntent = NavUtils.getParentActivityIntent(this); + if (upIntent != null) { + if (NavUtils.shouldUpRecreateTask(this, upIntent)) { + TaskStackBuilder.create(this) + .addNextIntentWithParentStack(upIntent) + .startActivities(); + } else { + upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + NavUtils.navigateUpTo(this, upIntent); + } + } else { + HomeActivity.show(this); + } + return true; + } + + return super.onOptionsItemSelected(item); + } + + + /* + * Intercept hardware volume control keys to control Squeezeserver + * volume. + * + * Change the volume when the key is depressed. Suppress the keyUp + * event, otherwise you get a notification beep as well as the volume + * changing. + */ + @Override + @CallSuper + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_VOLUME_UP: + return changeVolumeBy(+5); + case KeyEvent.KEYCODE_VOLUME_DOWN: + return changeVolumeBy(-5); + } + + return super.onKeyDown(keyCode, event); + } + + @Override + @CallSuper + public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + return true; + } + + return super.onKeyUp(keyCode, event); + } + + private boolean changeVolumeBy(int delta) { + ISqueezeService service = getService(); + if (service == null) { + return false; + } + Log.v(TAG, "Adjust volume by: " + delta); + service.adjustVolumeBy(delta); + return true; + } + + public void onEvent(PlayerVolume event) { + if (!mIgnoreVolumeChange && mVolumePanel != null && event.player == mService.getActivePlayer()) { + mVolumePanel.postVolumeChanged(event.volume, event.player.getName()); + } + } + + // Show the volume dialog. + public boolean showVolumePanel() { + if (mService != null) { + PlayerState playerState = mService.getPlayerState(); + Player player = mService.getActivePlayer(); + + if (playerState != null && mVolumePanel != null) { + mVolumePanel.postVolumeChanged(playerState.getCurrentVolume(), + player == null ? "" : player.getName()); + } + + return true; + } else { + return false; + } + } + + public void setIgnoreVolumeChange(boolean ignoreVolumeChange) { + mIgnoreVolumeChange = ignoreVolumeChange; + } + + public void onEventMainThread(DisplayEvent displayEvent) { + boolean showMe = true; + DisplayMessage display = displayEvent.message; + View layout = getLayoutInflater().inflate(R.layout.display_message, findViewById(R.id.display_message_container)); + ImageView artwork = layout.findViewById(R.id.artwork); + artwork.setVisibility(View.GONE); + ImageView icon = layout.findViewById(R.id.icon); + icon.setVisibility(View.GONE); + TextView text = layout.findViewById(R.id.text); + text.setVisibility(TextUtils.isEmpty(display.text) ? View.GONE : View.VISIBLE); + text.setText(display.text); + + if (display.isIcon() || display.isMixed() || display.isPopupAlbum()) { + if (display.isIcon() && ignoreIconMessages) { + //icon based messages afre ignored for the now playing screen + showMe = false; + } else { + @DrawableRes int iconResource = display.getIconResource(); + if (iconResource != 0) { + icon.setVisibility(View.VISIBLE); + icon.setImageResource(iconResource); + } + if (display.hasIcon()) { + artwork.setVisibility(View.VISIBLE); + ImageFetcher.getInstance(this).loadImage(display.icon, artwork); + } + } + } else if (display.isSong()) { + //These are for the NowPlaying screen, which we update via player status messages + showMe = false; + } + + if (showMe) { + if (!(icon.getVisibility() == View.VISIBLE &&text.getVisibility() == View.VISIBLE)) { + layout.findViewById(R.id.divider).setVisibility(View.GONE); + } + int duration = (display.duration >=0 && display.duration <= 3000 ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); + Toast toast = new Toast(getApplicationContext()); + //TODO handle duration == -1 => LENGTH.INDEFINITE and custom (server side) duration, + // once we have material design and BaseTransientBottomBar + toast.setDuration(duration); + toast.setView(layout); + toast.show(); + } + } + + public void onEventMainThread(AlertEvent alert) { + AlertEventDialog.show(getSupportFragmentManager(), alert.message.title, alert.message.text); + } + + // Safe accessors + + public boolean isConnected() { + return mService != null && mService.isConnected(); + } + + /** + * Perform the supplied action using parameters in item via + * {@link ISqueezeService#action(JiveItem, Action)} + *

+ * Navigate to nextWindow if it exists in action. The + * alreadyPopped parameter is used to modify nextWindow if any windows has already + * been popped by the Android system. + */ + public void action(JiveItem item, Action action, int alreadyPopped) { + if (mService == null) { + return; + } + + mService.action(item, action); + } + + /** + * Same as calling {@link #action(JiveItem, Action, int)} with alreadyPopped = 0 + */ + public void action(JiveItem item, Action action) { + action(item, action, 0); + } + + /** + * Perform the supplied action using parameters in item via + * {@link ISqueezeService#action(Action.JsonAction)} + */ + public void action(Action.JsonAction action, int alreadyPopped) { + if (mService == null) { + return; + } + + mService.action(action); + } + + /** + * Initiate download of songs for the supplied item. + * + * @param item Song or item with songs to download + * @see ISqueezeService#downloadItem(JiveItem) + */ + public void downloadItem(JiveItem item) { + if (new Preferences(this).isDownloadConfirmation()) { + DownloadDialog.show(getSupportFragmentManager(), item, new DownloadDialog.DownloadDialogListener() { + @Override + public void download(boolean persist) { + if (persist) { + new Preferences(BaseActivity.this).setDownloadConfirmation(false); + } + doDownload(item); + } + + @Override + public void cancel(boolean persist) { + if (persist) { + new Preferences(BaseActivity.this).setDownloadEnabled(false); + } + + } + }); + } else { + doDownload(item); + } + } + + private void doDownload(JiveItem item) { + if (Build.VERSION_CODES.M <= Build.VERSION.SDK_INT && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + currentDownloadItem = item; + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1); + } else + mService.downloadItem(item); + } + + private JiveItem currentDownloadItem; + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case 1: + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (currentDownloadItem != null) { + mService.downloadItem(currentDownloadItem); + currentDownloadItem = null; + } else + Toast.makeText(this, "Please select download again now that we have permission to save it", Toast.LENGTH_LONG).show(); + } else + Toast.makeText(this, R.string.DOWNLOAD_REQUIRES_WRITE_PERMISSION, Toast.LENGTH_LONG).show(); + break; + } + } + + /** + * Look up an attribute resource styled for the current theme. + * + * @param attribute Attribute identifier to look up. + * @return The resource identifier for the given attribute. + */ + public int getAttributeValue(int attribute) { + TypedValue v = new TypedValue(); + getTheme().resolveAttribute(attribute, v, true); + return v.resourceId; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java new file mode 100644 index 000000000..cfb2edd2f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseItemView.java @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.os.Parcelable.Creator; +import androidx.annotation.IntDef; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.RadioButton; +import android.widget.TextView; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Field; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.util.Reflection; +import uk.org.ngo.squeezer.widget.SquareImageView; + +/** + * Represents the view hierarchy for a single {@link Item} subclass, suitable for displaying in a + * {@link ItemListActivity}. + *

+ * This class supports views that have a {@link TextView} to display the primary information about + * the {@link Item} and can optionally enable additional views. The layout is defined in {@code + * res/layout/list_item.xml}.

  • A {@link SquareImageView} suitable for displaying icons
  • + *
  • A second, smaller {@link TextView} for additional item information
  • A {@link + * Button} that shows a disclosure triangle for a context menu
The view can + * display an item in one of two states. The primary state is when the data to be inserted in to + * the view is known, and represented by a complete {@link Item} subclass. The loading state is when + * the data type is known, but has not been fetched from the server yet. + *

+ * To customise the view's display create an int of {@link ViewParam} and pass it to + * {@link #setViewParams(int)} or {@link #setLoadingViewParams(int)} depending on whether + * you want to change the layout of the view in its primary state or the loading state. For example, + * if the primary state should show a context button you may not want to show that button while + * waiting for data to arrive. + *

+ * Override {@link #bindView(View, Item)} and {@link #bindView(View, String)} to + * control how data from the item is inserted in to the view. + *

+ * If you need a completely custom view hierarchy then override {@link #getAdapterView(View, + * ViewGroup, int, T, boolean)} and {@link #getAdapterView(View, ViewGroup, int, String)}. + * + * @param the Item subclass this view represents. + */ +public abstract class BaseItemView implements ItemView { + + private final ItemListActivity mActivity; + + private final LayoutInflater mLayoutInflater; + + private Class mItemClass; + + private Creator mCreator; + + @IntDef(flag=true, value={ + VIEW_PARAM_ICON, VIEW_PARAM_TWO_LINE, VIEW_PARAM_CONTEXT_BUTTON + }) + @Retention(RetentionPolicy.SOURCE) + /* Parameters that control which additional views will be enabled in the item view. */ + public @interface ViewParam {} + /** Adds a {@link SquareImageView} for displaying artwork or other iconography. */ + public static final int VIEW_PARAM_ICON = 1; + /** Adds a second line for detail information ({@code R.id.text2}). */ + public static final int VIEW_PARAM_TWO_LINE = 1 << 1; + /** Adds a button, with click handler, to display the context menu. */ + public static final int VIEW_PARAM_CONTEXT_BUTTON = 1 << 2; + + /** + * View parameters for a filled-in view. One primary line with context button. + */ + @ViewParam private int mViewParams = VIEW_PARAM_CONTEXT_BUTTON; + + /** + * View parameters for a view that is loading data. Primary line only. + */ + @ViewParam private int mLoadingViewParams = 0; + + /** + * A ViewHolder for the views that make up a complete list item. + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + public int position; + + public ImageView icon; + + public TextView text1; + + public TextView text2; + + public View contextMenuButtonHolder; + public Button contextMenuButton; + public ProgressBar contextMenuLoading; + public CheckBox contextMenuCheckbox; + public RadioButton contextMenuRadio; + + public @ViewParam int viewParams; + + public ViewHolder(@NonNull View view) { + super(view); + text1 = view.findViewById(R.id.text1); + text2 = view.findViewById(R.id.text2); + icon = view.findViewById(R.id.icon); + setContextMenu(view); + } + + private void setContextMenu(View view) { + contextMenuButtonHolder = view.findViewById(R.id.context_menu); + if (contextMenuButtonHolder!= null) { + contextMenuButton = contextMenuButtonHolder.findViewById(R.id.context_menu_button); + contextMenuLoading = contextMenuButtonHolder.findViewById(R.id.loading_progress); + contextMenuCheckbox = contextMenuButtonHolder.findViewById(R.id.checkbox); + contextMenuRadio = contextMenuButtonHolder.findViewById(R.id.radio); + } + } + } + + public BaseItemView(ItemListActivity activity) { + mActivity = activity; + mLayoutInflater = activity.getLayoutInflater(); + } + + @Override + public ItemListActivity getActivity() { + return mActivity; + } + + public LayoutInflater getLayoutInflater() { + return mLayoutInflater; + } + + /** + * Set the view parameters to use for the view when data is loaded. + */ + protected void setViewParams(@ViewParam int viewParams) { + mViewParams = viewParams; + } + + /** + * Set the view parameters to use for the view while data is being loaded. + */ + protected void setLoadingViewParams(@ViewParam int viewParams) { + mLoadingViewParams = viewParams; + } + + @Override + @SuppressWarnings("unchecked") + public Class getItemClass() { + if (mItemClass == null) { + mItemClass = (Class) Reflection.getGenericClass(getClass(), ItemView.class, + 0); + if (mItemClass == null) { + throw new RuntimeException("Could not read generic argument for: " + getClass()); + } + } + return mItemClass; + } + + @Override + @SuppressWarnings("unchecked") + public Creator getCreator() { + if (mCreator == null) { + Field field; + try { + field = getItemClass().getField("CREATOR"); + } catch (Exception e) { + throw new RuntimeException(e); + } + try { + mCreator = (Creator) field.get(null); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return mCreator; + } + + /** + * Returns a view suitable for displaying the data of item in a list. Item may not be null. + *

+ * Override this method and {@link #getAdapterView(View, ViewGroup, int, String)} if your subclass + * uses a different layout. + */ + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, T item, boolean selected) { + View view = getAdapterView(convertView, parent, position, mViewParams); + bindView(view, item); + return view; + } + + /** + * Binds the item's name to {@link ViewHolder#text1}. + *

+ * Override this instead of {@link #getAdapterView(View, ViewGroup, int, Item, boolean)} if the + * default layouts are sufficient. + * + * @param view The view that contains the {@link ViewHolder} + * @param item The item to be bound + */ + public void bindView(final View view, final T item) { + final ViewHolder viewHolder = (ViewHolder) view.getTag(); + + viewHolder.text1.setText(item.getName()); + + if (viewHolder.contextMenuButton!= null) { + viewHolder.contextMenuButton.setOnClickListener(v -> showContextMenu(viewHolder, item)); + } + } + + /** + * Returns a view suitable for displaying the "Loading..." text. + *

+ * Override this method and {@link #getAdapterView(View, ViewGroup, int, Item, boolean)} if your + * extension uses a different layout. + */ + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, String text) { + View view = getAdapterView(convertView, parent, position, mLoadingViewParams); + bindView(view, text); + return view; + } + + /** + * Binds the text to {@link ViewHolder#text1}. + *

+ * Override this instead of {@link #getAdapterView(View, ViewGroup, int, String)} if the default + * layout is sufficient. + * + * @param view The view that contains the {@link ViewHolder} + * @param text The text to set in the view. + */ + public void bindView(View view, String text) { + ViewHolder viewHolder = (ViewHolder) view.getTag(); + + viewHolder.text1.setText(text); + } + + /** + * Creates a view from {@code convertView} and the {@code viewParams} using the default layout + * {@link R.layout#list_item} + * + * @param convertView View to reuse if possible. + * @param parent The {@link ViewGroup} to inherit properties from. + * @param viewParams A set of 0 or more {@link ViewParam} to customise the view. + * + * @return convertView if it can be reused, or a new view + */ + public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) { + return getAdapterView(convertView, parent, viewParams, position, R.layout.list_item); + } + + /** + * Creates a view from {@code convertView} and the {@code viewParams}. + * + * @param convertView View to reuse if possible. + * @param parent The {@link ViewGroup} to inherit properties from. + * @param viewParams A set of 0 or more {@link ViewParam} to customise the view. + * @param layoutResource The layout resource defining the item view + * + * @return convertView if it can be reused, or a new view + */ + protected View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams, @LayoutRes int layoutResource) { + ViewHolder viewHolder = + (convertView != null && convertView.getTag() instanceof ViewHolder) + ? (ViewHolder) convertView.getTag() + : null; + + if (viewHolder == null) { + convertView = getLayoutInflater().inflate(layoutResource, parent, false); + viewHolder = createViewHolder(convertView); + setViewParams(viewParams, viewHolder); + convertView.setTag(viewHolder); + } + + viewHolder.position = position; + + // If the view parameters are different then reset the visibility of child views and hook + // up any standard behaviours. + if (viewParams != viewHolder.viewParams) { + setViewParams(viewParams, viewHolder); + } + + return convertView; + } + + private void setViewParams(@ViewParam int viewParams, ViewHolder viewHolder) { + viewHolder.icon.setVisibility( + (viewParams & VIEW_PARAM_ICON) != 0 ? View.VISIBLE : View.GONE); + viewHolder.text2.setVisibility( + (viewParams & VIEW_PARAM_TWO_LINE) != 0 ? View.VISIBLE : View.GONE); + + if (viewHolder.contextMenuButtonHolder != null) { + viewHolder.contextMenuButtonHolder.setVisibility( + (viewParams & VIEW_PARAM_CONTEXT_BUTTON) != 0 ? View.VISIBLE : View.GONE); + } + viewHolder.viewParams = viewParams; + } + + public ViewHolder createViewHolder(View itemView) { + return new ViewHolder(itemView); + } + + @Override + public boolean isSelectable(T item) { + return (item.getId() != null); + } + + @Override + public boolean onItemSelected(View view, int index, T item) { + return false; + } + + @Override + public void onGroupSelected(View view, T[] items) { + + } + + @Override + public boolean isSelected(T item) { + return false; + } + + @Override + public void showContextMenu(ViewHolder v, T item) { + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java new file mode 100644 index 000000000..f59b4e7ab --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/BaseListActivity.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + + +import android.os.Bundle; +import androidx.annotation.MainThread; +import android.widget.AbsListView; +import android.widget.ImageView; +import android.widget.ListView; + +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.util.ImageFetcher; + + +/** + * A generic base class for an activity to list items of a particular SqueezeServer data type. The + * data type is defined by the generic type argument, and must be an extension of {@link Item}. You + * must provide an {@link ItemView} to provide the view logic used by this activity. This is done by + * implementing {@link #createItemView()}. + *

+ * When the activity is first created ({@link #onCreate(Bundle)}), an empty {@link ItemAdapter} + * is created using the provided {@link ItemView}. See {@link ItemListActivity} for see details of + * ordering and receiving of list items from SqueezeServer, and handling of item selection. + * + * @param Denotes the class of the items this class should list + * + * @author Kurt Aaholst + */ +public abstract class BaseListActivity extends ItemListActivity implements IServiceItemListCallback { + + /** + * Tag for first visible position in mRetainFragment. + */ + private static final String TAG_POSITION = "position"; + + /** + * Tag for itemAdapter in mRetainFragment. + */ + public static final String TAG_ADAPTER = "adapter"; + + private ItemAdapter itemAdapter; + + /** + * Can't do much here, as content is based on settings, and which data to display, which is controlled by data + * returned from server. + *

+ * See {@link #setupListView(AbsListView)} and {@link #onItemsReceived(int, int, Map, List, Class)} for the actual setup of + * views and adapter + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(getContentView()); + } + + @Override + protected AbsListView setupListView(AbsListView listView) { + listView.setOnItemClickListener((parent, view, position, id) -> getItemAdapter().onItemSelected(view, position)); + + listView.setOnScrollListener(new ScrollListener()); + + listView.setRecyclerListener(view -> { + // Release strong reference when a view is recycled + final ImageView imageView = view.findViewById(R.id.icon); + if (imageView != null) { + imageView.setImageBitmap(null); + } + }); + + setupAdapter(listView); + + return listView; + } + + @MainThread + public void onEventMainThread(HandshakeComplete event) { + super.onEventMainThread(event); + if (!needPlayer() || getService().getActivePlayer() != null) { + maybeOrderVisiblePages(getListView()); + } else { + showEmptyView(); + } + } + + /** + * Returns the ID of a content view to be used by this list activity. + *

+ * The content view must contain a {@link AbsListView} with the id {@literal item_list} in order + * to be valid. + * + * @return The ID + */ + protected int getContentView() { + return R.layout.slim_browser_layout; + } + + /** + * @return A new view logic to be used by this activity + */ + abstract protected ItemView createItemView(); + + /** + * Set our adapter on the list view. + *

+ * This can't be done in {@link #onCreate(android.os.Bundle)} because getView might be called + * before the handshake is complete, so we need to delay it. + *

+ * However when we set the adapter after onCreate the list is scrolled to top, so we retain the + * visible position. + *

+ * Call this method after the handshake is complete. + */ + private void setupAdapter(AbsListView listView) { + listView.setAdapter(getItemAdapter()); + + Integer position = (Integer) getRetainedValue(TAG_POSITION); + if (position != null) { + if (listView instanceof ListView) { + ((ListView) listView).setSelectionFromTop(position, 0); + } else { + listView.setSelection(position); + } + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + saveVisiblePosition(); + } + + /** + * Store the first visible position of {@link #getListView()}, in the retain fragment, so + * we can later retrieve it. + * + * @see android.widget.AbsListView#getFirstVisiblePosition() + */ + private void saveVisiblePosition() { + putRetainedValue(TAG_POSITION, getListView().getFirstVisiblePosition()); + } + + /** + * @return The current {@link ItemAdapter}'s {@link ItemView} + */ + public ItemView getItemView() { + return getItemAdapter().getItemView(); + } + + /** + * @return The current {@link ItemAdapter}, creating it if necessary. + */ + public ItemAdapter getItemAdapter() { + if (itemAdapter == null) { + //noinspection unchecked + itemAdapter = (ItemAdapter) getRetainedValue(TAG_ADAPTER); + if (itemAdapter == null) { + itemAdapter = createItemListAdapter(createItemView()); + putRetainedValue(TAG_ADAPTER, itemAdapter); + } else { + // We have just retained the item adapter, we need to create a new + // item view logic, cause it holds a reference to the old activity + itemAdapter.setItemView(createItemView()); + // Update views with the count from the retained item adapter + itemAdapter.onCountUpdated(); + } + } + + return itemAdapter; + } + + @Override + protected void clearItemAdapter() { + getItemAdapter().clear(); + } + + protected ItemAdapter createItemListAdapter(ItemView itemView) { + return new ItemAdapter<>(itemView); + } + + @Override + @SuppressWarnings("unchecked") + protected void updateAdapter(int count, int start, List items, Class dataType) { + getItemAdapter().update(count, start, (List) items); + } + + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + super.onItemsReceived(count, start, items, dataType); + } + + @Override + public Object getClient() { + return this; + } + + protected class ScrollListener extends ItemListActivity.ScrollListener { + + ScrollListener() { + super(); + } + + /** + * Pauses cache disk fetches if the user is flinging the list, or if their finger is still + * on the screen. + */ + @Override + public void onScrollStateChanged(AbsListView listView, int scrollState) { + super.onScrollStateChanged(listView, scrollState); + + if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_FLING || + scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { + ImageFetcher.getInstance(BaseListActivity.this).setPauseWork(true); + } else { + ImageFetcher.getInstance(BaseListActivity.this).setPauseWork(false); + } + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java new file mode 100644 index 000000000..67f37709c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumIdLookup.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2015 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.util.SparseArray; + +/** + * Reverse mapping of EnumWithId. + *

+ * Enables lookup of enum value via it's id. + */ +public class EnumIdLookup & EnumWithId> { + + private final SparseArray map = new SparseArray<>(); + + public EnumIdLookup(Class enumType) { + for (E v : enumType.getEnumConstants()) { + map.put(v.getId(), v); + } + } + + public E get(int num) { + return map.get(num); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java new file mode 100644 index 000000000..8ec797abb --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithId.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2015 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +/** + * Helper interface to associate an id with an enum value + */ +public interface EnumWithId { + int getId(); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java new file mode 100644 index 000000000..c14c9e415 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/EnumWithText.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2015 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.content.Context; + +/** + * Helper interface to associate a text suitable for display in the UI with an enum value. + */ +public interface EnumWithText { + String getText(Context context); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java new file mode 100644 index 000000000..780f653d5 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemAdapter.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Item; + + +/** + * A generic class for an adapter to list items of a particular SqueezeServer data type. The data + * type is defined by the generic type argument, and must be an extension of {@link Item}. + *

+ * Normally there is no need to extend this, as we delegate all type dependent stuff to + * {@link ItemView}. + * + * @param Denotes the class of the items this class should list + * + * @author Kurt Aaholst + * @see ItemView + */ +public class ItemAdapter extends BaseAdapter { + + /** + * View logic for this adapter + */ + private ItemView mItemView; + + /** + * List of items, possibly headed with an empty item. + *

+ * As the items are received from SqueezeServer they will be inserted in the list. + */ + private int count; + + private final SparseArray pages = new SparseArray<>(); + + /** + * This is set if the list shall start with an empty item. + */ + private final boolean mEmptyItem; + + /** + * Text to display before the items are received from SqueezeServer + */ + private final String loadingText; + + /** + * Number of elements to by fetched at a time + */ + private final int pageSize; + + /** + * Index of the latest selected item see {@link #onItemSelected(View, int)} + */ + private int selectedIndex; + + /** + * Creates a new adapter. Initially the item list is populated with items displaying the + * localized "loading" text. Call {@link #update(int, int, List)} as items arrives from + * SqueezeServer. + * + * @param itemView The {@link ItemView} to use with this adapter + * @param emptyItem If set the list of items shall start with an empty item + */ + public ItemAdapter(ItemView itemView, boolean emptyItem) { + mItemView = itemView; + mEmptyItem = emptyItem; + loadingText = itemView.getActivity().getString(R.string.loading_text); + pageSize = itemView.getActivity().getResources().getInteger(R.integer.PageSize); + pages.clear(); + } + + /** + * Calls {@link #(ItemView, boolean)}, with emptyItem = false + */ + public ItemAdapter(ItemView itemView) { + this(itemView, false); + } + + private int pageNumber(int position) { + return position / pageSize; + } + + /** + * Removes all items from this adapter leaving it empty. + */ + public void clear() { + count = (mEmptyItem ? 1 : 0); + pages.clear(); + notifyDataSetChanged(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + T item = getItem(position); + if (item != null) { + return mItemView.getAdapterView(convertView, parent, position, item, position == selectedIndex); + } + + return mItemView.getAdapterView(convertView, parent, position, (position == 0 && mEmptyItem ? "" : loadingText)); + } + + public ItemListActivity getActivity() { + return mItemView.getActivity(); + } + + public int getSelectedIndex() { + return selectedIndex; + } + + public void setSelectedIndex(int index) { + selectedIndex = index; + } + + public void onItemSelected(View view, int position) { + T item = getItem(position); + if (item != null) { + selectedIndex = position; + if (mItemView.onItemSelected(view, position, item)) { + notifyDataSetChanged(); + } + } + } + public void onSelected(View view) { + mItemView.onGroupSelected(view, getPage(0)); + } + public ItemView getItemView() { + return mItemView; + } + + public void setItemView(ItemView itemView) { + mItemView = itemView; + } + + @Override + public int getCount() { + return count; + } + + private T[] getPage(int position) { + int pageNumber = pageNumber(position); + T[] page = pages.get(pageNumber); + if (page == null) { + pages.put(pageNumber, page = arrayInstance(pageSize)); + } + return page; + } + + private void setItems(int start, List items) { + T[] page = getPage(start); + int offset = start % pageSize; + for (T item : items) { + if (mItemView.isSelected(item)) { + selectedIndex = start + offset; + } + if (offset >= pageSize) { + start += offset; + page = getPage(start); + offset = 0; + } + page[offset++] = item; + } + } + + @Override + public T getItem(int position) { + T item = getPage(position)[position % pageSize]; + if (item == null) { + if (mEmptyItem) { + position--; + } + getActivity().maybeOrderPage(pageNumber(position) * pageSize); + } + return item; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public boolean isEnabled(int position) { + T item = getItem(position); + return item != null && mItemView.isSelectable(item); + } + + /** + * Called when the number of items in the list changes. The default implementation is empty. + */ + protected void onCountUpdated() { + } + + /** + * Update the contents of the items in this list. + *

+ * The size of the list of items is automatically adjusted if necessary, to obey the given + * parameters. + * + * @param count Number of items as reported by SqueezeServer. + * @param start The start position of items in this update. + * @param items New items to insert in the main list + */ + public void update(int count, int start, List items) { + int offset = (mEmptyItem ? 1 : 0); + count += offset; + start += offset; + if (count == 0 || count != getCount()) { + this.count = count; + onCountUpdated(); + } + setItems(start, items); + + notifyDataSetChanged(); + } + + /** + * @return The position of the given item in this adapter or 0 if not found + */ + public int findItem(T item) { + for (int pos = 0; pos < getCount(); pos++) { + if (getItem(pos) == null) { + if (item == null) { + return pos; + } + } else if (getItem(pos).equals(item)) { + return pos; + } + } + return 0; + } + + /** + * Move the item at the specified position to the new position and notify the change. + */ + public void moveItem(int fromPosition, int toPosition) { + T item = getItem(fromPosition); + remove(fromPosition); + insert(toPosition, item); + notifyDataSetChanged(); + } + + /** + * Remove the item at the specified position, update the count and notify the change. + */ + public void removeItem(int position) { + remove(position); + count--; + onCountUpdated(); + notifyDataSetChanged(); + } + + /** + * Insert an item at the specified position, update the count and notify the change. + */ + public void insertItem(int position, T item) { + insert(position, item); + count++; + onCountUpdated(); + notifyDataSetChanged(); + } + + private void remove(int position) { + T[] page = getPage(position); + int offset = position % pageSize; + while (position++ <= count) { + if (offset == pageSize - 1) { + T[] nextPage = getPage(position); + page[offset] = nextPage[0]; + offset = 0; + page = nextPage; + } else { + page[offset] = page[offset+1]; + offset++; + } + } + + } + + private void insert(int position, T item) { + int n = count; + T[] page = getPage(n); + int offset = n % pageSize; + while (n-- > position) { + if (offset == 0) { + T[] nextPage = getPage(n); + offset = pageSize - 1; + page[0] = nextPage[offset]; + page = nextPage; + } else { + page[offset] = page[offset-1]; + offset--; + } + } + page[offset] = item; + } + + private T[] arrayInstance(int size) { + return mItemView.getCreator().newArray(size); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java new file mode 100644 index 000000000..a142f4c59 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemListActivity.java @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + + +import android.os.Bundle; +import androidx.annotation.MainThread; +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import android.util.Log; +import android.view.View; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Stack; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.service.event.ActivePlayerChanged; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.util.RetainFragment; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * This class defines the common minimum, which any activity browsing the SqueezeServer's database + * must implement. + * + * @author Kurt Aaholst + */ +public abstract class ItemListActivity extends BaseActivity { + + private static final String TAG = ItemListActivity.class.getName(); + + /** + * The list is being actively scrolled by the user + */ + private boolean mListScrolling; + + /** + * The number of items per page. + */ + protected int mPageSize; + + /** + * The pages that have been requested from the server. + */ + private final Set mOrderedPages = new HashSet<>(); + + /** + * The pages that have been received from the server + */ + private Set mReceivedPages; + + /** + * Pages requested before the handshake completes. A stack on the assumption + * that once the service is bound the most recently requested pages should be ordered + * first. + */ + private final Stack mOrderedPagesBeforeHandshake = new Stack<>(); + + /** + * Progress bar (spinning) while items are loading. + */ + private View loadingProgress; + + /** + * View to show when no players are connected + */ + private View emptyView; + + /** + * Layout hosting the sub activity content + */ + private FrameLayout subActivityContent; + + /** + * List view to show the received items + */ + private AbsListView listView; + + /** + * Tag for mReceivedPages in mRetainFragment. + */ + private static final String TAG_RECEIVED_PAGES = "mReceivedPages"; + + /** + * Fragment to retain information across the activity lifecycle. + */ + private RetainFragment mRetainFragment; + + @Override + public void setContentView(int layoutResID) { + LinearLayout fullLayout = (LinearLayout) getLayoutInflater().inflate(R.layout.item_list_activity_layout, findViewById(R.id.activity_layout)); + subActivityContent = fullLayout.findViewById(R.id.content_frame); + getLayoutInflater().inflate(layoutResID, subActivityContent, true); // Places the activity layout inside the activity content frame. + super.setContentView(fullLayout); + + loadingProgress = checkNotNull(findViewById(R.id.loading_label), + "activity layout did not return a view containing R.id.loading_label"); + + emptyView = checkNotNull(findViewById(R.id.empty_view), + "activity layout did not return a view containing R.id.empty_view"); + + AbsListView listView = checkNotNull(subActivityContent.findViewById(R.id.item_list), + "getContentView() did not return a view containing R.id.item_list"); + setListView(setupListView(listView)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mPageSize = getResources().getInteger(R.integer.PageSize); + + mRetainFragment = RetainFragment.getInstance(TAG, getSupportFragmentManager()); + + //noinspection unchecked + mReceivedPages = (Set) getRetainedValue(TAG_RECEIVED_PAGES); + if (mReceivedPages == null) { + mReceivedPages = new HashSet<>(); + putRetainedValue(TAG_RECEIVED_PAGES, mReceivedPages); + } + } + + protected Object getRetainedValue(String key) { + return mRetainFragment.get(key); + } + + protected Object putRetainedValue(String key, Object value) { + return mRetainFragment.put(key, value); + } + + @Override + public void onPause() { + super.onPause(); + + // Any items coming in after callbacks have been unregistered are discarded. + // We cancel any outstanding orders, so items can be reordered after the + // activity resumes. + cancelOrders(); + } + + private void showLoading() { + subActivityContent.setVisibility(View.GONE); + loadingProgress.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + } + + void showEmptyView() { + subActivityContent.setVisibility(View.GONE); + loadingProgress.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + } + + protected void showContent() { + subActivityContent.setVisibility(View.VISIBLE); + loadingProgress.setVisibility(View.GONE); + emptyView.setVisibility(View.GONE); + } + + /** + * @return True if the LMS command issued by {@link #orderPage(ISqueezeService, int)} requires a player + */ + protected abstract boolean needPlayer(); + + /** + * Starts an asynchronous fetch of items from the server. Will only be called after the + * service connection has been bound. + * + * @param service The connection to the bound service. + * @param start Position in list to start the fetch. Pass this on to {@link + * uk.org.ngo.squeezer.service.SqueezeService} + */ + protected abstract void orderPage(@NonNull ISqueezeService service, int start); + + public ArtworkListLayout getPreferredListLayout() { + return new Preferences(this).getAlbumListLayout(); + } + + /** + * Set the list view to host received items + */ + protected abstract AbsListView setupListView(AbsListView listView); + + /** + * @return The view listing the items for this acitvity + */ + public final AbsListView getListView() { + return listView; + } + + public void setListView(AbsListView listView) { + this.listView = listView; + } + + /** + * List can clear any information about which items have been received and ordered, by calling + * {@link #clearAndReOrderItems()}. This will call back to this method, which must clear any + * adapters holding items. + */ + protected abstract void clearItemAdapter(); + + /** + * Call back from {@link #onItemsReceived(int, int, List, Class)} + */ + protected abstract void updateAdapter(int count, int start, List items, Class dataType); + + /** + * Orders a page worth of data, starting at the specified position, if it has not already been + * ordered, and if the service is connected and the handshake has completed. + * + * @param pagePosition position in the list to start the fetch. + * @return True if the page needed to be ordered (even if the order failed), false otherwise. + */ + public boolean maybeOrderPage(int pagePosition) { + if (!mListScrolling && !mReceivedPages.contains(pagePosition) && !mOrderedPages + .contains(pagePosition) && !mOrderedPagesBeforeHandshake.contains(pagePosition)) { + ISqueezeService service = getService(); + + // If the service connection hasn't happened yet then store the page + // request where it can be used in mHandshakeComplete. + if (service == null) { + mOrderedPagesBeforeHandshake.push(pagePosition); + } else { + try { + orderPage(service, pagePosition); + mOrderedPages.add(pagePosition); + } catch (SqueezeService.HandshakeNotCompleteException e) { + mOrderedPagesBeforeHandshake.push(pagePosition); + } + } + return true; + } else { + return false; + } + } + + /** + * Update the UI with the player change + */ + @MainThread + public void onEventMainThread(ActivePlayerChanged event) { + Log.i(TAG, "ActivePlayerChanged: " + event.player); + supportInvalidateOptionsMenu(); + if (needPlayer()) { + if (event.player == null) { + showEmptyView(); + } else { + clearAndReOrderItems(); + } + } + } + + /** + * Orders any pages requested before the handshake completed. + */ + @MainThread + public void onEventMainThread(HandshakeComplete event) { + // Order any pages that were requested before the handshake complete. + while (!mOrderedPagesBeforeHandshake.empty()) { + maybeOrderPage(mOrderedPagesBeforeHandshake.pop()); + } + } + + /** + * Orders pages that correspond to visible rows in the listview. + *

+ * Computes the pages that correspond to the rows that are currently being displayed by the + * listview, and calls {@link #maybeOrderPage(int)} to fetch the page if necessary. + * + * @param listView The listview with visible rows. + */ + public void maybeOrderVisiblePages(AbsListView listView) { + int pos = (listView.getFirstVisiblePosition() / mPageSize) * mPageSize; + int end = listView.getFirstVisiblePosition() + listView.getChildCount(); + + while (pos <= end) { + maybeOrderPage(pos); + pos += mPageSize; + } + } + + /** + * Tracks items that have been received from the server. + *

+ * Subclasses must call this method when receiving data from the server to ensure that + * internal bookkeeping about pages that have/have not been ordered is kept consistent. + *

+ * This will call back to {@link #updateAdapter(int, int, List, Class)} on the UI thread + * + * @param count The total number of items known by the server. + * @param start The start position of this update. + * @param items The items received in this update + */ + @CallSuper + protected void onItemsReceived(final int count, final int start, final List items, final Class dataType) { + int size = items.size(); + Log.d(TAG, "onItemsReceived(" + count + ", " + start + ", " + size + ")"); + + // If this doesn't add any items, then don't register the page as received + if (start < count && size != 0) { + // Because we might receive a page in chunks, we test if this is the end of a page + // before we register the page as received. + if (((start + size) % mPageSize == 0) || (start + size == count)) { + // Add this page of data to mReceivedPages and remove from mOrderedPages. + int pageStart = (start / mPageSize) * mPageSize; + mReceivedPages.add(pageStart); + mOrderedPages.remove(pageStart); + } + } + + runOnUiThread(() -> { + showContent(); + updateAdapter(count, start, items, dataType); + }); + } + + /** + * Empties the variables that track which pages have been requested, and orders page 0. + */ + public void clearAndReOrderItems() { + if (!(needPlayer() && getService().getActivePlayer() == null)) { + showLoading(); + clearItems(); + maybeOrderPage(0); + } + } + + /** Empty the variables that track which pages have been requested. */ + public void clearItems() { + mOrderedPagesBeforeHandshake.clear(); + mOrderedPages.clear(); + mReceivedPages.clear(); + clearItemAdapter(); + } + + /** + * Removes any outstanding requests from mOrderedPages. + */ + private void cancelOrders() { + mOrderedPages.clear(); + } + + /** + * Tracks scrolling activity. + *

+ * When the list is idle, new pages of data are fetched from the server. + */ + protected class ScrollListener implements AbsListView.OnScrollListener { + private int mPrevScrollState = OnScrollListener.SCROLL_STATE_IDLE; + + public ScrollListener() { + } + + @Override + public void onScrollStateChanged(AbsListView listView, int scrollState) { + if (scrollState == mPrevScrollState) { + return; + } + + switch (scrollState) { + case OnScrollListener.SCROLL_STATE_IDLE: + mListScrolling = false; + maybeOrderVisiblePages(listView); + break; + + case OnScrollListener.SCROLL_STATE_FLING: + case OnScrollListener.SCROLL_STATE_TOUCH_SCROLL: + mListScrolling = true; + break; + } + + mPrevScrollState = scrollState; + } + + // Do not use: is not called when the scroll completes, appears to be + // called multiple time during a scroll, including during flinging. + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, + int totalItemCount) { + } + + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java new file mode 100644 index 000000000..cef31d40e --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/framework/ItemView.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.framework; + +import android.os.Parcelable.Creator; +import android.view.View; +import android.view.ViewGroup; + +import uk.org.ngo.squeezer.model.Item; + + +/** + * Defines view logic for a {@link Item} + *

+ * We keep this here because we don't want to pollute the model with view related stuff. + *

+ * Currently this is the only logic class you have to implement for each SqueezeServer data type, so + * it contains a few methods, which are not strictly view related. + *

+ * {@link BaseItemView} implements all the common functionality, an some sensible defaults. + * + * @param Denotes the class of the item this class implements view logic for + * + * @author Kurt Aaholst + */ +public interface ItemView { + + /** + * @return The activity associated with this view logic + */ + ItemListActivity getActivity(); + + /** + * Gets a {@link android.view.View} that displays the data at the specified position in the data + * set. See {@link ItemAdapter#getView(int, View, android.view.ViewGroup)} + * + * @param convertView the old view to reuse, per {@link android.widget.Adapter#getView(int, View, + * android.view.ViewGroup)} + * @param position Position of item in adapter + * @param item the item to display. + * + * @return the view to display. + */ + View getAdapterView(View convertView, ViewGroup parent, int position, T item, boolean selected); + + /** + * Gets a {@link android.view.View} suitable for displaying the supplied (static) text. See + * {@link ItemAdapter#getView(int, View, android.view.ViewGroup)} + * + * @param convertView The old view to reuse, per {@link android.widget.Adapter#getView(int, + * View, android.view.ViewGroup)} + * @param text text to display + * + * @return the view to display. + */ + View getAdapterView(View convertView, ViewGroup parent, int position, String text); + + /** + * @return The generic argument of the implementation + */ + Class getItemClass(); + + /** + * @return the creator for the current {@link Item} implementation + */ + Creator getCreator(); + + /** + * Return whether the supplied item shall be selectable in a list + * + * @param item Item to check + * @return True if the item is selectable + * @see android.widget.ListAdapter#isEnabled(int) + */ + boolean isSelectable(T item); + + /** + * Return whether the supplied item is currently selected + * @param item Item to check + * @return True if the item is selected + */ + boolean isSelected(T item); + + /** + * Implement the action to be taken when an item is selected. + * + * @param view The view currently showing the item. + * @param index Position in the list of the selected item. + * @param item The selected item. This may be null if + * @return True if {@link android.widget.BaseAdapter#notifyDataSetChanged()} shall be called + */ + boolean onItemSelected(View view, int index, T item); + + /** + * Implement the action to be taken when an item is selected. + * + * @param view The view currently showing the item. + */ + void onGroupSelected(View view, T[] items); + + /** + * Creates the context menu. + *

+ * The default implementation is empty. + *

+ * Subclasses with a context menu should override this method, create a + * {@link android.widget.PopupMenu} or a {@link android.app.Dialog} then + * inflate their context menu and show it. + * + */ + void showContextMenu(BaseItemView.ViewHolder v, T item); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java new file mode 100644 index 000000000..cb266088b --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ContextServicePlayerHandler.java @@ -0,0 +1,11 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.content.Context; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.service.ISqueezeService; + +@FunctionalInterface +interface ContextServicePlayerHandler { + void run(Context context, ISqueezeService service, Player player) throws Exception; +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java new file mode 100644 index 000000000..1381ec759 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/RemoteButton.java @@ -0,0 +1,102 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.os.Handler; +import android.provider.Settings; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import uk.org.ngo.squeezer.NowPlayingActivity; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.CurrentPlaylistActivity; +import uk.org.ngo.squeezer.itemlist.HomeActivity; +import uk.org.ngo.squeezer.service.IRButton; +import uk.org.ngo.squeezer.service.ISqueezeService; + +public enum RemoteButton { + OPEN((context, service, player) -> { + service.setActivePlayer(service.getPlayer(player.getId())); + Handler handler = new Handler(); + float animationDelay = Settings.Global.getFloat(context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f); + handler.postDelayed(() -> HomeActivity.show(context), (long) (300 * animationDelay)); + }, R.string.remote_openPlayer, R.drawable.ic_home), + OPEN_NOW_PLAYING((context, service, player) -> { + service.setActivePlayer(service.getPlayer(player.getId())); + Handler handler = new Handler(); + float animationDelay = Settings.Global.getFloat(context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f); + handler.postDelayed(() -> NowPlayingActivity.show(context), (long) (300 * animationDelay)); + }, R.string.remote_openNowPlaying, R.drawable.ic_action_nowplaying), + OPEN_CURRENT_PLAYLIST((context, service, player) -> { + service.setActivePlayer(service.getPlayer(player.getId())); + Handler handler = new Handler(); + float animationDelay = Settings.Global.getFloat(context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f); + handler.postDelayed(() -> CurrentPlaylistActivity.show(context), (long) (300 * animationDelay)); + }, R.string.remote_openCurrentPlaylist, R.drawable.ic_action_playlist), + POWER(ISqueezeService::togglePower, R.string.remote_powerDescription, R.drawable.ic_action_power_settings_new), + NEXT(ISqueezeService::nextTrack, R.string.remote_nextDescription, R.drawable.ic_action_next), + PREVIOUS(ISqueezeService::previousTrack, R.string.remote_previousDescription, R.drawable.ic_action_previous), + PLAY(ISqueezeService::togglePausePlay, R.string.remote_pausePlayDescription, R.drawable.ic_action_play), + PRESET_1((context, service, player) -> service.button(player, IRButton.playPreset_1), R.string.remote_preset1Description, "1"), + PRESET_2((context, service, player) -> service.button(player, IRButton.playPreset_2), R.string.remote_preset2Description, "2"), + PRESET_3((context, service, player) -> service.button(player, IRButton.playPreset_3), R.string.remote_preset3Description, "3"), + PRESET_4((context, service, player) -> service.button(player, IRButton.playPreset_4), R.string.remote_preset4Description, "4"), + PRESET_5((context, service, player) -> service.button(player, IRButton.playPreset_5), R.string.remote_preset5Description, "5"), + PRESET_6((context, service, player) -> service.button(player, IRButton.playPreset_6), R.string.remote_preset6Description, "6"), + + // Must be last since it's truncated from user-visible lists + UNKNOWN((context, service, player) -> { + }, R.string.remote_unknownDescription, "?"), + ; + + + public static final int UNKNOWN_IMAGE = -1; + private ContextServicePlayerHandler handler; + private @DrawableRes + int buttonImage = UNKNOWN_IMAGE; + + private @StringRes + int description; + private String buttonText; + + RemoteButton(ContextServicePlayerHandler handler, @StringRes int description) { + this(handler, description, UNKNOWN_IMAGE); + } + + RemoteButton(ServicePlayerHandler handler, @StringRes int description, @DrawableRes int buttonImage) { + this((context, service, player) -> handler.run(service, player), description, buttonImage); + } + + RemoteButton(ContextServicePlayerHandler handler, @StringRes int description, @DrawableRes int buttonImage) { + this.handler = handler; + this.buttonImage = buttonImage; + this.description = description; + } + + RemoteButton(ContextServicePlayerHandler handler, @StringRes int description, String buttonText) { + this.handler = handler; + this.buttonText = buttonText; + this.description = description; + } + + public ContextServicePlayerHandler getHandler() { + return handler; + } + + public int getButtonImage() { + return buttonImage; + } + + public String getButtonText() { + return buttonText; + } + + public @StringRes + int getDescription() { + return description; + } + + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java new file mode 100644 index 000000000..4c1633291 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServiceHandler.java @@ -0,0 +1,8 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import uk.org.ngo.squeezer.service.ISqueezeService; + +@FunctionalInterface +interface ServiceHandler { + void run(ISqueezeService service) throws Exception; +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java new file mode 100644 index 000000000..224d0729f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/ServicePlayerHandler.java @@ -0,0 +1,9 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.service.ISqueezeService; + +@FunctionalInterface +interface ServicePlayerHandler { + void run(ISqueezeService service, Player player) throws Exception; +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java new file mode 100644 index 000000000..1c82f35ce --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerHomeScreenWidget.java @@ -0,0 +1,102 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.SqueezeService; +import uk.org.ngo.squeezer.service.event.PlayersChanged; + +public class SqueezerHomeScreenWidget extends AppWidgetProvider { + + private static final String TAG = SqueezerHomeScreenWidget.class.getName(); + + public static final String PLAYER_ID = "playerId"; + + private final Handler uiThreadHandler = new Handler(Looper.getMainLooper()); + + /** + * Returns number of cells needed for given size of the widget. + * + * @param size Widget size in dp. + * @return Size in number of cells. + */ + protected static int getCellsForSize(int size) { + int n = 2; + while (70 * n - 30 < size) { + ++n; + } + return n - 1; + } + + protected void runOnService(final Context context, final ServiceHandler handler) { + boolean bound = context.getApplicationContext().bindService(new Intent(context, SqueezeService.class), new ServiceConnection() { + public void onServiceConnected(ComponentName name, IBinder service1) { + final ServiceConnection serviceConnection = this; + + if (name != null && service1 instanceof ISqueezeService) { + Log.i(SqueezerHomeScreenWidget.TAG, "onServiceConnected connected to ISqueezeService"); + final ISqueezeService squeezeService = (ISqueezeService) service1; + + // Wait for the PlayersChanged event + squeezeService.getEventBus().registerSticky(new Object() { + public void onEvent(PlayersChanged event) { + squeezeService.getEventBus().unregister(this); + Log.i(SqueezerHomeScreenWidget.TAG, "Players ready, perform action"); + uiThreadHandler.post(() -> { + showToastExceptionIfExists(context, runHandlerAndCatchException(handler, squeezeService)); + // Handler was called successfully; service no longer needed + context.unbindService(serviceConnection); + }); + } + }); + + // Auto connect if necessary + if (!squeezeService.isConnected()) { + Log.i(SqueezerHomeScreenWidget.TAG, "SqueezeService wasn't connected, connecting..."); + squeezeService.startConnect(); + } + } + } + + public void onServiceDisconnected(ComponentName name) { + Log.i(SqueezerHomeScreenWidget.TAG, "service disconnected"); + } + }, Context.BIND_AUTO_CREATE); + + if (!bound) + Log.e(SqueezerHomeScreenWidget.TAG, "Squeezer service not bound"); + } + + protected void showToastExceptionIfExists(Context context, @Nullable Exception possibleException) { + if (possibleException != null) { + Toast.makeText(context, possibleException.getMessage(), Toast.LENGTH_LONG).show(); + } + } + + private @Nullable + Exception runHandlerAndCatchException(ServiceHandler handler, ISqueezeService squeezeService) { + try { + handler.run(squeezeService); + return null; + } catch (Exception ex) { + Log.e(SqueezerHomeScreenWidget.TAG, "Exception while handling serviceHandler", ex); + return ex; + } + } + + protected void runOnPlayer(final Context context, final String playerId, final ContextServicePlayerHandler handler) { + runOnService(context, service -> handler.run(context, service, service.getPlayer(playerId))); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java new file mode 100644 index 000000000..4d1a6e46d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerInfoScreen.java @@ -0,0 +1,130 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.provider.Settings; +import android.util.Log; +import android.widget.RemoteViews; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.HomeActivity; + +/** + * TODO this will eventually be a player status widget but is currently WIP + * App Widget Configuration implemented in {@link SqueezerRemoteControlPlayerSelectActivity SqueezerRemoteControlConfigureActivity} + */ +public class SqueezerInfoScreen extends SqueezerHomeScreenWidget { + + private static final String TAG = SqueezerInfoScreen.class.getName(); + + + private static final String SQUEEZER_REMOTE_OPEN = "squeezeRemoteOpen"; + + + static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, + int appWidgetId) { + + String playerId = SqueezerRemoteControl.loadPlayerId(context, appWidgetId); + String playerName = SqueezerRemoteControl.loadPlayerName(context, appWidgetId); + + // Construct the RemoteViews object + + // See the dimensions and + Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId); + + // Get min width and height. + int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT); + RemoteViews views = getRemoteViews(context, minWidth, minHeight); + appWidgetManager.updateAppWidget(appWidgetId, views); + + Log.d(TAG, "wiring up widget for player " + playerName + " with id " + playerId); + views.setTextViewText(R.id.squeezerRemote_playerButton, playerName); + views.setOnClickPendingIntent(R.id.squeezerRemote_playerButton, getPendingSelfIntent(context, SQUEEZER_REMOTE_OPEN, playerId)); + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views); + } + + static PendingIntent getPendingSelfIntent(Context context, String action, String playerId) { + Intent intent = new Intent(context, SqueezerInfoScreen.class); + intent.setAction(action); + intent.putExtra(PLAYER_ID, playerId); + + return PendingIntent.getBroadcast(context, playerId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + // There may be multiple widgets active, so update all of them + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) { + + updateAppWidget(context, appWidgetManager, appWidgetId); + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); + } + + /** + * Determine appropriate view based on row or column provided. + * + * @param minWidth + * @param minHeight + * @return + */ + private static RemoteViews getRemoteViews(Context context, int minWidth, int minHeight) { + // First find out rows and columns based on width provided. + int rows = getCellsForSize(minHeight); + int columns = getCellsForSize(minWidth); + // Now you changing layout base on you column count + // In this code from 1 column to 4 + // you can make code for more columns on your own. + switch (columns) { + default: + return new RemoteViews(context.getPackageName(), R.layout.squeezer_remote_control); + } + } + + public void onReceive(final Context context, Intent intent) { + super.onReceive(context, intent); + String action = intent.getAction(); + final String playerId = intent.getStringExtra(PLAYER_ID); + + + Log.d(TAG, "recieved intent with action " + action + " and playerid " + playerId); + + if (SQUEEZER_REMOTE_OPEN.equals(action)) { + runOnService(context, service -> { + Log.d(TAG, "setting active player: " + playerId); + service.setActivePlayer(service.getPlayer(playerId)); + Handler handler = new Handler(); + float animationDelay = Settings.Global.getFloat(context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f); + handler.postDelayed(() -> HomeActivity.show(context), (long) (300 * animationDelay)); + }); + + } + } + + + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + // When the user deletes the widget, delete the preference associated with it. + for (int appWidgetId : appWidgetIds) { + SqueezerRemoteControl.deletePrefs(context, appWidgetId); + } + } + +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java new file mode 100644 index 000000000..c4289b237 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControl.java @@ -0,0 +1,164 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.annotation.TargetApi; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.Player; + + +/** + * Implementation of App Widget functionality. + * App Widget Configuration implemented in {@link SqueezerRemoteControlPlayerSelectActivity SqueezerRemoteControlConfigureActivity} + */ +public class SqueezerRemoteControl extends SqueezerHomeScreenWidget { + + public static final String UNKNOWN_PLAYER = "UNKNOWN_PLAYER"; + private static final String ACTION_PREFIX = "ngo.squeezer.homescreenwidgets."; + private static final String TAG = SqueezerRemoteControl.class.getName(); + static final String PREFS_NAME = "uk.org.ngo.squeezer.homescreenwidgets.SqueezerRemoteControl"; + static final String PREF_PREFIX_KEY = "squeezerRemote_"; + static final String PREF_SUFFIX_PLAYER_ID = "playerId"; + static final String PREF_SUFFIX_PLAYER_NAME = "playerName"; + static final String PREF_SUFFIX_BUTTON = "button"; + + static final String EXTRA_PLAYER = "player"; + static final String EXTRA_REMOTE_BUTTON = "remoteButton"; + + + static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, + int appWidgetId) { + + String playerId = loadPlayerId(context, appWidgetId); + String playerName = loadPlayerName(context, appWidgetId); + String action = loadAction(context, appWidgetId); + RemoteButton button = RemoteButton.valueOf(action); + + // Construct the RemoteViews object + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.squeezer_remote_control); + appWidgetManager.updateAppWidget(appWidgetId, views); + + Log.d(TAG, "wiring up widget for player " + playerName + " with id " + playerId); + views.setTextViewText(R.id.squeezerRemote_playerButton, playerName); + views.setOnClickPendingIntent(R.id.squeezerRemote_playerButton, getPendingSelfIntent(context, RemoteButton.OPEN, playerId)); + + + int buttonId; + if (button.getButtonImage() != RemoteButton.UNKNOWN_IMAGE) { + buttonId = R.id.squeezerRemote_imageButton; + views.setImageViewBitmap(buttonId, Util.vectorToBitmap(context, button.getButtonImage())); + } else { + buttonId = R.id.squeezerRemote_textButton; + views.setTextViewText(buttonId, button.getButtonText()); + } + views.setViewVisibility(buttonId, View.VISIBLE); + views.setContentDescription(buttonId, context.getString(button.getDescription())); + views.setOnClickPendingIntent(buttonId, getPendingSelfIntent(context, button, playerId)); + + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views); + } + + static PendingIntent getPendingSelfIntent(Context context, RemoteButton button, String playerId) { + Intent intent = new Intent(context, SqueezerRemoteControl.class); + intent.setAction(ACTION_PREFIX + button.name()); + intent.putExtra(PLAYER_ID, playerId); + + return PendingIntent.getBroadcast(context, playerId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + // Read the prefix from the SharedPreferences object for this widget. + // If there is no preference saved, get the default from a resource + static String loadPlayerId(Context context, int appWidgetId) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0); + return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_ID, UNKNOWN_PLAYER); + } + + static String loadPlayerName(Context context, int appWidgetId) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0); + return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_NAME, UNKNOWN_PLAYER); + } + + static String loadAction(Context context, int appWidgetId) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, 0); + return prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_BUTTON, RemoteButton.UNKNOWN.name()); + } + + static void deletePrefs(Context context, int appWidgetId) { + SharedPreferences.Editor prefs = context.getSharedPreferences(PREFS_NAME, 0).edit(); + prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_ID); + prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_PLAYER_NAME); + prefs.remove(PREF_PREFIX_KEY + appWidgetId + PREF_SUFFIX_BUTTON); + prefs.apply(); + } + + public static void savePrefs(Context context, Intent intent) { + SharedPreferences.Editor prefs = context.getSharedPreferences(SqueezerRemoteControl.PREFS_NAME, Context.MODE_PRIVATE).edit(); + + Player player = intent.getParcelableExtra(EXTRA_PLAYER); + RemoteButton button = (RemoteButton) intent.getSerializableExtra(EXTRA_REMOTE_BUTTON); + + int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + + prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_PLAYER_ID, player.getId()); + prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_PLAYER_NAME, player.getName()); + prefs.putString(SqueezerRemoteControl.PREF_PREFIX_KEY + widgetId + SqueezerRemoteControl.PREF_SUFFIX_BUTTON, button.name()); + prefs.apply(); + + SqueezerRemoteControl.updateAppWidget(context, appWidgetManager, widgetId); + + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + // There may be multiple widgets active, so update all of them + for (int appWidgetId : appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) { + + updateAppWidget(context, appWidgetManager, appWidgetId); + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); + } + + public void onReceive(final Context context, Intent intent) { + super.onReceive(context, intent); + String action = intent.getAction(); + final String playerId = intent.getStringExtra(PLAYER_ID); + + Log.d(TAG, "recieved intent with action " + action + " and playerid " + playerId); + + if (action.startsWith(ACTION_PREFIX)) { + RemoteButton button = RemoteButton.valueOf(action.substring(ACTION_PREFIX.length())); + runOnPlayer(context, playerId, button.getHandler()); + } + } + + + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + // When the user deletes the widget, delete the preference associated with it. + for (int appWidgetId : appWidgetIds) { + deletePrefs(context, appWidgetId); + } + } + +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java new file mode 100644 index 000000000..f4cce9fc6 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlButtonSelectActivity.java @@ -0,0 +1,163 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.appwidget.AppWidgetManager; +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Arrays; +import java.util.function.Consumer; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.model.Player; + +public class SqueezerRemoteControlButtonSelectActivity extends BaseActivity { + + private static final String TAG = SqueezerRemoteControlButtonSelectActivity.class.getName(); + + RecyclerView remoteButtonListView; + RemoteButton[] remoteButtonListItems = RemoteButton.values(); + ItemAdapter remoteButtonListAdapter; + + + private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + private Player player; + + /* + This Activity leverages a base Activity which almost all of squeezer uses, itself adding an + actionBar, which we don't want on this activity. + */ + protected boolean addActionBar() { + return false; + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + Log.d(TAG, "onCreate"); + + setResult(RESULT_CANCELED); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.configure_select_button); + } + + setContentView(R.layout.squeezer_remote_control_button_select); + + + // Find the widget id from the intent. + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + if (extras != null) { + mAppWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + player = extras.getParcelable(SqueezerRemoteControl.EXTRA_PLAYER); + } + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + return; + } + + remoteButtonListView = findViewById(R.id.remoteButtonList); + remoteButtonListView.setLayoutManager(new LinearLayoutManager(this)); + remoteButtonListAdapter = new ItemAdapter( + Arrays.stream(remoteButtonListItems).filter(b -> b != RemoteButton.UNKNOWN).toArray(RemoteButton[]::new), + this::finish); + + remoteButtonListView.setAdapter(remoteButtonListAdapter); + + + } + + private class ItemAdapter extends RecyclerView.Adapter { + + private RemoteButton[] buttons; + private Consumer clickHandler; + + public ItemAdapter(RemoteButton[] buttons, Consumer clickHandler) { + this.buttons = buttons; + this.clickHandler = clickHandler; + } + + public int getItemCount() { + return buttons.length; + } + + @Override + @NonNull + public RecyclerView.ViewHolder onCreateViewHolder(final @NonNull ViewGroup parent, final int viewType) { + return new RemoteButtonViewHolder(LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false), clickHandler); + } + + @Override + public void onBindViewHolder(final @NonNull RecyclerView.ViewHolder holder, final int position) { + RemoteButtonViewHolder viewHolder = (RemoteButtonViewHolder) holder; + viewHolder.bindData(buttons[position]); + } + + @Override + public int getItemViewType(final int position) { + return R.layout.squeezer_remote_control_button_select_item; + } + } + + private class RemoteButtonViewHolder extends RecyclerView.ViewHolder { + private TextView textView; + private ImageView imageView; + private Consumer clickHandler; + + public RemoteButtonViewHolder(final View itemView, Consumer clickHandler) { + super(itemView); + textView = itemView.findViewById(R.id.text); + imageView = itemView.findViewById(R.id.icon); + this.clickHandler = clickHandler; + } + + public void bindData(final RemoteButton button) { + itemView.setOnClickListener(v -> clickHandler.accept(button)); + int buttonImage = button.getButtonImage(); + int description = button.getDescription(); + + if (buttonImage != RemoteButton.UNKNOWN_IMAGE) { + imageView.setImageBitmap(Util.vectorToBitmap(SqueezerRemoteControlButtonSelectActivity.this.getBaseContext(), buttonImage)); + } else { + final TypedArray a = obtainStyledAttributes(new int[]{R.attr.colorControlNormal}); + final int tintColor = a.getColor(0, 0); + a.recycle(); + + TextDrawable drawable = new TextDrawable(Resources.getSystem(), button.getButtonText(), tintColor); + imageView.setImageDrawable(drawable); + } + textView.setText(description); + } + } + + + public void finish(RemoteButton button) { + // Make sure we pass back the original appWidgetId + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + resultValue.putExtra(SqueezerRemoteControl.EXTRA_PLAYER, player); + resultValue.putExtra(SqueezerRemoteControl.EXTRA_REMOTE_BUTTON, button); + setResult(RESULT_OK, resultValue); + finish(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java new file mode 100644 index 000000000..9849884c8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/SqueezerRemoteControlPlayerSelectActivity.java @@ -0,0 +1,160 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import androidx.annotation.LayoutRes; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; + +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.PlayerBaseView; +import uk.org.ngo.squeezer.itemlist.PlayerListBaseActivity; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** + * The configuration screen for the {@link SqueezerRemoteControl SqueezerRemoteControl} AppWidget. + */ +public class SqueezerRemoteControlPlayerSelectActivity extends PlayerListBaseActivity { + + private static final String TAG = SqueezerRemoteControlPlayerSelectActivity.class.getName(); + + private static final int GET_BUTTON_ACTIVITY = 1001; + + private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + + public SqueezerRemoteControlPlayerSelectActivity() { + super(); + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + Log.d(TAG, "onCreate"); + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if the user presses the back button. + // Actual result, when successful is below in the onGroupSelected handler + setResult(RESULT_CANCELED); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.configure_select_player); + } + + setContentView(R.layout.squeezer_remote_control_configure); + + // Find the widget id from the intent. + Intent intent = getIntent(); + Bundle extras = intent.getExtras(); + if (extras != null) { + mAppWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + } + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + return; + } + + } + + public PlayerBaseView createPlayerView() { + return new SqueezerRemoteControlConfigureActivityPlayerBaseView(); + } + + /* + This Activity leverages a base Activity which almost all of squeezer uses, itself adding a + player status, which we don't want on this activity. + */ + @Override + public void setContentView(@LayoutRes int layoutResID) { + getDelegate().setContentView(layoutResID); + + setListView(setupListView(findViewById(R.id.item_list))); + } + + /* + This Activity leverages a base Activity which almost all of squeezer uses, itself adding an + actionBar, which we don't want on this activity. + */ + protected boolean addActionBar() { + return false; + } + + @Override + protected boolean needPlayer() { + return false; + } + + @Override + protected void updateAdapter(int count, int start, List items, Class dataType) { + + } + + private class SqueezerRemoteControlConfigureActivityPlayerBaseView extends PlayerBaseView { + + public SqueezerRemoteControlConfigureActivityPlayerBaseView() { + super(SqueezerRemoteControlPlayerSelectActivity.this, R.layout.list_item_player_simple); + setViewParams(VIEW_PARAM_ICON); + } + + @Override + public void bindView(View view, Player player) { + super.bindView(view, player); + ViewHolder viewHolder = (ViewHolder) view.getTag(); + viewHolder.icon.setImageResource(getModelIcon(player.getModel())); + + PlayerState playerState = player.getPlayerState(); + + if (playerState.isPoweredOn()) { + viewHolder.text1.setAlpha(1.0f); + } else { + viewHolder.text1.setAlpha(0.25f); + } + } + + public void onGroupSelected(View view, Player[] items) { + final Context context = SqueezerRemoteControlPlayerSelectActivity.this; + + Intent intent = new Intent(context, SqueezerRemoteControlButtonSelectActivity.class); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + intent.putExtra(SqueezerRemoteControl.EXTRA_PLAYER, items[0]); + + startActivityForResult(intent, GET_BUTTON_ACTIVITY); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + switch (requestCode) { + case GET_BUTTON_ACTIVITY: + if (resultCode != RESULT_CANCELED) { + SqueezerRemoteControl.savePrefs(this.getBaseContext(), data); + + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + setResult(RESULT_OK, resultValue); + finish(); + } + break; + default: + Log.w(TAG, "Unknown request code: " + requestCode); + } + } +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java new file mode 100644 index 000000000..94fc89420 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/homescreenwidgets/TextDrawable.java @@ -0,0 +1,67 @@ +package uk.org.ngo.squeezer.homescreenwidgets; + +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.TypedValue; + +import androidx.annotation.ColorInt; + +public class TextDrawable extends Drawable { + private static final int DEFAULT_TEXTSIZE = 15; + private Paint mPaint; + private CharSequence mText; + private int mIntrinsicWidth; + private int mIntrinsicHeight; + float textSize; + + + public TextDrawable(Resources res, CharSequence text, @ColorInt int color) { + mText = text; + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + mPaint.setColor(color); + mPaint.setTextAlign(Paint.Align.CENTER); + textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, + DEFAULT_TEXTSIZE, res.getDisplayMetrics()); + mPaint.setTextSize(textSize); + mIntrinsicWidth = (int) (mPaint.measureText(mText, 0, mText.length()) + .5); + mIntrinsicHeight = mPaint.getFontMetricsInt(null); + } + + @Override + public void draw(Canvas canvas) { + Rect bounds = getBounds(); + + canvas.drawText(mText, 0, mText.length(), + bounds.centerX(), bounds.centerY() + textSize/2, mPaint); + } + + @Override + public int getOpacity() { + return mPaint.getAlpha(); + } + + @Override + public int getIntrinsicWidth() { + return mIntrinsicWidth; + } + + @Override + public int getIntrinsicHeight() { + return mIntrinsicHeight; + } + + @Override + public void setAlpha(int alpha) { + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(ColorFilter filter) { + mPaint.setColorFilter(filter); + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java new file mode 100644 index 000000000..c5108a6c7 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmView.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.app.Dialog; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; +import android.text.SpannableString; +import android.text.format.DateFormat; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckedTextView; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; + +import com.android.datetimepicker.time.RadialPickerLayout; +import com.android.datetimepicker.time.TimePickerDialog; +import com.google.common.collect.ImmutableList; + +import java.text.DateFormatSymbols; +import java.util.ArrayList; +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.framework.BaseListActivity; +import uk.org.ngo.squeezer.model.Alarm; +import uk.org.ngo.squeezer.model.AlarmPlaylist; +import uk.org.ngo.squeezer.util.CompoundButtonWrapper; +import uk.org.ngo.squeezer.widget.AnimationEndListener; +import uk.org.ngo.squeezer.widget.UndoBarController; + +public class AlarmView extends BaseItemView { + private static final int ANIMATION_DURATION = 300; + + private final AlarmsActivity mActivity; + private final Resources mResources; + private final int mColorSelected; + private final float mDensity; + private final List mAlarmPlaylists = new ArrayList<>(); + + public AlarmView(AlarmsActivity activity) { + super(activity); + mActivity = activity; + mResources = activity.getResources(); + mColorSelected = mResources.getColor(getActivity().getAttributeValue(R.attr.alarm_dow_selected)); + mDensity = mResources.getDisplayMetrics().density; + } + + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, Alarm item, boolean selected) { + View view = getAdapterView(convertView, parent); + bindView((AlarmViewHolder) view.getTag(), position, item); + return view; + } + + private View getAdapterView(View convertView, final ViewGroup parent) { + AlarmViewHolder currentViewHolder = + (convertView != null && convertView.getTag() instanceof AlarmViewHolder) + ? (AlarmViewHolder) convertView.getTag() + : null; + + if (currentViewHolder == null) { + convertView = getLayoutInflater().inflate(R.layout.list_item_alarm, parent, false); + final View alarmView = convertView; + final AlarmViewHolder viewHolder = new AlarmViewHolder(); + viewHolder.is24HourFormat = DateFormat.is24HourFormat(getActivity()); + viewHolder.timeFormat = viewHolder.is24HourFormat ? "%02d:%02d" : "%d:%02d"; + String[] amPmStrings = new DateFormatSymbols().getAmPmStrings(); + viewHolder.am = amPmStrings[0]; + viewHolder.pm = amPmStrings[1]; + viewHolder.time = convertView.findViewById(R.id.time); + viewHolder.amPm = convertView.findViewById(R.id.am_pm); + viewHolder.amPm.setVisibility(viewHolder.is24HourFormat ? View.GONE : View.VISIBLE); + viewHolder.enabled = new CompoundButtonWrapper(convertView.findViewById(R.id.enabled)); + viewHolder.enabled.setOncheckedChangeListener((compoundButton, b) -> { + if (getActivity().getService() != null) { + viewHolder.alarm.setEnabled(b); + getActivity().getService().alarmEnable(viewHolder.alarm.getId(), b); + } + }); + viewHolder.repeat = new CompoundButtonWrapper(convertView.findViewById(R.id.repeat)); + viewHolder.repeat.setOncheckedChangeListener((compoundButton, b) -> { + if (getActivity().getService() != null) { + viewHolder.alarm.setRepeat(b); + getActivity().getService().alarmRepeat(viewHolder.alarm.getId(), b); + viewHolder.dowHolder.setVisibility(b ? View.VISIBLE : View.GONE); + } + }); + viewHolder.repeat.getButton().setText(R.string.ALARM_ALARM_REPEAT); + viewHolder.delete = convertView.findViewById(R.id.delete); + viewHolder.playlist = convertView.findViewById(R.id.playlist); + viewHolder.dowHolder = convertView.findViewById(R.id.dow); + for (int day = 0; day < 7; day++) { + ViewGroup dowButton = (ViewGroup) viewHolder.dowHolder.getChildAt(day); + final int finalDay = day; + dowButton.setOnClickListener(v -> { + if (getActivity().getService() != null) { + final Alarm alarm = viewHolder.alarm; + boolean wasChecked = alarm.isDayActive(finalDay); + if (wasChecked) { + alarm.clearDay(finalDay); + getActivity().getService().alarmRemoveDay(alarm.getId(), finalDay); + } else { + alarm.setDay(finalDay); + getActivity().getService().alarmAddDay(alarm.getId(), finalDay); + } + setDowText(viewHolder, finalDay); + } + }); + viewHolder.dowTexts[day] = (TextView) dowButton.getChildAt(0); + } + viewHolder.delete.setOnClickListener(view -> { + final AnimationSet animationSet = new AnimationSet(true); + animationSet.addAnimation(new ScaleAnimation(1F, 1F, 1F, 0.5F)); + animationSet.addAnimation(new AlphaAnimation(1F, 0F)); + animationSet.setDuration(ANIMATION_DURATION); + animationSet.setAnimationListener(new AnimationEndListener() { + @Override + public void onAnimationEnd(Animation animation) { + mActivity.getItemAdapter().removeItem(viewHolder.position); + UndoBarController.show(getActivity(), R.string.ALARM_DELETING, new UndoListener(viewHolder.position, viewHolder.alarm)); + } + }); + + alarmView.startAnimation(animationSet); + }); + viewHolder.playlist.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + final AlarmPlaylist selectedAlarmPlaylist = mAlarmPlaylists.get(position); + final Alarm alarm = viewHolder.alarm; + if (getActivity().getService() != null && + selectedAlarmPlaylist.getId() != null && + !selectedAlarmPlaylist.getId().equals(alarm.getPlayListId())) { + alarm.setPlayListId(selectedAlarmPlaylist.getId()); + getActivity().getService().alarmSetPlaylist(alarm.getId(), selectedAlarmPlaylist); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + + convertView.setTag(viewHolder); + } + + return convertView; + } + + private void bindView(final AlarmViewHolder viewHolder, final int position, final Alarm item) { + long tod = item.getTod(); + int hour = (int) (tod / 3600); + int minute = (int) ((tod / 60) % 60); + int displayHour = hour; + if (!viewHolder.is24HourFormat) { + displayHour = displayHour % 12; + if (displayHour == 0) displayHour = 12; + } + + viewHolder.position = position; + viewHolder.alarm = item; + viewHolder.time.setText(String.format(viewHolder.timeFormat, displayHour, minute)); + viewHolder.time.setOnClickListener(view -> TimePickerFragment.show(getActivity().getSupportFragmentManager(), item, viewHolder.is24HourFormat, getActivity().getThemeId() == R.style.AppTheme)); + viewHolder.amPm.setText(hour < 12 ? viewHolder.am : viewHolder.pm); + viewHolder.enabled.setChecked(item.isEnabled()); + viewHolder.repeat.setChecked(item.isRepeat()); + if (!mAlarmPlaylists.isEmpty()) { + viewHolder.playlist.setAdapter(new AlarmPlaylistSpinnerAdapter()); + for (int i = 0; i < mAlarmPlaylists.size(); i++) { + AlarmPlaylist alarmPlaylist = mAlarmPlaylists.get(i); + if (alarmPlaylist.getId() != null && alarmPlaylist.getId().equals(item.getPlayListId())) { + viewHolder.playlist.setSelection(i); + break; + } + } + + } + + viewHolder.dowHolder.setVisibility(item.isRepeat() ? View.VISIBLE : View.GONE); + for (int day = 0; day < 7; day++) { + setDowText(viewHolder, day); + } + } + + private void setDowText(AlarmViewHolder viewHolder, int day) { + SpannableString text = new SpannableString(getAlarmShortDayText(day)); + if (viewHolder.alarm.isDayActive(day)) { + text.setSpan(new StyleSpan(Typeface.BOLD), 0, text.length(), 0); + text.setSpan(new ForegroundColorSpan(mColorSelected), 0, text.length(), 0); + Drawable underline = mResources.getDrawable(R.drawable.underline); + float textSize = (new Paint()).measureText(text.toString()); + underline.setBounds(0, 0, (int) (textSize * mDensity), (int) (1 * mDensity)); + viewHolder.dowTexts[day].setCompoundDrawables(null, null, null, underline); + } else + viewHolder.dowTexts[day].setCompoundDrawables(null, null, null, null); + viewHolder.dowTexts[day].setText(text); + } + + private CharSequence getAlarmShortDayText(int day) { + switch (day) { + default: return getActivity().getString(R.string.ALARM_SHORT_DAY_0); + case 1: return getActivity().getString(R.string.ALARM_SHORT_DAY_1); + case 2: return getActivity().getString(R.string.ALARM_SHORT_DAY_2); + case 3: return getActivity().getString(R.string.ALARM_SHORT_DAY_3); + case 4: return getActivity().getString(R.string.ALARM_SHORT_DAY_4); + case 5: return getActivity().getString(R.string.ALARM_SHORT_DAY_5); + case 6: return getActivity().getString(R.string.ALARM_SHORT_DAY_6); + } + } + + @Override + public boolean isSelectable(Alarm item) { + return false; + } + + // Require an immutable list so that caller's can't modify it when this method iterates + // over it. + public void setAlarmPlaylists(ImmutableList alarmPlaylists) { + String currentCategory = null; + + mAlarmPlaylists.clear(); + for (AlarmPlaylist alarmPlaylist : alarmPlaylists) { + if (!alarmPlaylist.getCategory().equals(currentCategory)) { + AlarmPlaylist categoryAlarmPlaylist = new AlarmPlaylist(); + categoryAlarmPlaylist.setCategory(alarmPlaylist.getCategory()); + mAlarmPlaylists.add(categoryAlarmPlaylist); + } + mAlarmPlaylists.add(alarmPlaylist); + currentCategory = alarmPlaylist.getCategory(); + } + } + + private static class AlarmViewHolder { + int position; + public boolean is24HourFormat; + String timeFormat; + String am; + String pm; + Alarm alarm; + TextView time; + TextView amPm; + CompoundButtonWrapper enabled; + CompoundButtonWrapper repeat; + ImageView delete; + Spinner playlist; + LinearLayout dowHolder; + final TextView[] dowTexts = new TextView[7]; + } + + public static class TimePickerFragment extends TimePickerDialog implements TimePickerDialog.OnTimeSetListener { + BaseListActivity activity; + Alarm alarm; + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + activity = (BaseListActivity) getActivity(); + alarm = getArguments().getParcelable("alarm"); + setOnTimeSetListener(this); + return super.onCreateDialog(savedInstanceState); + } + + public static void show(FragmentManager manager, Alarm alarm, boolean is24HourFormat, boolean dark) { + long tod = alarm.getTod(); + int hour = (int) (tod / 3600); + int minute = (int) ((tod / 60) % 60); + + TimePickerFragment fragment = new TimePickerFragment(); + Bundle bundle = new Bundle(); + bundle.putParcelable("alarm", alarm); + fragment.setArguments(bundle); + fragment.initialize(fragment, hour, minute, is24HourFormat); + fragment.setThemeDark(dark); + fragment.show(manager, TimePickerFragment.class.getSimpleName()); + } + + @Override + public void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute) { + if (activity.getService() != null) { + int time = (hourOfDay * 60 + minute) * 60; + alarm.setTod(time); + activity.getService().alarmSetTime(alarm.getId(), time); + activity.getItemAdapter().notifyDataSetChanged(); + } + } + } + + private class AlarmPlaylistSpinnerAdapter extends ArrayAdapter { + + public AlarmPlaylistSpinnerAdapter() { + super(getActivity(), android.R.layout.simple_spinner_dropdown_item, mAlarmPlaylists); + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int position) { + return (mAlarmPlaylists.get(position).getId() != null); + } + + @NonNull + @Override + public View getView(int position, View convertView, @NonNull ViewGroup parent) { + return Util.getSpinnerItemView(getActivity(), convertView, parent, getItem(position).getName()); + } + + @Override + public View getDropDownView(int position, View convertView, @NonNull ViewGroup parent) { + if (!isEnabled(position)) { + FrameLayout view = (FrameLayout) getActivity().getLayoutInflater().inflate(R.layout.alarm_playlist_category_dropdown_item, parent, false); + CheckedTextView spinnerItemView = view.findViewById(R.id.text); + spinnerItemView.setText(getItem(position).getCategory()); + spinnerItemView.setTypeface(spinnerItemView.getTypeface(), Typeface.BOLD); + // Hide the checkmark for headings. + spinnerItemView.setCheckMarkDrawable(new ColorDrawable(Color.TRANSPARENT)); + return view; + } else { + FrameLayout view = (FrameLayout) getActivity().getLayoutInflater().inflate(R.layout.alarm_playlist_dropdown_item, parent, false); + TextView spinnerItemView = view.findViewById(R.id.text); + spinnerItemView.setText(getItem(position).getName()); + return view; + } + } + } + + private class UndoListener implements UndoBarController.UndoListener { + private final int position; + private final Alarm alarm; + + public UndoListener(int position, Alarm alarm) { + this.position = position; + this.alarm = alarm; + } + + @Override + public void onUndo() { + mActivity.getItemAdapter().insertItem(position, alarm); + } + + @Override + public void onDone() { + if (mActivity.getService() != null) { + mActivity.getService().alarmDelete(alarm.getId()); + } + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java new file mode 100644 index 000000000..5de390118 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/AlarmsActivity.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; +import android.text.format.DateFormat; +import android.view.View; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.datetimepicker.time.RadialPickerLayout; +import com.android.datetimepicker.time.TimePickerDialog; +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.dialog.AlarmSettingsDialog; +import uk.org.ngo.squeezer.framework.BaseListActivity; +import uk.org.ngo.squeezer.framework.ItemView; +import uk.org.ngo.squeezer.model.Alarm; +import uk.org.ngo.squeezer.model.AlarmPlaylist; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.ActivePlayerChanged; +import uk.org.ngo.squeezer.service.event.PlayerPrefReceived; +import uk.org.ngo.squeezer.util.CompoundButtonWrapper; +import uk.org.ngo.squeezer.widget.UndoBarController; + +public class AlarmsActivity extends BaseListActivity implements AlarmSettingsDialog.HostActivity { + /** The most recent active player. */ + private Player mActivePlayer; + + private AlarmView mAlarmView; + + /** Toggle/Switch that controls whether all alarms are enabled or disabled. */ + private CompoundButtonWrapper mAlarmsEnabledButton; + + /** View that contains all_alarms_{on,off}_hint text. */ + private TextView mAllAlarmsHintView; + + /** Settings button. */ + private ImageView mSettingsButton; + + /** Have player preference values been requested from the server? */ + private boolean mPrefsOrdered = false; + + /** Maps from a @Player.Pref.Name to its value. */ + private final Map mPlayerPrefs = new HashMap<>(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ((TextView)findViewById(R.id.all_alarms_text)).setText(R.string.ALARM_ALL_ALARMS); + mAllAlarmsHintView = findViewById(R.id.all_alarms_hint); + + mAlarmsEnabledButton = new CompoundButtonWrapper((CompoundButton) findViewById(R.id.alarms_enabled)); + findViewById(R.id.add_alarm).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + TimePickerFragment.show(getSupportFragmentManager(), DateFormat.is24HourFormat(AlarmsActivity.this), getThemeId() == R.style.AppTheme); + } + }); + + mSettingsButton = findViewById(R.id.settings); + mSettingsButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + new AlarmSettingsDialog().show(getSupportFragmentManager(), "AlarmSettingsDialog"); + } + }); + + if (savedInstanceState != null) { + mActivePlayer = savedInstanceState.getParcelable("activePlayer"); + } + } + + @Override + protected void onPostCreate(Bundle savedInstanceState) { + super.onPostCreate(savedInstanceState); + mAlarmsEnabledButton.setOncheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mAllAlarmsHintView.setText(isChecked ? R.string.all_alarms_on_hint : R.string.all_alarms_off_hint); + if (getService() != null) { + getService().playerPref(Player.Pref.ALARMS_ENABLED, isChecked ? "1" : "0"); + } + } + }); + } + + @Override + protected void onServiceConnected(@NonNull ISqueezeService service) { + super.onServiceConnected(service); + maybeOrderPrefs(service); + } + + @Override + public void onResume() { + super.onResume(); + ISqueezeService service = getService(); + if (service != null) { + maybeOrderPrefs(service); + } + } + + private void maybeOrderPrefs(ISqueezeService service) { + if (!mPrefsOrdered) { + mPrefsOrdered = true; + + service.playerPref(Player.Pref.ALARM_FADE_SECONDS); + service.playerPref(Player.Pref.ALARM_DEFAULT_VOLUME); + service.playerPref(Player.Pref.ALARM_SNOOZE_SECONDS); + service.playerPref(Player.Pref.ALARM_TIMEOUT_SECONDS); + } + } + + @Override + public void onPause() { + super.onPause(); + UndoBarController.hide(this); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable("activePlayer", mActivePlayer); + } + + public static void show(Activity context) { + final Intent intent = new Intent(context, AlarmsActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + context.startActivity(intent); + } + + @Override + protected int getContentView() { + return R.layout.item_list_player_alarms; + } + + @Override + public ItemView createItemView() { + mAlarmView = new AlarmView(this); + return mAlarmView; + } + + @Override + protected boolean needPlayer() { + return true; + } + + @Override + protected void orderPage(@NonNull ISqueezeService service, int start) { + service.alarms(start, this); + if (start == 0) { + mActivePlayer = service.getActivePlayer(); + service.alarmPlaylists(mAlarmPlaylistsCallback); + + mAlarmsEnabledButton.setEnabled(false); + service.playerPref(Player.Pref.ALARMS_ENABLED); + } + } + + private final IServiceItemListCallback mAlarmPlaylistsCallback = new IServiceItemListCallback() { + private final List mAlarmPlaylists = new ArrayList<>(); + + @Override + public void onItemsReceived(final int count, final int start, Map parameters, final List items, Class dataType) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (start == 0) { + mAlarmPlaylists.clear(); + } + + mAlarmPlaylists.addAll(items); + if (start + items.size() >= count) { + mAlarmView.setAlarmPlaylists(ImmutableList.copyOf(mAlarmPlaylists)); + getItemAdapter().notifyDataSetChanged(); + + } + } + }); + } + + @Override + public Object getClient() { + return AlarmsActivity.this; + } + }; + + public void onEventMainThread(PlayerPrefReceived event) { + if (!event.player.equals(getService().getActivePlayer())) { + return; + } + + mPlayerPrefs.put(event.pref, event.value); + + if (Player.Pref.ALARMS_ENABLED.equals(event.pref)) { + boolean checked = Integer.valueOf(event.value) > 0; + mAlarmsEnabledButton.setEnabled(true); + mAlarmsEnabledButton.setChecked(checked); + mAllAlarmsHintView.setText(checked ? R.string.all_alarms_on_hint : R.string.all_alarms_off_hint); + } + + // The settings dialog can only be shown after all 4 prefs have been received, so + // that it can show their values. + if (mSettingsButton.getVisibility() == View.INVISIBLE) { + if (mPlayerPrefs.containsKey(Player.Pref.ALARM_DEFAULT_VOLUME) && + mPlayerPrefs.containsKey(Player.Pref.ALARM_SNOOZE_SECONDS) && + mPlayerPrefs.containsKey(Player.Pref.ALARM_TIMEOUT_SECONDS) && + mPlayerPrefs.containsKey(Player.Pref.ALARM_FADE_SECONDS)) { + mSettingsButton.setVisibility(View.VISIBLE); + } + } + } + + @MainThread + public void onEventMainThread(ActivePlayerChanged event) { + super.onEventMainThread(event); + mActivePlayer = event.player; + } + + @Override + @NonNull + public Player getPlayer() { + return mActivePlayer; + } + + @Override + @NonNull + public String getPlayerPref(@NonNull @Player.Pref.Name String playerPref, @NonNull String def) { + String ret = mPlayerPrefs.get(playerPref); + if (ret == null) { + ret = def; + } + return ret; + } + + @Override + public void onPositiveClick(int volume, int snooze, int timeout, boolean fade) { + ISqueezeService service = getService(); + if (service != null) { + service.playerPref(Player.Pref.ALARM_DEFAULT_VOLUME, String.valueOf(volume)); + service.playerPref(Player.Pref.ALARM_SNOOZE_SECONDS, String.valueOf(snooze)); + service.playerPref(Player.Pref.ALARM_TIMEOUT_SECONDS, String.valueOf(timeout)); + service.playerPref(Player.Pref.ALARM_FADE_SECONDS, fade ? "1" : "0"); + } + } + + public static class TimePickerFragment extends TimePickerDialog implements TimePickerDialog.OnTimeSetListener { + BaseListActivity activity; + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + activity = (BaseListActivity) getActivity(); + setOnTimeSetListener(this); + return super.onCreateDialog(savedInstanceState); + } + + public static void show(FragmentManager manager, boolean is24HourMode, boolean dark) { + // Use the current time as the default values for the picker + final Calendar c = Calendar.getInstance(); + int hour = c.get(Calendar.HOUR_OF_DAY); + int minute = c.get(Calendar.MINUTE); + + TimePickerFragment fragment = new TimePickerFragment(); + fragment.initialize(fragment, hour, minute, is24HourMode); + fragment.setThemeDark(dark); + fragment.show(manager, TimePickerFragment.class.getSimpleName()); + } + + @Override + public void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute) { + if (activity.getService() != null) { + activity.getService().alarmAdd((hourOfDay * 60 + minute) * 60); + // TODO add to list and animate the new alarm in + activity.clearAndReOrderItems(); + } + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java new file mode 100644 index 000000000..d678644d5 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistActivity.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.AbsListView; +import android.widget.ListView; + +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.core.view.GestureDetectorCompat; + +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.ItemView; +import uk.org.ngo.squeezer.itemlist.dialog.PlaylistSaveDialog; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.MusicChanged; +import uk.org.ngo.squeezer.service.event.PlaylistChanged; +import uk.org.ngo.squeezer.widget.OnSwipeListener; +import uk.org.ngo.squeezer.widget.UndoBarController; + +/** + * Activity that shows the songs in the current playlist. + */ +public class CurrentPlaylistActivity extends JiveItemListActivity { + private int skipPlaylistChanged = 0; + private int draggedIndex = -1; + + /** + * Called when the activity is first created. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setHomeAsUpIndicator(R.drawable.ic_action_close); + } + + final GestureDetectorCompat detector = new GestureDetectorCompat(this, new OnSwipeListener() { + @Override + public boolean onSwipeDown() { + finish(); + return true; + } + }); + findViewById(R.id.parent_container).setOnTouchListener((v, event) -> { + detector.onTouchEvent(event); + return true; + }); + + ignoreIconMessages = true; + } + + @Override + public void onPause() { + if (isFinishing()) { + overridePendingTransition(android.R.anim.fade_in, R.anim.slide_out_down); + } + super.onPause(); + } + + @Override + protected void orderPage(@NonNull ISqueezeService service, int start) { + service.pluginItems(start, "status", this); + } + + @Override + protected boolean needPlayer() { + return true; + } + + @Override + public void setListView(AbsListView listView) { + super.setListView(listView); + listView.setOnDragListener(new ListDragListener(this)); + } + + @Override + public ItemView createItemView() { + return new CurrentPlaylistItemView(this); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.currentplaylistmenu, menu); + return super.onCreateOptionsMenu(menu); + } + + /** + * Sets the enabled state of the R.menu.currentplaylistmenu items. + */ + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + final int[] ids = {R.id.menu_item_playlist_clear, R.id.menu_item_playlist_save, + R.id.menu_item_playlist_show_current_song}; + + final boolean knowCurrentPlaylist = getCurrentPlaylist() != null; + + for (int id : ids) { + MenuItem item = menu.findItem(id); + item.setVisible(knowCurrentPlaylist); + } + + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_item_playlist_clear: + UndoBarController.show(this, R.string.CLEAR_PLAYLIST, new UndoBarController.UndoListener() { + @Override + public void onUndo() { + } + + @Override + public void onDone() { + if (getService() != null) { + getService().playlistClear(); + } + } + }); + return true; + case R.id.menu_item_playlist_save: + PlaylistSaveDialog.addTo(this, getCurrentPlaylist()); + return true; + case R.id.menu_item_playlist_show_current_song: + getListView().smoothScrollToPositionFromTop(getItemAdapter().getSelectedIndex(), 0); + return true; + + } + return super.onOptionsItemSelected(item); + } + + private String getCurrentPlaylist() { + if (getService() == null) { + return null; + } + return getService().getCurrentPlaylist(); + } + + public void onEventMainThread(MusicChanged event) { + if (getService() == null) { + return; + } + if (event.player.equals(getService().getActivePlayer())) { + getItemAdapter().setSelectedIndex(event.playerState.getCurrentPlaylistIndex()); + getItemAdapter().notifyDataSetChanged(); + } + } + + public void onEventMainThread(PlaylistChanged event) { + if (getService() == null) { + return; + } + if (skipPlaylistChanged > 0) { + skipPlaylistChanged--; + return; + } + if (event.player.equals(getService().getActivePlayer())) { + clearAndReOrderItems(); + getItemAdapter().notifyDataSetChanged(); + } + } + + public void skipPlaylistChanged() { + skipPlaylistChanged++; + } + + public int getDraggedIndex() { + return draggedIndex; + } + + public void setDraggedIndex(int draggedIndex) { + this.draggedIndex = draggedIndex; + getItemAdapter().notifyDataSetChanged(); + } + + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + List playlistItems = new ArrayList<>(); + for (JiveItem item : items) { + // Skip special items (global actions) as there are handled locally + if ((item.hasSubItems() || item.hasInput())) { + count--; + } else { + playlistItems.add(item); + if (item.moreAction == null) { + item.moreAction = item.goAction; + item.goAction = null; + } + } + } + super.onItemsReceived(count, start, parameters, playlistItems, dataType); + + ISqueezeService service = getService(); + if (service == null) { + return; + } + + int selectedIndex = service.getPlayerState().getCurrentPlaylistIndex(); + getItemAdapter().setSelectedIndex(selectedIndex); + // Initially position the list at the currently playing song. + // Do it again once it has loaded because the newly displayed items + // may push the current song outside the displayed area + if (start == 0 || (start <= selectedIndex && selectedIndex < start + playlistItems.size())) { + runOnUiThread(() -> ((ListView) getListView()).setSelectionFromTop(selectedIndex, 0)); + } + } + + public static void show(Context context) { + final Intent intent = new Intent(context, CurrentPlaylistActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.putExtra(JiveItem.class.getName(), JiveItem.CURRENT_PLAYLIST); + + if (!(context instanceof Activity)) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + context.startActivity(intent); + if (context instanceof Activity) { + ((Activity) context).overridePendingTransition(R.anim.slide_in_up, android.R.anim.fade_out); + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java new file mode 100644 index 000000000..7e379e01c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/CurrentPlaylistItemView.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2020 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.content.ClipData; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Build; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; + +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.view.GestureDetectorCompat; +import androidx.palette.graphics.Palette; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.widget.AnimationEndListener; +import uk.org.ngo.squeezer.widget.OnSwipeListener; +import uk.org.ngo.squeezer.widget.UndoBarController; + +class CurrentPlaylistItemView extends JiveItemView { + private static final int ANIMATION_DURATION = 200; + + private final CurrentPlaylistActivity activity; + + public CurrentPlaylistItemView(CurrentPlaylistActivity activity) { + super(activity, activity.window.windowStyle); + this.activity = activity; + } + + @Override + public BaseItemView.ViewHolder createViewHolder(View itemView) { + return new ViewHolder(itemView); + } + + @Override + public void bindView(View view, JiveItem item) { + super.bindView(view, item); + view.setBackgroundResource(getActivity().getAttributeValue(R.attr.selectableItemBackground)); + ViewHolder viewHolder = (ViewHolder) view.getTag(); + + if (viewHolder.position == activity.getItemAdapter().getSelectedIndex()) { + viewHolder.text1.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Primary_Highlight); + viewHolder.text2.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Secondary_Highlight); + } else { + viewHolder.text1.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Primary); + viewHolder.text2.setTextAppearance(getActivity(), R.style.SqueezerTextAppearance_ListItem_Secondary); + } + + view.setAlpha(viewHolder.position == activity.getDraggedIndex() ? 0 : 1); + + final GestureDetectorCompat detector = new GestureDetectorCompat(getActivity(), new OnSwipeListener() { + @Override + public boolean onDown(MotionEvent e) { + view.setPressed(true); + return super.onDown(e); + } + + @Override + public void onLongPress(MotionEvent e) { + activity.setDraggedIndex(viewHolder.position); + view.setPressed(false); + ClipData data = ClipData.newPlainText("", ""); + View.DragShadowBuilder shadowBuilder = new View.DragShadowBuilder(view); + view.setActivated(true); + view.startDrag(data, shadowBuilder, null, 0); + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + onItemSelected(view, viewHolder.position, item); + return true; + } + + @Override + public boolean onSwipeLeft() { + removeItem(view, viewHolder.position, item); + return true; + } + + @Override + public boolean onSwipeRight() { + removeItem(view, viewHolder.position, item); + return true; + } + }); + + view.setOnTouchListener((v, event) -> { + if (event.getAction() == MotionEvent.ACTION_UP) { + view.setPressed(false); + view.performClick(); + } + return detector.onTouchEvent(event); + }); + } + + @Override + public void onIcon(ViewHolder viewHolder) { + if (viewHolder.position == activity.getItemAdapter().getSelectedIndex() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Drawable icon = viewHolder.icon.getDrawable(); + Drawable marker = AppCompatResources.getDrawable(activity, R.drawable.ic_action_nowplaying); + Palette colorPalette = Palette.from(Util.drawableToBitmap(icon)).generate(); + marker.setTint(colorPalette.getDominantSwatch().getBodyTextColor()); + + LayerDrawable layerDrawable = new LayerDrawable(new Drawable[]{icon, marker}); + layerDrawable.setLayerGravity(1, Gravity.CENTER); + + viewHolder.icon.setImageDrawable(layerDrawable); + } + } + + private void removeItem(View view, int position, JiveItem item) { + final AnimationSet animationSet = new AnimationSet(true); + animationSet.addAnimation(new ScaleAnimation(1F, 1F, 1F, 0.5F)); + animationSet.addAnimation(new AlphaAnimation(1F, 0F)); + animationSet.setDuration(ANIMATION_DURATION); + animationSet.setAnimationListener(new AnimationEndListener() { + @Override + public void onAnimationEnd(Animation animation) { + activity.getItemAdapter().removeItem(position); + UndoBarController.show(activity, activity.getString(R.string.JIVE_POPUP_REMOVING_FROM_PLAYLIST, item.getName()), new UndoBarController.UndoListener() { + @Override + public void onUndo() { + activity.getItemAdapter().insertItem(position, item); + } + + @Override + public void onDone() { + ISqueezeService service = activity.getService(); + if (service != null) { + service.playlistRemove(position); + activity.skipPlaylistChanged(); + } + } + }); + } + }); + + view.startAnimation(animationSet); + + + } + + @Override + public boolean isSelectable(JiveItem item) { + return true; + } + + /** + * Jumps to whichever song the user chose. + */ + @Override + public boolean onItemSelected(View view, int index, JiveItem item) { + ISqueezeService service = getActivity().getService(); + if (service != null) { + getActivity().getService().playlistIndex(index); + } + return false; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java new file mode 100644 index 000000000..6dfc5d60d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeActivity.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; +import androidx.preference.PreferenceManager; +import androidx.annotation.MainThread; +import androidx.appcompat.app.ActionBar; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.dialog.ChangeLogDialog; +import uk.org.ngo.squeezer.dialog.TipsDialog; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; + +public class HomeActivity extends HomeMenuActivity { + + @Override + public void onCreate(Bundle savedInstanceState) { + getIntent().putExtra(JiveItem.class.getName(), JiveItem.HOME); + super.onCreate(savedInstanceState); + + // Turn off the home icon. + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + } + + PreferenceManager.setDefaultValues(this, R.xml.preferences, false); + + // Show the change log if necessary. + ChangeLogDialog changeLog = new ChangeLogDialog(this); + if (changeLog.isFirstRun()) { + if (changeLog.isFirstRunEver()) { + changeLog.skipLogDialog(); + } else { + changeLog.getThemedLogDialog().show(); + } + } + } + + @MainThread + public void onEventMainThread(HandshakeComplete event) { + super.onEventMainThread(event); + + // Show a tip about volume controls, if this is the first time this app + // has run. TODO: Add more robust and general 'tips' functionality. + PackageInfo pInfo; + try { + final SharedPreferences preferences = getSharedPreferences(Preferences.NAME, + 0); + + pInfo = getPackageManager().getPackageInfo(getPackageName(), + PackageManager.GET_META_DATA); + if (preferences.getLong("lastRunVersionCode", 0) == 0) { + new TipsDialog().show(getSupportFragmentManager(), "TipsDialog"); + SharedPreferences.Editor editor = preferences.edit(); + editor.putLong("lastRunVersionCode", pInfo.versionCode); + editor.apply(); + } + } catch (PackageManager.NameNotFoundException e) { + // Nothing to do, don't crash. + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + public static void show(Context context) { + Intent intent = new Intent(context, HomeActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + if (!(context instanceof Activity)) + intent = intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + context.startActivity(intent); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java new file mode 100644 index 000000000..664563688 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/HomeMenuActivity.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + + +import android.app.Activity; +import android.content.Intent; +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.Window; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.HomeMenuEvent; + +public class HomeMenuActivity extends JiveItemListActivity { + + @Override + protected void orderPage(@NonNull ISqueezeService service, int start) { + // Do nothing we get the home menu from the sticky HomeMenuEvent + } + + @Override + public ArtworkListLayout getPreferredListLayout() { + return new Preferences(this).getHomeMenuLayout(); + } + + @Override + protected void saveListLayout(ArtworkListLayout listLayout) { + new Preferences(this).setHomeMenuLayout(listLayout); + } + + public void onEvent(HomeMenuEvent event) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (parent.window == null) { + applyWindowStyle(Window.WindowStyle.HOME_MENU); + } + if (parent != JiveItem.HOME && window.text == null) { + updateHeader(parent); + } + clearItemAdapter(); + } + }); + List menu = getMenuNode(parent.getId(), event.menuItems); + onItemsReceived(menu.size(), 0, menu, JiveItem.class); + } + + private List getMenuNode(String node, List homeMenu) { + ArrayList menu = new ArrayList<>(); + for (JiveItem item : homeMenu) { + if (node.equals(item.getNode())) { + menu.add(item); + } + } + Collections.sort(menu, new Comparator() { + @Override + public int compare(JiveItem o1, JiveItem o2) { + if (o1.getWeight() == o2.getWeight()) { + return o1.getName().compareTo(o2.getName()); + } + return o1.getWeight() - o2.getWeight(); + } + }); + return menu; + } + + public static void show(Activity activity, JiveItem item) { + final Intent intent = new Intent(activity, HomeMenuActivity.class); + intent.putExtra(JiveItem.class.getName(), item); + activity.startActivity(intent); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java new file mode 100644 index 000000000..e2f9bc99c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/IServiceItemListCallback.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.service.ServiceCallback; + +public interface IServiceItemListCallback extends ServiceCallback { + void onItemsReceived(int count, int start, Map parameters, List items, Class dataType); +} + diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java new file mode 100644 index 000000000..0117e97c8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemListActivity.java @@ -0,0 +1,555 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.app.Activity; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Bundle; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.AbsListView; +import android.widget.EditText; +import android.widget.GridView; +import android.widget.TextView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.NowPlayingActivity; +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.dialog.NetworkErrorDialogFragment; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.framework.BaseListActivity; +import uk.org.ngo.squeezer.framework.ItemView; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.Window; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.util.ImageFetcher; + +import static com.google.common.base.Preconditions.checkNotNull; + +/* + * The activity's content view scrolls in from the right, and disappear to the left, to provide a + * spatial component to navigation. + */ +public class JiveItemListActivity extends BaseListActivity + implements NetworkErrorDialogFragment.NetworkErrorDialogListener { + private static final int GO = 1; + private static final String FINISH = "FINISH"; + private static final String RELOAD = "RELOAD"; + + private JiveItemViewLogic pluginViewDelegate; + private boolean register; + protected JiveItem parent; + private Action action; + Window window = new Window(); + + private MenuItem menuItemList; + private MenuItem menuItemGrid; + private BaseItemView.ViewHolder parentViewHolder; + + @Override + protected ItemView createItemView() { + return new JiveItemView(this, window.windowStyle); + } + + @Override + public JiveItemView getItemView() { + return (JiveItemView) super.getItemView(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle extras = checkNotNull(getIntent().getExtras(), "intent did not contain extras"); + register = extras.getBoolean("register"); + parent = extras.getParcelable(JiveItem.class.getName()); + action = extras.getParcelable(Action.class.getName()); + + pluginViewDelegate = new JiveItemViewLogic(this); + setParentViewHolder(); + + // If initial setup is performed, use it + if (savedInstanceState != null && savedInstanceState.containsKey("window")) { + applyWindow((Window) savedInstanceState.getParcelable("window")); + } else { + if (parent != null && parent.window != null) { + applyWindow(parent.window); + } else if (parent != null && "playlist".equals(parent.getType())) { + // special case of playlist - override server based windowStyle to play_list + applyWindowStyle(Window.WindowStyle.PLAY_LIST); + } else + applyWindowStyle(Window.WindowStyle.TEXT_ONLY); + } + + findViewById(R.id.input_view).setVisibility((hasInputField()) ? View.VISIBLE : View.GONE); + if (hasInputField()) { + MaterialButton inputButton = findViewById(R.id.input_button); + final EditText inputText = findViewById(R.id.plugin_input); + TextInputLayout inputTextLayout = findViewById(R.id.plugin_input_til); + int inputType = EditorInfo.TYPE_CLASS_TEXT; + int inputImage = R.drawable.keyboard_return; + + switch (action.getInputType()) { + case TEXT: + break; + case SEARCH: + inputImage = R.drawable.ic_menu_search; + break; + case EMAIL: + inputType |= EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + break; + case PASSWORD: + inputType |= EditorInfo.TYPE_TEXT_VARIATION_PASSWORD; + break; + } + inputText.setInputType(inputType); + inputButton.setIconResource(inputImage); + inputTextLayout.setHint(parent.input.title); + inputText.setText(parent.input.initialText); + inputText.setOnKeyListener((v, keyCode, event) -> { + if ((event.getAction() == KeyEvent.ACTION_DOWN) + && (keyCode == KeyEvent.KEYCODE_ENTER)) { + clearAndReOrderItems(inputText.getText().toString()); + return true; + } + return false; + }); + + inputButton.setOnClickListener(v -> { + if (getService() != null) { + clearAndReOrderItems(inputText.getText().toString()); + } + }); + } + } + + private void setParentViewHolder() { + parentViewHolder = new BaseItemView.ViewHolder(this.findViewById(R.id.parent_container)); + parentViewHolder.contextMenuButton.setOnClickListener(v -> pluginViewDelegate.showContextMenu(parentViewHolder, parent)); + parentViewHolder.contextMenuButtonHolder.setTag(parentViewHolder); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable("window", window); + } + + @Override + public void onResume() { + super.onResume(); + ArtworkListLayout listLayout = JiveItemView.listLayout(this, window.windowStyle); + AbsListView listView = getListView(); + if ((listLayout == ArtworkListLayout.grid && !(listView instanceof GridView)) + || (listLayout != ArtworkListLayout.grid && (listView instanceof GridView))) { + setListView(setupListView(listView)); + } + } + + @Override + public void onPause() { + super.onPause(); + getItemView().getLogicDelegate().resetContextMenu(); + pluginViewDelegate.resetContextMenu(); + } + + @Override + protected AbsListView setupListView(AbsListView listView) { + ArtworkListLayout listLayout = JiveItemView.listLayout(this, window.windowStyle); + if (listLayout == ArtworkListLayout.grid && !(listView instanceof GridView)) { + listView = switchListView(listView, R.layout.item_grid); + } + if (listLayout != ArtworkListLayout.grid && (listView instanceof GridView)) { + listView = switchListView(listView, R.layout.item_list); + } + return super.setupListView(listView); + } + + private AbsListView switchListView(AbsListView listView, @LayoutRes int resource) { + ViewGroup parent = (ViewGroup) listView.getParent(); + int i1 = parent.indexOfChild(listView); + parent.removeViewAt(i1); + listView = (AbsListView) getLayoutInflater().inflate(resource, parent, false); + parent.addView(listView, i1); + return listView; + } + + void updateHeader(String windowTitle) { + window.text = windowTitle; + + parentViewHolder.itemView.setVisibility(View.VISIBLE); + parentViewHolder.text1.setText(windowTitle); + parentViewHolder.icon.setVisibility(View.GONE); + parentViewHolder.contextMenuButtonHolder.setVisibility(View.GONE); + } + + void updateHeader(JiveItem parent) { + updateHeader(parent.getName()); + + if (parent.hasArtwork() && window.windowStyle == Window.WindowStyle.TEXT_ONLY) { + parentViewHolder.text2.setVisibility(View.VISIBLE); + parentViewHolder.text2.setText(parent.text2); + + parentViewHolder.icon.setVisibility(View.VISIBLE); + ImageFetcher.getInstance(this).loadImage(parent.getIcon(), parentViewHolder.icon); + } + if (parent.hasContextMenu()) { + parentViewHolder.contextMenuButtonHolder.setVisibility(View.VISIBLE); + } + + } + + private void updateHeader(@NonNull Window window) { + if (!TextUtils.isEmpty(window.text)) { + updateHeader(window.text); + } + if (!TextUtils.isEmpty(window.textarea)) { + TextView header = findViewById(R.id.sub_header); + header.setText(window.textarea); + findViewById(R.id.sub_header_container).setVisibility(View.VISIBLE); + } + } + + private void applyWindow(@NonNull Window window) { + applyWindowStyle(register ? Window.WindowStyle.TEXT_ONLY : window.windowStyle); + updateHeader(window); + + window.titleStyle = this.window.titleStyle; + window.text = this.window.text; + this.window = window; + } + + + void applyWindowStyle(Window.WindowStyle windowStyle) { + applyWindowStyle(windowStyle, getItemView().listLayout()); + } + + void applyWindowStyle(Window.WindowStyle windowStyle, ArtworkListLayout prevListLayout) { + ArtworkListLayout listLayout = JiveItemView.listLayout(this, windowStyle); + updateViewMenuItems(listLayout, windowStyle); + if (windowStyle != window.windowStyle || listLayout != getItemView().listLayout()) { + window.windowStyle = windowStyle; + getItemView().setWindowStyle(windowStyle); + getItemAdapter().notifyDataSetChanged(); + } + if (listLayout != prevListLayout) { + setListView(setupListView(getListView())); + } + } + + + private void clearAndReOrderItems(String inputString) { + if (getService() != null && !TextUtils.isEmpty(inputString)) { + parent.inputValue = inputString; + clearAndReOrderItems(); + } + } + + private boolean hasInputField() { + return parent != null && parent.hasInputField(); + } + + @Override + protected boolean needPlayer() { + // Most of the the times we actually do need a player, but if we need to register on SN, + // it is before we can get the players + return !register; + } + + @Override + protected void orderPage(@NonNull ISqueezeService service, int start) { + if (parent != null) { + if (action == null || (parent.hasInput() && !parent.isInputReady())) { + showContent(); + } else + service.pluginItems(start, parent, action, this); + } else if (register) { + service.register(this); + } + } + + public void onEventMainThread(HandshakeComplete event) { + super.onEventMainThread(event); + if (parent != null && parent.hasSubItems()) { + getItemAdapter().update(parent.subItems.size(), 0, parent.subItems); + } + } + + @Override + public void onItemsReceived(int count, int start, final Map parameters, List items, Class dataType) { + if (parameters.containsKey("goNow")) { + Action.NextWindow nextWindow = Action.NextWindow.fromString(Util.getString(parameters, "goNow")); + switch (nextWindow.nextWindow) { + case nowPlaying: + NowPlayingActivity.show(this); + break; + case playlist: + CurrentPlaylistActivity.show(this); + break; + case home: + HomeActivity.show(this); + break; + } + finish(); + return; + } + + final Window window = JiveItem.extractWindow(Util.getRecord(parameters, "window"), null); + if (window != null) { + // override server based icon_list style for playlist + if (window.windowStyle == Window.WindowStyle.ICON_LIST && parent != null && "playlist".equals(parent.getType())) { + window.windowStyle = Window.WindowStyle.PLAY_LIST; + } + runOnUiThread(() -> applyWindow(window)); + } + + if (this.window.text == null && parent != null) { + runOnUiThread(() -> updateHeader(parent)); + } + + // The documentation says "Returned with value 1 if there was a network error accessing + // the content source.". In practice (with at least the Napster and Pandora plugins) the + // value is an error message suitable for displaying to the user. + if (parameters.containsKey("networkerror")) { + Resources resources = getResources(); + ISqueezeService service = getService(); + String playerName; + + if (service == null) { + playerName = "Unknown"; + } else { + playerName = service.getActivePlayer().getName(); + } + + String errorMsg = Util.getString(parameters, "networkerror"); + + String errorMessage = String.format(resources.getString(R.string.server_error), + playerName, errorMsg); + NetworkErrorDialogFragment networkErrorDialogFragment = + NetworkErrorDialogFragment.newInstance(errorMessage); + networkErrorDialogFragment.show(getSupportFragmentManager(), "networkerror"); + } + + super.onItemsReceived(count, start, parameters, items, dataType); + } + + @Override + public void action(JiveItem item, Action action, int alreadyPopped) { + if (getService() == null) { + return; + } + + if (action != null) { + getService().action(item, action); + } + + Action.JsonAction jAction = (action != null && action.action != null) ? action.action : null; + Action.NextWindow nextWindow = (jAction != null ? jAction.nextWindow : item.nextWindow); + nextWindow(nextWindow, alreadyPopped); + } + + @Override + public void action(Action.JsonAction action, int alreadyPopped) { + if (getService() == null) { + return; + } + + getService().action(action); + nextWindow(action.nextWindow, alreadyPopped); + } + + private void nextWindow(Action.NextWindow nextWindow, int alreadyPopped) { + while (alreadyPopped > 0 && nextWindow != null) { + nextWindow = popNextWindow(nextWindow); + alreadyPopped--; + } + if (nextWindow != null) { + switch (nextWindow.nextWindow) { + case nowPlaying: + // Do nothing as now playing is always available in Squeezer (maybe toast the action) + break; + case playlist: + CurrentPlaylistActivity.show(this); + break; + case home: + HomeActivity.show(this); + break; + case parentNoRefresh: + finish(); + break; + case grandparent: + setResult(Activity.RESULT_OK, new Intent(FINISH)); + finish(); + break; + case refresh: + clearAndReOrderItems(); + break; + case parent: + case refreshOrigin: + setResult(Activity.RESULT_OK, new Intent(RELOAD)); + finish(); + break; + case windowId: + //TODO implement + break; + } + } + } + + private Action.NextWindow popNextWindow(Action.NextWindow nextWindow) { + switch (nextWindow.nextWindow) { + case parent: + case parentNoRefresh: + return null; + case grandparent: + return new Action.NextWindow(Action.NextWindowEnum.parentNoRefresh); + case refreshOrigin: + return new Action.NextWindow(Action.NextWindowEnum.refresh); + default: + return nextWindow; + + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == GO) { + if (resultCode == RESULT_OK) { + if (FINISH.equals(data.getAction())) { + finish(); + } else if (RELOAD.equals(data.getAction())) { + clearAndReOrderItems(); + } + } + } + } + + public void setPreferredListLayout(ArtworkListLayout listLayout) { + ArtworkListLayout prevListLayout = getItemView().listLayout(); + saveListLayout(listLayout); + applyWindowStyle(window.windowStyle, prevListLayout); + } + + protected void saveListLayout(ArtworkListLayout listLayout) { + new Preferences(this).setAlbumListLayout(listLayout); + } + + /** + * The user dismissed the network error dialog box. There's nothing more to do, so finish + * the activity. + */ + @Override + public void onDialogDismissed(DialogInterface dialog) { + runOnUiThread(this::finish); + } + + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.pluginlistmenu, menu); + menuItemList = menu.findItem(R.id.menu_item_list); + menuItemGrid = menu.findItem(R.id.menu_item_grid); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + updateViewMenuItems(getPreferredListLayout(), window.windowStyle); + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_item_list: + setPreferredListLayout(ArtworkListLayout.list); + return true; + case R.id.menu_item_grid: + setPreferredListLayout(ArtworkListLayout.grid); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void updateViewMenuItems(ArtworkListLayout listLayout, Window.WindowStyle windowStyle) { + boolean canChangeListLayout = JiveItemView.canChangeListLayout(windowStyle); + if (menuItemList != null) { + menuItemList.setVisible(canChangeListLayout && listLayout != ArtworkListLayout.list); + menuItemGrid.setVisible(canChangeListLayout && listLayout != ArtworkListLayout.grid); + } + } + + + public static void register(Activity activity) { + final Intent intent = new Intent(activity, JiveItemListActivity.class); + intent.putExtra("register", true); + activity.startActivity(intent); + } + + /** + * Start a new {@link JiveItemListActivity} to perform the supplied action. + *

+ * If the action requires input, we initially get the input. + *

+ * When input is ready or the action does not require input, items are ordered asynchronously + * via {@link ISqueezeService#pluginItems(int, JiveItem, Action, IServiceItemListCallback)} + * + * @see #orderPage(ISqueezeService, int) + */ + public static void show(Activity activity, JiveItem parent, Action action) { + final Intent intent = getPluginListIntent(activity); + intent.putExtra(JiveItem.class.getName(), parent); + intent.putExtra(Action.class.getName(), action); + activity.startActivityForResult(intent, GO); + } + + public static void show(Activity activity, JiveItem item) { + final Intent intent = getPluginListIntent(activity); + intent.putExtra(JiveItem.class.getName(), item); + activity.startActivityForResult(intent, GO); + } + + @NonNull + private static Intent getPluginListIntent(Activity activity) { + Intent intent = new Intent(activity, JiveItemListActivity.class); + if (activity instanceof JiveItemListActivity && ((JiveItemListActivity)activity).register) { + intent.putExtra("register", true); + } + return intent; + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java new file mode 100644 index 000000000..a5bef1cfd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemView.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; + +import java.util.EnumSet; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.framework.BaseListActivity; +import uk.org.ngo.squeezer.framework.ItemListActivity; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.Slider; +import uk.org.ngo.squeezer.model.Window; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkListLayout; +import uk.org.ngo.squeezer.util.ImageFetcher; + +public class JiveItemView extends BaseItemView { + private final JiveItemViewLogic logicDelegate; + private Window.WindowStyle windowStyle; + + /** Width of the icon, if VIEW_PARAM_ICON is used. */ + private int mIconWidth; + + /** Height of the icon, if VIEW_PARAM_ICON is used. */ + private int mIconHeight; + + JiveItemView(BaseListActivity activity, Window.WindowStyle windowStyle) { + super(activity); + setWindowStyle(windowStyle); + this.logicDelegate = new JiveItemViewLogic(activity); + setLoadingViewParams(viewParamIcon() | VIEW_PARAM_TWO_LINE ); + } + + JiveItemViewLogic getLogicDelegate() { + return logicDelegate; + } + + void setWindowStyle(Window.WindowStyle windowStyle) { + this.windowStyle = windowStyle; + if (listLayout() == ArtworkListLayout.grid) { + mIconWidth = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_grid_width); + mIconHeight = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_grid_height); + } else { + mIconWidth = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_width); + mIconHeight = getActivity().getResources().getDimensionPixelSize(R.dimen.album_art_icon_height); + } + } + + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, final JiveItem item, boolean selected) { + if (item.radio != null) { + item.radio = selected; + } + if (item.hasSlider()) { + return sliderView(parent, item); + } else { + @ViewParam int viewParams = (viewParamIcon() | VIEW_PARAM_TWO_LINE | viewParamContext(item)); + View view = getAdapterView(convertView, parent, position, viewParams); + bindView(view, item); + return view; + } + } + + private View sliderView(ViewGroup parent, final JiveItem item) { + View view = getLayoutInflater().inflate(R.layout.slider_item, parent, false); + final TextView sliderValue = view.findViewById(R.id.slider_value); + SeekBar seekBar = view.findViewById(R.id.slider); + final int thumbWidth = seekBar.getThumb().getIntrinsicWidth(); + final int thumbOffset = seekBar.getThumbOffset(); + final Slider slider = item.slider; + final int max = seekBar.getMax(); + seekBar.setProgress((slider.initial - slider.min) * max / (slider.max - slider.min)); + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + sliderValue.setText(String.valueOf(getValue(progress))); + + int pos = progress * (seekBar.getWidth() - 2 * thumbWidth) / seekBar.getMax(); + sliderValue.setX(seekBar.getX() + pos + thumbOffset + thumbWidth); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + sliderValue.setVisibility(View.VISIBLE); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (item.goAction != null) { + sliderValue.setVisibility(View.INVISIBLE); + item.inputValue = String.valueOf(getValue(seekBar.getProgress())); + getActivity().action(item, item.goAction); + } + } + + private int getValue(int progress) { + return slider.min + (slider.max - slider.min) * progress / max; + } + }); + return view; + } + + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) { + return getAdapterView(convertView, parent, position, viewParams, layoutResource()); + } + + @LayoutRes private int layoutResource() { + return (listLayout() == ArtworkListLayout.grid) ? R.layout.grid_item : R.layout.list_item; + } + + ArtworkListLayout listLayout() { + return listLayout(getActivity(), windowStyle); + } + + static ArtworkListLayout listLayout(ItemListActivity activity, Window.WindowStyle windowStyle) { + if (canChangeListLayout(windowStyle)) { + return activity.getPreferredListLayout(); + } + return ArtworkListLayout.list; + } + + static boolean canChangeListLayout(Window.WindowStyle windowStyle) { + return EnumSet.of(Window.WindowStyle.HOME_MENU, Window.WindowStyle.ICON_LIST).contains(windowStyle); + } + + private int viewParamIcon() { + return windowStyle == Window.WindowStyle.TEXT_ONLY ? 0 : VIEW_PARAM_ICON; + } + + private int viewParamContext(JiveItem item) { + return item.hasContextMenu() ? VIEW_PARAM_CONTEXT_BUTTON : 0; + } + + @Override + public void bindView(View view, JiveItem item) { + super.bindView(view, item); + ViewHolder viewHolder = (ViewHolder) view.getTag(); + + viewHolder.text2.setText(item.text2); + + // If the item has an image, then fetch and display it + if (item.hasArtwork()) { + ImageFetcher.getInstance(getActivity()).loadImage( + item.getIcon(), + viewHolder.icon, + mIconWidth, + mIconHeight, + () -> onIcon(viewHolder) + + ); + } else { + viewHolder.icon.setImageDrawable(item.getIconDrawable(getActivity())); + onIcon(viewHolder); + } + + if (item.hasContextMenu()) { + viewHolder.contextMenuButton.setVisibility(item.checkbox == null && item.radio == null ? View.VISIBLE : View.GONE); + viewHolder.contextMenuCheckbox.setVisibility(item.checkbox != null ? View.VISIBLE : View.GONE); + viewHolder.contextMenuRadio.setVisibility(item.radio != null ? View.VISIBLE : View.GONE); + if (item.checkbox != null) { + viewHolder.contextMenuCheckbox.setChecked(item.checkbox); + } else if (item.radio != null) { + viewHolder.contextMenuRadio.setChecked(item.radio); + } + } + } + + protected void onIcon(ViewHolder viewHolder) { + } + + @Override + public boolean isSelectable(JiveItem item) { + return item.isSelectable(); + } + + @Override + public boolean isSelected(JiveItem item) { + return item.radio != null && item.radio; + } + + @Override + public boolean onItemSelected(View view, int index, JiveItem item) { + Action.JsonAction action = (item.goAction != null && item.goAction.action != null) ? item.goAction.action : null; + Action.NextWindow nextWindow = (action != null ? action.nextWindow : item.nextWindow); + if (item.checkbox != null) { + item.checkbox = !item.checkbox; + Action checkboxAction = item.checkboxActions.get(item.checkbox); + if (checkboxAction != null) { + getActivity().action(item, checkboxAction); + } + ViewHolder viewHolder = (ViewHolder) view.getTag(); + viewHolder.contextMenuCheckbox.setChecked(item.checkbox); + } else if (nextWindow != null && !item.hasInput()) { + getActivity().action(item, item.goAction); + } else { + if (item.goAction != null) + logicDelegate.execGoAction((ViewHolder) view.getTag(), item, 0); + else if (item.hasSubItems()) + JiveItemListActivity.show(getActivity(), item); + else if (item.getNode() != null) { + HomeMenuActivity.show(getActivity(), item); + } + } + + return (item.radio != null); + } + + @Override + public void showContextMenu(ViewHolder viewHolder, JiveItem item) { + logicDelegate.showContextMenu(viewHolder, item); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java new file mode 100644 index 000000000..d2a485964 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/JiveItemViewLogic.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.app.Activity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; + +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.itemlist.dialog.ArtworkDialog; +import uk.org.ngo.squeezer.itemlist.dialog.ChoicesDialog; +import uk.org.ngo.squeezer.itemlist.dialog.InputTextDialog; +import uk.org.ngo.squeezer.itemlist.dialog.InputTimeDialog; +import uk.org.ngo.squeezer.itemlist.dialog.SlideShow; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ISqueezeService; + +/** + * Delegate with view logic for {@link JiveItem} which can be used from any {@link BaseActivity} + */ +public class JiveItemViewLogic implements IServiceItemListCallback, PopupMenu.OnDismissListener { + private final BaseActivity activity; + + public JiveItemViewLogic(BaseActivity activity) { + this.activity = activity; + } + + /** + * Perform the go action of the supplied item. + *

+ * If this is a do action and it doesn't require input, it is performed immediately + * by calling {@link BaseActivity#action(JiveItem, Action) }. + *

+ * Otherwise we pass the action to a sub activity (window in slim terminology) which + * collects the input if required and performs the action. See {@link JiveItemListActivity#show(Activity, JiveItem, Action)} + *

+ * Finally if the (unsupported) "showBigArtwork" flag is present in an item the do + * action will return an artwork id or URL, which can be used the fetch an image to display in a + * popup. See {@link ArtworkDialog#show(BaseActivity, Action)} + */ + void execGoAction(BaseItemView.ViewHolder viewHolder, JiveItem item, int alreadyPopped) { + if (item.showBigArtwork) { + ArtworkDialog.show(activity, item.goAction); + } else if (item.goAction.isSlideShow()) { + SlideShow.show(activity, item.goAction); + } else if (item.goAction.isContextMenu()) { + showContextMenu(viewHolder, item, item.goAction); + } else if (item.doAction) { + if (item.hasInput()) { + if (item.hasChoices()) { + ChoicesDialog.show(activity, item, alreadyPopped); + } else if ("time".equals(item.input.inputStyle)) { + InputTimeDialog.show(activity, item, alreadyPopped); + } else { + InputTextDialog.show(activity, item, alreadyPopped); + } + } else { + activity.action(item, item.goAction, alreadyPopped); + } + } else { + JiveItemListActivity.show(activity, item, item.goAction); + } + } + + // Only touch these from the main thread + private int contextStack = 0; + private JiveItem contextMenuItem; + private PopupMenu contextPopup; + private BaseItemView.ViewHolder contextMenuViewHolder; + + public void showContextMenu(BaseItemView.ViewHolder viewHolder, JiveItem item) { + if (item.moreAction != null) { + showContextMenu(viewHolder, item, item.moreAction); + } else { + showStandardContextMenu(viewHolder.contextMenuButtonHolder, item); + } + } + + private void showContextMenu(BaseItemView.ViewHolder viewHolder, JiveItem item, Action action) { + contextMenuViewHolder = viewHolder; + contextStack = 1; + contextMenuItem = item; + orderContextMenu(action); + } + + private void showStandardContextMenu(View v, final JiveItem item) { + contextPopup = new PopupMenu(activity, v); + Menu menu = contextPopup.getMenu(); + + if (item.playAction != null) { + menu.add(Menu.NONE, R.id.play_now, Menu.NONE, R.string.PLAY_NOW); + } + if (item.addAction != null) { + menu.add(Menu.NONE, R.id.add_to_playlist, Menu.NONE, R.string.ADD_TO_END); + } + if (item.insertAction != null) { + menu.add(Menu.NONE, R.id.play_next, Menu.NONE, R.string.PLAY_NEXT); + } + if (item.moreAction != null) { + menu.add(Menu.NONE, R.id.more, Menu.NONE, R.string.MORE); + } + + contextPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + return doStandardItemContext(menuItem, item); + } + }); + contextPopup.setOnDismissListener(this); + contextPopup.show(); + } + + private boolean doStandardItemContext(MenuItem menuItem, JiveItem item) { + switch (menuItem.getItemId()) { + case R.id.play_now: + activity.action(item, item.playAction); + return true; + case R.id.add_to_playlist: + activity.action(item, item.addAction); + return true; + case R.id.play_next: + activity.action(item, item.insertAction); + return true; + case R.id.more: + JiveItemListActivity.show(activity, item, item.moreAction); + return true; + } + return false; + } + + private void showContextMenu(final BaseItemView.ViewHolder viewHolder, final List items) { + Preferences preferences = new Preferences(activity); + contextPopup = new PopupMenu(activity, viewHolder.contextMenuButtonHolder); + Menu menu = contextPopup.getMenu(); + + int index = 0; + if (preferences.isDownloadEnabled() && contextMenuItem != null && contextMenuItem.canDownload()) { + menu.add(Menu.NONE, index++, Menu.NONE, R.string.DOWNLOAD); + } + final int offset = index; + for (JiveItem jiveItem : items) { + menu.add(Menu.NONE, index++, Menu.NONE, jiveItem.getName()).setEnabled(jiveItem.goAction != null); + } + + contextPopup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + if (menuItem.getItemId() < offset) { + activity.downloadItem(contextMenuItem); + } else { + doItemContext(viewHolder, items.get(menuItem.getItemId() - offset)); + } + return true; + } + }); + contextPopup.setOnDismissListener(this); + contextPopup.show(); + } + + private void doItemContext(BaseItemView.ViewHolder viewHolder, JiveItem item) { + Action.NextWindow nextWindow = (item.goAction != null ? item.goAction.action.nextWindow : item.nextWindow); + if (nextWindow != null) { + activity.action(item, item.goAction, contextStack); + } else { + execGoAction(viewHolder, item, contextStack); + } + } + + private void orderContextMenu(Action action) { + ISqueezeService service = activity.getService(); + if (service != null) { + contextMenuViewHolder.contextMenuButton.setVisibility(View.GONE); + contextMenuViewHolder.contextMenuLoading.setVisibility(View.VISIBLE); + service.pluginItems(action, this); + } + } + + @Override + public Object getClient() { + return activity; + } + + @Override + public void onItemsReceived(int count, int start, final Map parameters, final List items, Class dataType) { + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + // If #resetContextMenu has been called while we were in the main looper #contextMenuViewHolder will be null, so skip the items + if (contextMenuViewHolder != null) { + contextMenuViewHolder.contextMenuButton.setVisibility(View.VISIBLE); + contextMenuViewHolder.contextMenuLoading.setVisibility(View.GONE); + showContextMenu(contextMenuViewHolder, items); + } + } + }); + } + + public void resetContextMenu() { + if (contextMenuViewHolder != null) { + contextMenuViewHolder.contextMenuButton.setVisibility(View.VISIBLE); + contextMenuViewHolder.contextMenuLoading.setVisibility(View.GONE); + } + + if (contextPopup != null) { + contextPopup.dismiss(); + contextPopup = null; + } + + contextStack = 0; + contextMenuViewHolder = null; + } + + @Override + public void onDismiss(PopupMenu menu) { + contextPopup = null; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java new file mode 100644 index 000000000..a396aa7b9 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/ListDragListener.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2020 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.DragEvent; +import android.view.View; +import android.widget.AbsListView; +import android.widget.AdapterView; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.R; + +class ListDragListener extends Handler implements View.OnDragListener { + private static final int MSG_SCROLL_ENDED = 1; + + private final CurrentPlaylistActivity activity; + private int viewPosition; + private int itemPosition; + private boolean scrolling; + private int scrollSpeed; + private float lastMoveY = -1; + + public ListDragListener(CurrentPlaylistActivity activity) { + super(Looper.getMainLooper()); + this.activity = activity; + } + + @Override + public boolean onDrag(View v, DragEvent event) { + switch(event.getAction()) { + + case DragEvent.ACTION_DRAG_STARTED: + itemPosition = viewPosition = getPosition(event); + return true; + + case DragEvent.ACTION_DRAG_ENTERED: + return true; + + case DragEvent.ACTION_DRAG_LOCATION: { + int position = getPosition(event); + // Move the highlighted song if necessary + // Don't move to a position that is not yet loaded, because it will be overridden on load. + if (position != AdapterView.INVALID_POSITION && position != viewPosition && activity.getItemAdapter().getItem(position) != null) { + // Prevent moving back if we have just swapped the current drag item with a taller item + if (lastMoveY == -1 || !((event.getY() > lastMoveY && position < viewPosition) || (event.getY() < lastMoveY && position > viewPosition))) { + int selectedIndex = activity.getItemAdapter().getSelectedIndex(); + if (selectedIndex == viewPosition) { + activity.getItemAdapter().setSelectedIndex(position); + } else if (viewPosition < selectedIndex && position >= selectedIndex) { + activity.getItemAdapter().setSelectedIndex(selectedIndex - 1); + } else if (viewPosition > selectedIndex && position <= selectedIndex) { + activity.getItemAdapter().setSelectedIndex(selectedIndex + 1); + } + + activity.getItemAdapter().moveItem(viewPosition, position); + activity.setDraggedIndex(position); + viewPosition = position; + lastMoveY = event.getY(); + } + } + setScrollSpeed(event); + return true; + } + case DragEvent.ACTION_DRAG_EXITED: + return true; + + case DragEvent.ACTION_DROP: { + return true; + } + case DragEvent.ACTION_DRAG_ENDED: + activity.setDraggedIndex(-1); + scrollSpeed = 0; + lastMoveY = -1; + if (viewPosition != itemPosition) { + activity.getService().playlistMove(itemPosition, viewPosition); + activity.skipPlaylistChanged(); + } + return true; + } + + return false; + } + + private void startScroll(int scrollSpeed) { + scrolling = true; + lastMoveY = -1; + int distance = activity.getResources().getDimensionPixelSize(R.dimen.playlist_scroll_distance); + int duration = 250 - Math.abs(scrollSpeed); + activity.getListView().smoothScrollBy(scrollSpeed < 0 ? -distance : distance, duration); + removeMessages(MSG_SCROLL_ENDED); + sendEmptyMessageDelayed(MSG_SCROLL_ENDED, duration-10); + } + + private void setScrollSpeed(DragEvent event) { + scrollSpeed = getScrollSpeed(event); + if (scrollSpeed != 0 && !scrolling) { + startScroll(scrollSpeed); + } + } + + private int getPosition(DragEvent event) { + AbsListView listView = activity.getListView(); + return listView.pointToPosition((int) (event.getX()), (int) (event.getY())); + } + + private int getScrollSpeed(DragEvent event) { + int perMille = (int) (event.getY() / activity.getListView().getHeight() * 1000); + return (perMille < 200 ? (perMille - 200) : perMille > 800 ? (perMille - 800) : 0); + } + + @Override + public void handleMessage(@NonNull Message msg) { + scrolling = false; + if (scrollSpeed != 0) { + startScroll(scrollSpeed); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java new file mode 100644 index 000000000..6947fadb1 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerBaseView.java @@ -0,0 +1,55 @@ +package uk.org.ngo.squeezer.itemlist; + +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.LayoutRes; + +import java.util.HashMap; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.BaseItemView; +import uk.org.ngo.squeezer.model.Player; + + +public abstract class PlayerBaseView extends BaseItemView { + private static final Map modelIcons = PlayerBaseView.initializeModelIcons(); + protected final A activity; + private @LayoutRes + int layoutResource; + + public PlayerBaseView(A activity, @LayoutRes int layoutResource) { + super(activity); + this.activity = activity; + this.layoutResource = layoutResource; + } + + private static Map initializeModelIcons() { + Map modelIcons = new HashMap<>(); + modelIcons.put("baby", R.drawable.ic_baby); + modelIcons.put("boom", R.drawable.ic_boom); + modelIcons.put("fab4", R.drawable.ic_fab4); + modelIcons.put("receiver", R.drawable.ic_receiver); + modelIcons.put("controller", R.drawable.ic_controller); + modelIcons.put("sb1n2", R.drawable.ic_sb1n2); + modelIcons.put("sb3", R.drawable.ic_sb3); + modelIcons.put("slimp3", R.drawable.ic_slimp3); + modelIcons.put("softsqueeze", R.drawable.ic_softsqueeze); + modelIcons.put("squeezeplay", R.drawable.ic_squeezeplay); + modelIcons.put("transporter", R.drawable.ic_transporter); + modelIcons.put("squeezeplayer", R.drawable.ic_squeezeplayer); + return modelIcons; + } + + protected static int getModelIcon(String model) { + Integer icon = modelIcons.get(model); + return (icon != null ? icon : R.drawable.ic_blank); + } + + @Override + public View getAdapterView(View convertView, ViewGroup parent, int position, @ViewParam int viewParams) { + return getAdapterView(convertView, parent, position, viewParams, layoutResource); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java new file mode 100644 index 000000000..8db6b65c5 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListActivity.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.os.Bundle; + +import androidx.annotation.NonNull; + +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.dialog.DefeatDestructiveTouchToPlayDialog; +import uk.org.ngo.squeezer.itemlist.dialog.PlayTrackAlbumDialog; +import uk.org.ngo.squeezer.itemlist.dialog.PlayerSyncDialog; +import uk.org.ngo.squeezer.model.Item; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.PlayerVolume; + + +public class PlayerListActivity extends PlayerListBaseActivity implements + PlayerSyncDialog.PlayerSyncDialogHost, + PlayTrackAlbumDialog.PlayTrackAlbumDialogHost, + DefeatDestructiveTouchToPlayDialog.DefeatDestructiveTouchToPlayDialogHost { + private static final String CURRENT_PLAYER = "currentPlayer"; + + private Player currentPlayer; + + @Override + protected void onSaveInstanceState(Bundle outState) { + outState.putParcelable(CURRENT_PLAYER, currentPlayer); + super.onSaveInstanceState(outState); + } + + @Override + protected boolean needPlayer() { + return false; + } + + + + public void onEventMainThread(PlayerVolume event) { + if (!mTrackingTouch) { + mResultsAdapter.notifyDataSetChanged(); + } + } + + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.item_list_players); + + if (savedInstanceState != null) + currentPlayer = savedInstanceState.getParcelable(PlayerListActivity.CURRENT_PLAYER); + } + + @Override + public Player getCurrentPlayer() { + return currentPlayer; + } + + public void setCurrentPlayer(Player currentPlayer) { + this.currentPlayer = currentPlayer; + } + + public void playerRename(String newName) { + ISqueezeService service = getService(); + if (service == null) { + return; + } + + service.playerRename(currentPlayer, newName); + this.currentPlayer.setName(newName); + mResultsAdapter.notifyDataSetChanged(); + } + + public PlayerBaseView createPlayerView() { + return new PlayerView(this); + } + + /** + * Synchronises the slave player to the player with masterId. + * + * @param slave the player to sync. + * @param masterId ID of the player to sync to. + */ + @Override + public void syncPlayerToPlayer(@NonNull Player slave, @NonNull String masterId) { + getService().syncPlayerToPlayer(slave, masterId); + } + + /** + * Removes the player from any sync groups. + * + * @param player the player to be removed from sync groups. + */ + @Override + public void unsyncPlayer(@NonNull Player player) { + getService().unsyncPlayer(player); + } + + @Override + public String getPlayTrackAlbum() { + return currentPlayer.getPlayerState().prefs.get(Player.Pref.PLAY_TRACK_ALBUM); + } + + @Override + public void setPlayTrackAlbum(@NonNull String option) { + getService().playerPref(currentPlayer, Player.Pref.PLAY_TRACK_ALBUM, option); + } + + @Override + public String getDefeatDestructiveTTP() { + return currentPlayer.getPlayerState().prefs.get(Player.Pref.DEFEAT_DESTRUCTIVE_TTP); + } + + @Override + public void setDefeatDestructiveTTP(@NonNull String option) { + getService().playerPref(currentPlayer, Player.Pref.DEFEAT_DESTRUCTIVE_TTP, option); + } + + @Override + protected void updateAdapter(int count, int start, List items, Class dataType) { + + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java new file mode 100644 index 000000000..0e23ef1c8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListAdapter.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; +import android.widget.TextView; + +import com.google.common.base.Joiner; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.ItemAdapter; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.Player; + +public class PlayerListAdapter extends BaseExpandableListAdapter { + private final PlayerListBaseActivity mActivity; + + private final List mChildAdapters = new ArrayList<>(); + + /** + * A list adapter for a synchronization group, containing players. + * This class is comparable and it has a name for the synchronization group. + */ + private class SyncGroup extends ItemAdapter implements Comparable { + + public String syncGroupName; // the name of the synchronization group as displayed in the players screen + + public SyncGroup(PlayerBaseView playerView) { + super(playerView); + } + + @Override + public int compareTo(Object otherSyncGroup) { + // compare this syncgroup name with the other one, alphabetically + return this.syncGroupName.compareToIgnoreCase(((SyncGroup)otherSyncGroup).syncGroupName); + } + + @Override + public void update(int count, int start, List syncedPlayersList) { + Collections.sort(syncedPlayersList); // first order players in syncgroup alphabetically + + // add the list + super.update(count, start, syncedPlayersList); + + // determine and set synchronization group name (player names divided by commas) + List playerNames = new ArrayList<>(); + for (int i = 0; i < this.getCount(); i++) { + Player p = this.getItem(i); + playerNames.add(p.getName()); + } + syncGroupName = Joiner.on(", ").join(playerNames); + } + + } + /** The last set of player sync groups that were provided. */ + private Multimap prevPlayerSyncGroups; + + /** Indicates if the list of players has changed. */ + boolean mPlayersChanged; + + /** Joins elements together with ' - ', skipping nulls. */ + private static final Joiner mJoiner = Joiner.on(" - ").skipNulls(); + + /** Count of how many players are in the adapter. */ + int mPlayerCount; + + public PlayerListAdapter(PlayerListBaseActivity activity) { + mActivity = activity; + } + + + public void onGroupClick(View view, int groupPosition) { + mChildAdapters.get(groupPosition).onSelected(view); + } + public void onChildClick(View view, int groupPosition, int childPosition) { + mChildAdapters.get(groupPosition).onItemSelected(view, childPosition); + } + + public void clear() { + mPlayersChanged = true; + mChildAdapters.clear(); + mPlayerCount = 0; + notifyDataSetChanged(); + } + + /** + * Sets the players in to the adapter. + * + * @param playerSyncGroups Multimap, mapping from the player ID of the syncmaster to the + * Players synced to that master. See + * {@link PlayerListActivity#updateSyncGroups(Collection)} for how this map is + * generated. + */ + void setSyncGroups(Multimap playerSyncGroups) { + // The players might not have changed (so there's no need to reset the contents of the + // adapter) but information about an individual player might have done. + if (prevPlayerSyncGroups != null && prevPlayerSyncGroups.equals(playerSyncGroups)) { + notifyDataSetChanged(); + return; + } + + prevPlayerSyncGroups = HashMultimap.create(playerSyncGroups); + clear(); + + // Get a list of slaves for every synchronization group + for (Collection slaves: playerSyncGroups.asMap().values()) { + // create a new synchronization group + SyncGroup syncGroup = new SyncGroup(mActivity.createPlayerView()); + mPlayerCount += slaves.size(); + // add the slaves (the players) to the synchronization group + syncGroup.update(slaves.size(), 0, new ArrayList<>(slaves)); + // add synchronization group to the child adapters + mChildAdapters.add(syncGroup); + } + Collections.sort(mChildAdapters); // sort syncgroup list alphabetically by syncgroup name + notifyDataSetChanged(); + } + + @Override + public boolean areAllItemsEnabled() { + return true; // Should be false, but then there is no divider + } + + @Override + public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { + return mChildAdapters.get(groupPosition).getView(childPosition, convertView, parent); + } + + @Override + public int getGroupCount() { + return mChildAdapters.size(); + } + + @Override + public int getChildrenCount(int groupPosition) { + return mChildAdapters.get(groupPosition).getCount(); + } + + @Override + public Object getGroup(int groupPosition) { + return mChildAdapters.get(groupPosition); + } + + @Override + public Object getChild(int groupPosition, int childPosition) { + return mChildAdapters.get(groupPosition).getItem(childPosition); + } + + /** + * Use the ID of the first player in the group as the identifier for the group. + *

+ * {@inheritDoc} + * @param groupPosition + * @return + */ + @Override + public long getGroupId(int groupPosition) { + return mChildAdapters.get(groupPosition).getItem(0).getIdAsLong(); + } + + @Override + public long getChildId(int groupPosition, int childPosition) { + return mChildAdapters.get(groupPosition).getItem(childPosition).getIdAsLong(); + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { + View row = mActivity.getLayoutInflater().inflate(R.layout.group_player, parent, false); + + TextView text1 = row.findViewById(R.id.text1); + TextView text2 = row.findViewById(R.id.text2); + + SyncGroup syncGroup = mChildAdapters.get(groupPosition); + String header = syncGroup.syncGroupName; + text1.setText(mActivity.getString(R.string.player_group_header, header)); + + CurrentPlaylistItem groupSong = syncGroup.getItem(0).getPlayerState().getCurrentSong(); + + if (groupSong != null) { + text2.setText(mJoiner.join(groupSong.getName(), groupSong.getArtist(), + groupSong.getAlbum())); + } + return row; + } + + @Override + public boolean isChildSelectable(int groupPosition, int childPosition) { + return false; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java new file mode 100644 index 000000000..404a9cc28 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerListBaseActivity.java @@ -0,0 +1,197 @@ +package uk.org.ngo.squeezer.itemlist; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.AbsListView; +import android.widget.ExpandableListView; + +import androidx.annotation.NonNull; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import uk.org.ngo.squeezer.framework.ItemListActivity; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.service.event.PlayerStateChanged; + + +public abstract class PlayerListBaseActivity extends ItemListActivity { + private static final String TAG = PlayerListBaseActivity.class.getName(); + + /** + * Map from player IDs to Players synced to that player ID. + */ + private final Multimap mPlayerSyncGroups = HashMultimap.create(); + protected boolean mTrackingTouch; + /** + * An update arrived while tracking touches. UI should be re-synced. + */ + protected boolean mUpdateWhileTracking = false; + PlayerListAdapter mResultsAdapter; + private ExpandableListView mResultsExpandableListView; + + public static void show(Context context) { + final Intent intent = new Intent(context, PlayerListActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + context.startActivity(intent); + } + + /** + * Updates the adapter with the current players, and ensures that the list view is + * expanded. + */ + protected void updateAndExpandPlayerList() { + // Can't do anything if the adapter hasn't been set (pre-handshake). + if (mResultsExpandableListView.getAdapter() == null) { + return; + } + + updateSyncGroups(getService().getPlayers()); + mResultsAdapter.setSyncGroups(mPlayerSyncGroups); + + for (int i = 0; i < mResultsAdapter.getGroupCount(); i++) { + mResultsExpandableListView.expandGroup(i); + } + } + + @Override + protected void onServiceConnected(@NonNull ISqueezeService service) { + super.onServiceConnected(service); + Log.d(TAG, "onServiceConnected: service.isConnected=" + service.isConnected()); + + if (!service.isConnected()) { + service.startConnect(); + } + } + + @Override + protected void orderPage(@NonNull ISqueezeService service, int start) { + // Do nothing -- the service has been tracking players from the time it + // initially connected to the server. + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mResultsAdapter = new PlayerListAdapter(this); + + setIgnoreVolumeChange(true); + } + + @Override + protected AbsListView setupListView(AbsListView listView) { + mResultsExpandableListView = (ExpandableListView) listView; + mResultsExpandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() { + public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) { + mResultsAdapter.onGroupClick(v, groupPosition); + return true; + } + }); + mResultsExpandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() { + @Override + public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, + int childPosition, long id) { + mResultsAdapter.onChildClick(v, groupPosition, childPosition); + return true; + } + }); + + mResultsExpandableListView.setOnScrollListener(new ScrollListener()); + + return listView; + } + + public void onEventMainThread(HandshakeComplete event) { + super.onEventMainThread(event); + if (mResultsExpandableListView.getExpandableListAdapter() == null) + mResultsExpandableListView.setAdapter(mResultsAdapter); + updateAndExpandPlayerList(); + } + + + public void onEventMainThread(PlayerStateChanged event) { + if (!mTrackingTouch) { + updateAndExpandPlayerList(); + } else { + mUpdateWhileTracking = true; + } + } + + public void setTrackingTouch(boolean trackingTouch) { + mTrackingTouch = trackingTouch; + if (!mTrackingTouch) { + if (mUpdateWhileTracking) { + mUpdateWhileTracking = false; + updateAndExpandPlayerList(); + } + } + } + + /** + * Builds the list of lists that is a sync group. + * + * @param players List of players. + */ + public void updateSyncGroups(Collection players) { + Map connectedPlayers = new HashMap<>(); + + // Make a copy of the players we know about, ignoring unconnected ones. + for (Player player : players) { + if (!player.getConnected()) + continue; + + connectedPlayers.put(player.getId(), player); + } + + mPlayerSyncGroups.clear(); + + // Iterate over all the connected players to build the list of master players. + for (Player player : connectedPlayers.values()) { + String playerId = player.getId(); + String name = player.getName(); + PlayerState playerState = player.getPlayerState(); + String syncMaster = playerState.getSyncMaster(); + + Log.d(TAG, "player discovered: id=" + playerId + ", syncMaster=" + syncMaster + ", name=" + name); + // If a player doesn't have a sync master then it's in a group of its own. + if (syncMaster == null) { + mPlayerSyncGroups.put(playerId, player); + continue; + } + + // If the master is this player then add itself and all the slaves. + if (playerId.equals(syncMaster)) { + mPlayerSyncGroups.put(playerId, player); + continue; + } + + // Must be a slave. Add it under the master. This might have already + // happened (in the block above), but might not. For example, it's possible + // to have a player that's a syncslave of an player that is not connected. + mPlayerSyncGroups.put(syncMaster, player); + } + } + + @NonNull + public Multimap getPlayerSyncGroups() { + return mPlayerSyncGroups; + } + + @Override + protected void clearItemAdapter() { + mResultsAdapter.clear(); + } + + public abstract PlayerBaseView createPlayerView(); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java new file mode 100644 index 000000000..31dcaf8a7 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerView.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.PopupMenu; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.itemlist.dialog.DefeatDestructiveTouchToPlayDialog; +import uk.org.ngo.squeezer.itemlist.dialog.PlayTrackAlbumDialog; +import uk.org.ngo.squeezer.itemlist.dialog.PlayerRenameDialog; +import uk.org.ngo.squeezer.itemlist.dialog.PlayerSyncDialog; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.service.ISqueezeService; + +public class PlayerView extends PlayerBaseView { + + public PlayerView(PlayerListActivity activity) { + super(activity, R.layout.list_item_player); + + setViewParams(VIEW_PARAM_ICON | VIEW_PARAM_TWO_LINE | VIEW_PARAM_CONTEXT_BUTTON); + setLoadingViewParams(VIEW_PARAM_ICON | VIEW_PARAM_TWO_LINE); + } + + @Override + public ViewHolder createViewHolder(View view) { + return new PlayerViewHolder(view); + } + + @Override + public void bindView(View view, Player item) { + PlayerState playerState = item.getPlayerState(); + PlayerViewHolder viewHolder = (PlayerViewHolder) view.getTag(); + + super.bindView(view, item); + viewHolder.icon.setImageResource(getModelIcon(item.getModel())); + + if (viewHolder.volumeBar == null) { + viewHolder.volumeBar = view.findViewById(R.id.volume_slider); + viewHolder.volumeBar.setOnSeekBarChangeListener(new VolumeSeekBarChangeListener(item, viewHolder.volumeValue)); + } + + viewHolder.volumeBar.setVisibility(View.VISIBLE); + + if (playerState.isPoweredOn()) { + viewHolder.text1.setAlpha(1.0f); + } else { + viewHolder.text1.setAlpha(0.25f); + } + + viewHolder.volumeBar.setProgress(playerState.getCurrentVolume()); + + viewHolder.text2.setVisibility(playerState.getSleepDuration() > 0 ? View.VISIBLE : View.INVISIBLE); + if (playerState.getSleepDuration() > 0) { + viewHolder.text2.setText(activity.getString(R.string.SLEEPING_IN) + + " " + Util.formatElapsedTime(item.getSleepingIn())); + } + } + + @Override + public void showContextMenu(ViewHolder viewHolder, final Player item) { + PopupMenu popup = new PopupMenu(getActivity(), viewHolder.contextMenuButtonHolder); + popup.inflate(R.menu.playercontextmenu); + + Menu menu = popup.getMenu(); + PlayerViewLogic.inflatePlayerActions(activity, popup.getMenuInflater(), menu); + + PlayerState playerState = item.getPlayerState(); + menu.findItem(R.id.cancel_sleep).setVisible(playerState.getSleepDuration() != 0); + + menu.findItem(R.id.end_of_song).setVisible(playerState.isPlaying()); + + menu.findItem(R.id.toggle_power).setTitle(playerState.isPoweredOn() ? R.string.menu_item_power_off : R.string.menu_item_power_on); + + // Enable player sync menu options if there's more than one player. + menu.findItem(R.id.player_sync).setVisible(activity.mResultsAdapter.mPlayerCount > 1); + + menu.findItem(R.id.play_track_album).setVisible(playerState.prefs.containsKey(Player.Pref.PLAY_TRACK_ALBUM)); + + menu.findItem(R.id.defeat_destructive_ttp).setVisible(playerState.prefs.containsKey(Player.Pref.DEFEAT_DESTRUCTIVE_TTP)); + + popup.setOnMenuItemClickListener(menuItem -> doItemContext(menuItem, item)); + + activity.mResultsAdapter.mPlayersChanged = false; + popup.show(); + } + + private boolean doItemContext(MenuItem menuItem, Player selectedItem) { + if (activity.mResultsAdapter.mPlayersChanged) { + Toast.makeText(activity, activity.getText(R.string.player_list_changed), + Toast.LENGTH_LONG).show(); + return true; + } + + activity.setCurrentPlayer(selectedItem); + ISqueezeService service = activity.getService(); + if (service == null) { + return true; + } + + if (PlayerViewLogic.doPlayerAction(service, menuItem, selectedItem)) { + return true; + } + + switch (menuItem.getItemId()) { + case R.id.rename: + new PlayerRenameDialog().show(activity.getSupportFragmentManager(), + PlayerRenameDialog.class.getName()); + return true; + case R.id.player_sync: + new PlayerSyncDialog().show(activity.getSupportFragmentManager(), + PlayerSyncDialog.class.getName()); + return true; + case R.id.play_track_album: + PlayTrackAlbumDialog.show(activity); + return true; + case R.id.defeat_destructive_ttp: + DefeatDestructiveTouchToPlayDialog.show(activity); + return true; + } + + return false; + } + + private class VolumeSeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { + private final Player player; + private final TextView valueView; + + public VolumeSeekBarChangeListener(Player player, TextView valueView) { + this.player = player; + this.valueView = valueView; + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) { + ISqueezeService service = activity.getService(); + if (service == null) { + return; + } + service.adjustVolumeTo(player, progress); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + activity.setTrackingTouch(true); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + activity.setTrackingTouch(false); + } + } + + private class PowerButtonClickListener implements View.OnClickListener { + private final Player player; + + private PowerButtonClickListener(Player player) { + this.player = player; + } + + @Override + public void onClick(View v) { + ISqueezeService service = activity.getService(); + if (service == null) { + return; + } + service.togglePower(player); + } + } + + private static class PlayerViewHolder extends ViewHolder { + SeekBar volumeBar; + TextView volumeValue; + + public PlayerViewHolder(@NonNull View view) { + super(view); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java new file mode 100644 index 000000000..52808bf85 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/PlayerViewLogic.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2020 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist; + +import android.content.Context; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.service.event.SongTimeChanged; + +public class PlayerViewLogic { + + /** + * Inflate common player actions onto the supplied menu + */ + public static void inflatePlayerActions(Context context, MenuInflater inflater, Menu menu) { + inflater.inflate(R.menu.playermenu, menu); + + String xMinutes = context.getString(R.string.X_MINUTES); + menu.findItem(R.id.in_15_minutes).setTitle(String.format(xMinutes, "15")); + menu.findItem(R.id.in_30_minutes).setTitle(String.format(xMinutes, "30")); + menu.findItem(R.id.in_45_minutes).setTitle(String.format(xMinutes, "45")); + menu.findItem(R.id.in_60_minutes).setTitle(String.format(xMinutes, "60")); + menu.findItem(R.id.in_90_minutes).setTitle(String.format(xMinutes, "90")); + } + + /** + * If menu item is a known player action, perform it and return true. + */ + public static boolean doPlayerAction(ISqueezeService service, MenuItem menuItem, Player selectedItem) { + switch (menuItem.getItemId()) { + case R.id.sleep: + // This is the start of a context menu. + // Just return, as we have set the current player. + return true; + case R.id.end_of_song: { + PlayerState playerState = selectedItem.getPlayerState(); + if (playerState.isPlaying()) { + SongTimeChanged trackElapsed = selectedItem.getTrackElapsed(); + int sleep = trackElapsed.duration - trackElapsed.currentPosition + 1; + if (sleep >= 0) + service.sleep(selectedItem, sleep); + } + return true; + } + case R.id.in_15_minutes: + service.sleep(selectedItem, 15*60); + return true; + case R.id.in_30_minutes: + service.sleep(selectedItem, 30*60); + return true; + case R.id.in_45_minutes: + service.sleep(selectedItem, 45*60); + return true; + case R.id.in_60_minutes: + service.sleep(selectedItem, 60*60); + return true; + case R.id.in_90_minutes: + service.sleep(selectedItem, 90*60); + return true; + case R.id.cancel_sleep: + service.sleep(selectedItem, 0); + return true; + case R.id.toggle_power: + service.togglePower(selectedItem); + return true; + } + + + return false; + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java new file mode 100644 index 000000000..f2e6495e9 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/AlarmSettingsDialog.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.view.View; +import android.widget.CompoundButton; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Player; + +/** + * A dialog with controls to manage a player's default alarm preferences (volume, snooze duration, + * etc). + *

+ * Activities that host this dialog must implement {@link AlarmSettingsDialog.HostActivity} + * to provide information about the preferences, and to save the new values when the user + * selects the dialog's positive action button. + */ +public class AlarmSettingsDialog extends DialogFragment { + private HostActivity mHostActivity; + + /** Activities that host this dialog must implement this interface. */ + public interface HostActivity { + /** + * @return The current player. + */ + @NonNull + Player getPlayer(); + + /** + * @param playerPref the name of the preference to get + * @param def the default value to return if the preference does not exist + * @return The value of the PlayerPref identified by playerPref + */ + @NonNull + String getPlayerPref(@NonNull @Player.Pref.Name String playerPref, @NonNull String def); + + /** + * Called when the user selects the dialog's positive button. + * + * @param volume The user's chosen volume + * @param snooze The user's chosen snooze duration, in seconds + * @param timeout The user's chosen timeout duration, in seconds + * @param fade Whether alarms should fade up + */ + void onPositiveClick(int volume, int snooze, int timeout, boolean fade); + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + // Verify that the host activity implements the callback interface + try { + // Instantiate the NoticeDialogListener so we can send events to the host + mHostActivity = (HostActivity) activity; + } catch (ClassCastException e) { + // The activity doesn't implement the interface, throw exception + throw new ClassCastException(activity.toString() + + " must implement HostActivity"); + } + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + @SuppressLint({"InflateParams"}) + final View view = getActivity().getLayoutInflater().inflate(R.layout.alarm_settings_dialog, null); + + final TextView alarmVolumeHint = view.findViewById(R.id.alarm_volume_hint); + final TextView alarmSnoozeHint = view.findViewById(R.id.alarm_snooze_hint); + final TextView alarmTimeoutHint = view.findViewById(R.id.alarm_timeout_hint); + final TextView alarmFadeHint = view.findViewById(R.id.alarm_fade_hint); + + final SeekBar alarmVolume = view.findViewById(R.id.alarm_volume_seekbar); + final SeekBar alarmSnooze = view.findViewById(R.id.alarm_snooze_seekbar); + final SeekBar alarmTimeout = view.findViewById(R.id.alarm_timeout_seekbar); + + final CompoundButton alarmFadeToggle = view.findViewById(R.id.alarm_fade); + + alarmVolume.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + alarmVolumeHint.setText(String.format("%d%%", progress)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { } + }); + + alarmSnooze.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + alarmSnoozeHint.setText(getResources().getQuantityString(R.plurals.alarm_snooze_hint_text, + progress, progress)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { } + }); + + alarmTimeout.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (progress == 0) { + alarmTimeoutHint.setText(R.string.alarm_timeout_hint_text_zero); + } else { + alarmTimeoutHint.setText(getResources().getQuantityString(R.plurals.alarm_timeout_hint_text, + progress, progress)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { } + }); + + alarmFadeToggle.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + alarmFadeHint.setText(isChecked ? R.string.alarm_fade_on_text : R.string.alarm_fade_off_text); + } + }); + + alarmVolume.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_DEFAULT_VOLUME, "50"))); + alarmSnooze.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_SNOOZE_SECONDS, "600")) / 60); + alarmTimeout.setProgress(Integer.valueOf(mHostActivity.getPlayerPref(Player.Pref.ALARM_TIMEOUT_SECONDS, "300")) / 60); + alarmFadeToggle.setChecked("1".equals(mHostActivity.getPlayerPref(Player.Pref.ALARM_FADE_SECONDS, "0"))); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setView(view); + builder.setTitle(getResources().getString(R.string.alarms_settings_dialog_title, mHostActivity.getPlayer().getName())); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mHostActivity.onPositiveClick(alarmVolume.getProgress(), alarmSnooze.getProgress() * 60, + alarmTimeout.getProgress() * 60, alarmFadeToggle.isChecked()); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + return builder.create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java new file mode 100644 index 000000000..924cd92ed --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkDialog.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2019 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.view.Window; +import android.widget.ImageView; + +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.util.ImageFetcher; + +public class ArtworkDialog extends DialogFragment implements IServiceItemListCallback { + private static final String TAG = DialogFragment.class.getSimpleName(); + private ImageView artwork; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + BaseActivity activity = (BaseActivity)getActivity(); + Action action = getArguments().getParcelable(Action.class.getName()); + + Dialog dialog = new Dialog(getContext()); + dialog.setContentView(R.layout.show_artwork); + artwork = dialog.findViewById(R.id.artwork); + + Rect rect = new Rect(); + Window window = dialog.getWindow(); + window.getDecorView().getWindowVisibleDisplayFrame(rect); + int size = Math.min(rect.width(), rect.height()); + window.setLayout(size, size); + + // FIXME Image wont get fetched (and thus not displayed) after orientation change + if (activity.getService()!= null) { + activity.getService().pluginItems(action, this); + } + + return dialog; + } + + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + Uri artworkId = Util.getImageUrl(parameters, parameters.containsKey("artworkId") ? "artworkId" : "artworkUrl"); + ImageFetcher.getInstance(getContext()).loadImage(artworkId, artwork); + } + + @Override + public Object getClient() { + return getActivity(); + } + + /** + * Create a dialog to show artwork. + *

+ * We call {@link ISqueezeService#pluginItems(Action, IServiceItemListCallback)} with the + * supplied action to asynchronously order an artwork id or URL. When the response + * arrives we load the artwork into the dialog. + *

+ * See Slim/Control/Queries.pm in the slimserver code + */ + public static ArtworkDialog show(BaseActivity activity, Action action) { + // Create and show the dialog + ArtworkDialog dialog = new ArtworkDialog(); + + Bundle args = new Bundle(); + args.putParcelable(Action.class.getName(), action); + dialog.setArguments(args); + + dialog.show(activity.getSupportFragmentManager(), TAG); + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java new file mode 100644 index 000000000..6a660c97c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ArtworkListLayout.java @@ -0,0 +1,31 @@ +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.content.Context; + +import androidx.annotation.StringRes; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.EnumWithText; + +/** + * Supported list layouts. + */ +public enum ArtworkListLayout implements EnumWithText { + grid(R.string.SWITCH_TO_GALLERY), + list(R.string.SWITCH_TO_EXTENDED_LIST); + + /** + * The text to use for this layout + */ + @StringRes + private final int stringResource; + + @Override + public String getText(Context context) { + return context.getString(stringResource); + } + + ArtworkListLayout(@StringRes int serverString) { + this.stringResource = serverString; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java new file mode 100644 index 000000000..329dc323c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseChoicesDialog.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2020 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.view.View; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; + +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import uk.org.ngo.squeezer.R; + +public abstract class BaseChoicesDialog extends DialogFragment { + + Dialog createDialog(String title, String message, int selectedIndex, String[] choiceStrings) { + final Activity activity = getActivity(); + + @SuppressLint({"InflateParams"}) + View content = activity.getLayoutInflater().inflate(R.layout.choices_layout, null); + + + if (message != null) { + content.findViewById(R.id.message).setVisibility(View.VISIBLE); + content.findViewById(R.id.message).setText(message); + } + + RadioGroup radioGroup = content.findViewById(R.id.choices); + for (int i = 0; i < choiceStrings.length; i++) { + RadioButton radioButton = new RadioButton(activity); + radioButton.setText(choiceStrings[i]); + radioButton.setId(i); + radioGroup.addView(radioButton); + } + radioGroup.check(selectedIndex); + + radioGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(RadioGroup group, int checkedId) { + onSelectOption(checkedId); + dismiss(); + } + }); + + return new MaterialAlertDialogBuilder(getContext()) + .setTitle(title) + .setView(content) + .create(); + } + + protected abstract void onSelectOption(int checkedId); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java new file mode 100644 index 000000000..440c82686 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/BaseEditTextDialog.java @@ -0,0 +1,61 @@ +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.view.KeyEvent; +import android.view.View; +import android.view.View.OnKeyListener; +import android.widget.EditText; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputLayout; + +import uk.org.ngo.squeezer.R; + +public abstract class BaseEditTextDialog extends DialogFragment { + + protected TextInputLayout editTextLayout; + protected EditText editText; + + abstract protected boolean commit(String string); + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); + + @SuppressLint({"InflateParams"}) + View form = getActivity().getLayoutInflater().inflate(R.layout.edittext_dialog, null); + builder.setView(form); + editTextLayout = form.findViewById(R.id.edittext_til); + editText = form.findViewById(R.id.edittext); + + editText.setText(""); + editText.setOnKeyListener(new OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if ((event.getAction() == KeyEvent.ACTION_DOWN) && (keyCode + == KeyEvent.KEYCODE_ENTER)) { + if (commit(editText.getText().toString())) { + dismiss(); + } + return true; + } + return false; + } + }); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + commit(editText.getText().toString()); + } + }); + + return builder.create(); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java new file mode 100644 index 000000000..5531d1b97 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/ChoicesDialog.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2019 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.os.Bundle; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.model.JiveItem; + +public class ChoicesDialog extends BaseChoicesDialog { + + private BaseActivity activity; + private JiveItem item; + private int alreadyPopped; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + activity = (BaseActivity)getActivity(); + item = getArguments().getParcelable(JiveItem.class.getName()); + alreadyPopped = getArguments().getInt("alreadyPopped", 0); + return createDialog(item.getName(), null, item.selectedIndex-1, item.choiceStrings); + } + + @Override + protected void onSelectOption(int checkedId) { + activity.action(item.goAction.choices[checkedId], alreadyPopped); + } + + /** + * Create a dialog to select from choices + *

+ * See http://wiki.slimdevices.com/index.php/SBS_SqueezePlay_interface#Choices_array_in_Do_action + * and choiceStrings of + * http://wiki.slimdevices.com/index.php/SBS_SqueezePlay_interface#.3Citem_fields.3E + */ + public static ChoicesDialog show(BaseActivity activity, JiveItem item, int alreadyPopped) { + // Create and show the dialog + ChoicesDialog dialog = new ChoicesDialog(); + + Bundle args = new Bundle(); + args.putParcelable(JiveItem.class.getName(), item); + args.putInt("alreadyPopped", alreadyPopped); + dialog.setArguments(args); + + dialog.show(activity.getSupportFragmentManager(), ChoicesDialog.class.getSimpleName()); + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/DefeatDestructiveTouchToPlayDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/DefeatDestructiveTouchToPlayDialog.java new file mode 100644 index 000000000..7f113992c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/DefeatDestructiveTouchToPlayDialog.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; + +import uk.org.ngo.squeezer.R; + +public class DefeatDestructiveTouchToPlayDialog extends BaseChoicesDialog { + /** + * Activities that host this dialog must implement this interface. + */ + public interface DefeatDestructiveTouchToPlayDialogHost { + FragmentManager getSupportFragmentManager(); + String getDefeatDestructiveTTP(); + void setDefeatDestructiveTTP(@NonNull String option); + } + + private DefeatDestructiveTouchToPlayDialogHost host; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + host = (DefeatDestructiveTouchToPlayDialogHost) context; + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + String[] options = { + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_0), + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_1), + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_2), + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_3), + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_4) + }; + return createDialog( + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP), + getString(R.string.SETUP_DEFEAT_DESTRUCTIVE_TTP_DESC), + Integer.parseInt(host.getDefeatDestructiveTTP()), + options + ); + } + + @Override + protected void onSelectOption(int checkedId) { + host.setDefeatDestructiveTTP(String.valueOf(checkedId)); + } + + /** + * Create a dialog to select the defeat destructive touch-to-play option. + * + * @param host The hosting activity must implement {@link DefeatDestructiveTouchToPlayDialogHost} to provide + * the information that the dialog needs. + */ + public static DefeatDestructiveTouchToPlayDialog show(DefeatDestructiveTouchToPlayDialogHost host) { + DefeatDestructiveTouchToPlayDialog dialog = new DefeatDestructiveTouchToPlayDialog(); + + dialog.show(host.getSupportFragmentManager(), DefeatDestructiveTouchToPlayDialog.class.getSimpleName()); + + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTextDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTextDialog.java new file mode 100644 index 000000000..2d8b4ff1b --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTextDialog.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import android.text.InputType; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.model.JiveItem; + +public class InputTextDialog extends BaseEditTextDialog { + private BaseActivity activity; + private JiveItem item; + private int alreadyPopped; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + activity = (BaseActivity)getActivity(); + item = getArguments().getParcelable(JiveItem.class.getName()); + alreadyPopped = getArguments().getInt("alreadyPopped", 0); + + Dialog dialog = super.onCreateDialog(savedInstanceState); + dialog.setTitle(item.getName()); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editTextLayout.setHint(item.input.title); + editText.setText(item.input.initialText); + + return dialog; + } + + @Override + protected boolean commit(String inputString) { + item.inputValue = inputString; + activity.action(item, item.goAction, alreadyPopped); + return true; + } + + /** + * Create a dialog to input text before proceding with the actions + *

+ * See http://wiki.slimdevices.com/index.php/SBS_SqueezePlay_interface#.3Cinput_fields.3E + */ + public static void show(BaseActivity activity, JiveItem item, int alreadyPopped) { + InputTextDialog dialog = new InputTextDialog(); + + Bundle args = new Bundle(); + args.putParcelable(JiveItem.class.getName(), item); + args.putInt("alreadyPopped", alreadyPopped); + dialog.setArguments(args); + + dialog.show(activity.getSupportFragmentManager(), DialogFragment.class.getSimpleName()); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTimeDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTimeDialog.java new file mode 100644 index 000000000..bd5f74583 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/InputTimeDialog.java @@ -0,0 +1,65 @@ +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import android.text.format.DateFormat; + +import androidx.annotation.NonNull; + +import com.android.datetimepicker.time.RadialPickerLayout; +import com.android.datetimepicker.time.TimePickerDialog; + +import java.util.Calendar; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.framework.BaseListActivity; +import uk.org.ngo.squeezer.model.JiveItem; + +public class InputTimeDialog extends TimePickerDialog implements TimePickerDialog.OnTimeSetListener { + private BaseListActivity activity; + private JiveItem item; + private int alreadyPopped; + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + activity = (BaseListActivity) getActivity(); + item = getArguments().getParcelable(JiveItem.class.getName()); + alreadyPopped = getArguments().getInt("alreadyPopped", 0); + setOnTimeSetListener(this); + return super.onCreateDialog(savedInstanceState); + } + + public static void show(BaseActivity activity, JiveItem item, int alreadyPopped) { + int hour; + int minute; + try { + int tod = Integer.parseInt(item.input.initialText); + hour = tod / 3600; + minute = (tod / 60) % 60; + } catch (NumberFormatException nfe) { + // Fall back to current time as the default values for the picker + final Calendar c = Calendar.getInstance(); + hour = c.get(Calendar.HOUR_OF_DAY); + minute = c.get(Calendar.MINUTE); + } + + InputTimeDialog dialog = new InputTimeDialog(); + + Bundle args = new Bundle(); + args.putParcelable(JiveItem.class.getName(), item); + args.putInt("alreadyPopped", alreadyPopped); + dialog.setArguments(args); + + dialog.initialize(dialog, hour, minute, DateFormat.is24HourFormat(activity)); + dialog.setThemeDark(activity.getThemeId() == R.style.AppTheme); + dialog.show(activity.getSupportFragmentManager(), InputTimeDialog.class.getSimpleName()); + } + + @Override + public void onTimeSet(RadialPickerLayout view, int hourOfDay, int minute) { + item.inputValue = String.valueOf((hourOfDay * 60 + minute) * 60); + activity.action(item, item.goAction, alreadyPopped); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayTrackAlbumDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayTrackAlbumDialog.java new file mode 100644 index 000000000..c9741b81b --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayTrackAlbumDialog.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; + +import uk.org.ngo.squeezer.R; + +public class PlayTrackAlbumDialog extends BaseChoicesDialog { + /** + * Activities that host this dialog must implement this interface. + */ + public interface PlayTrackAlbumDialogHost { + FragmentManager getSupportFragmentManager(); + String getPlayTrackAlbum(); + void setPlayTrackAlbum(@NonNull String option); + } + + private PlayTrackAlbumDialogHost host; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + host = (PlayTrackAlbumDialogHost) context; + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + String[] options = { + getString(R.string.SETUP_PLAYTRACKALBUM_0), + getString(R.string.SETUP_PLAYTRACKALBUM_1) + }; + return createDialog( + getString(R.string.SETUP_PLAYTRACKALBUM), + getString(R.string.SETUP_PLAYTRACKALBUM_DESC), + Integer.parseInt(host.getPlayTrackAlbum()), + options + ); + } + + @Override + protected void onSelectOption(int checkedId) { + host.setPlayTrackAlbum(String.valueOf(checkedId)); + } + + /** + * Create a dialog to select play track album option. + * + * @param host The hosting activity must implement {@link PlayTrackAlbumDialogHost} to provide + * the information that the dialog needs. + */ + public static PlayTrackAlbumDialog show(PlayTrackAlbumDialogHost host) { + PlayTrackAlbumDialog dialog = new PlayTrackAlbumDialog(); + + dialog.show(host.getSupportFragmentManager(), PlayTrackAlbumDialog.class.getSimpleName()); + + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerRenameDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerRenameDialog.java new file mode 100644 index 000000000..f8f49cc4d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerRenameDialog.java @@ -0,0 +1,34 @@ +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import android.text.InputType; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.itemlist.PlayerListActivity; + +public class PlayerRenameDialog extends BaseEditTextDialog { + + private PlayerListActivity activity; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + + activity = (PlayerListActivity) getActivity(); + dialog.setTitle(getString(R.string.rename_title, activity.getCurrentPlayer().getName())); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setText(activity.getCurrentPlayer().getName()); + + return dialog; + } + + @Override + protected boolean commit(String newName) { + activity.playerRename(newName); + return true; + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerSyncDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerSyncDialog.java new file mode 100644 index 000000000..101447d25 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlayerSyncDialog.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import android.widget.ArrayAdapter; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.common.base.Joiner; +import com.google.common.collect.Multimap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.model.Player; + +/** + * A dialog that shows sync group options -- either joining an existing group, or + * removing a player from a sync group. The hosting activity must implement + * {@link PlayerSyncDialogHost} to provide the information that the dialog needs. + */ +public class PlayerSyncDialog extends DialogFragment { + /** + * Activities that host this dialog must implement this interface. + */ + public interface PlayerSyncDialogHost { + Multimap getPlayerSyncGroups(); + Player getCurrentPlayer(); + void syncPlayerToPlayer(@NonNull Player slave, @NonNull String masterId); + void unsyncPlayer(@NonNull Player player); + } + + private PlayerSyncDialogHost mHost; + + /** The sync group the user selected. */ + private int mSelectedGroup = 0; + + // Override the Fragment.onAttach() method to instantiate the PlayerSyncDialogHost. + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + // Verify that the host activity implements the callback interface + try { + // Instantiate the PlayerSyncDialogHost. + mHost = (PlayerSyncDialogHost) activity; + } catch (ClassCastException e) { + // The activity doesn't implement the interface, throw exception + throw new ClassCastException(activity.toString() + + " must implement PlayerSyncDialogHost"); + } + } + + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + Multimap playerSyncGroups = mHost.getPlayerSyncGroups(); + final Player currentPlayer = mHost.getCurrentPlayer(); + + /** The names of each sync group. */ + List playerSyncGroupNames = new ArrayList(); + + /** + * The master ID of the player for each sync group. Indices in this correspond + * 1:1 with {@link playerSyncGroupNames}. + */ + final List playerSyncGroupMasterIds = new ArrayList(); + + // Build the list of sync groups to show the user. + + // Collect and sort the master IDs. + List masterIds = new ArrayList(playerSyncGroups.keySet()); + Collections.sort(masterIds); + + // Generate descriptive text for each sync group. + for (String masterId : masterIds) { + // Do not show an entry for the group the current player is in. That might be... + // 1. Because it's the master of this group, or... + if (masterId.equals(currentPlayer.getId())) + continue; + + // 2. Because it's a member of this group. + if (masterId.equals(currentPlayer.getPlayerState().getSyncMaster())) + continue; + + // Collect the player names and master ID for this sync group. + List playerNames = new ArrayList(); + List slaves = new ArrayList(playerSyncGroups.get(masterId)); + Collections.sort(slaves, Player.compareById); + + for (Player slave : slaves) { + playerNames.add(slave.getName()); + } + playerSyncGroupNames.add(Joiner.on(", ").join(playerNames)); + playerSyncGroupMasterIds.add(masterId); + } + + // Add an additional entry for the "No synchronisation" option. + playerSyncGroupNames.add(getString(R.string.menu_item_player_unsync)); + + ArrayAdapter playerSyncGroupAdapter = new ArrayAdapter(getActivity(), + android.R.layout.simple_list_item_single_choice, playerSyncGroupNames); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setTitle(getString(R.string.sync_title, currentPlayer.getName())) + .setSingleChoiceItems(playerSyncGroupAdapter, mSelectedGroup, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mSelectedGroup = which; + } + }) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // The "No synchronisation" option is always last. + if (mSelectedGroup == playerSyncGroupMasterIds.size()) { + mHost.unsyncPlayer(currentPlayer); + } else { + mHost.syncPlayerToPlayer(currentPlayer, + playerSyncGroupMasterIds.get(mSelectedGroup)); + } + } + }) + .setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + // User cancelled the dialog, nothing to do. + } + }); + return builder.create(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlaylistSaveDialog.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlaylistSaveDialog.java new file mode 100644 index 000000000..e1d133c35 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/PlaylistSaveDialog.java @@ -0,0 +1,54 @@ +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.text.InputType; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.service.ISqueezeService; + +public class PlaylistSaveDialog extends BaseEditTextDialog { + + private BaseActivity activity; + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Dialog dialog = super.onCreateDialog(savedInstanceState); + + Bundle args = getArguments(); + String name = args.getString("name"); + + activity = (BaseActivity) getActivity(); + dialog.setTitle(R.string.save_playlist_title); + editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setHint(R.string.save_playlist_hint); + if (name != null && name.length() > 0) { + editText.setText(name); + } + + return dialog; + } + + @Override + protected boolean commit(String name) { + ISqueezeService service = activity.getService(); + if (service == null) { + return false; + } + + service.playlistSave(name); + return true; + } + + public static void addTo(BaseActivity activity, @Nullable String name) { + PlaylistSaveDialog dialog = new PlaylistSaveDialog(); + Bundle args = new Bundle(); + args.putString("name", name); + dialog.setArguments(args); + dialog.show(activity.getSupportFragmentManager(), "SaveDialog"); + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/SlideShow.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/SlideShow.java new file mode 100644 index 000000000..4602aa197 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/itemlist/dialog/SlideShow.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2019 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.itemlist.dialog; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.view.View; +import android.view.Window; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.framework.BaseActivity; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.ISqueezeService; +import uk.org.ngo.squeezer.util.ImageFetcher; + +public class SlideShow extends DialogFragment implements IServiceItemListCallback { + private static final String TAG = SlideShow.class.getSimpleName(); + private static final int DELAY = 10_000; + private ImageView artwork; + private Uri[] images; + private int nextImage; + + private final Handler handler = new Handler(); + private final Runnable nextSlideTask = new Runnable() { + @Override + public void run() { + nextSlide(); + handler.postDelayed(this, DELAY); + } + }; + + private void nextSlide() { + ImageFetcher.getInstance(getContext()).loadImage(images[nextImage], artwork); + nextImage = ++nextImage % images.length; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + BaseActivity activity = (BaseActivity)getActivity(); + Action action = getArguments().getParcelable(Action.class.getName()); + + Dialog dialog = new Dialog(getContext()); + dialog.setContentView(R.layout.show_artwork); + artwork = dialog.findViewById(R.id.artwork); + artwork.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (images != null && images.length > 0) { + nextSlide(); + } + } + }); + + Rect rect = new Rect(); + Window window = dialog.getWindow(); + window.getDecorView().getWindowVisibleDisplayFrame(rect); + int size = Math.min(rect.width(), rect.height()); + window.setLayout(size, size); + + // FIXME Image wont get fetched (and thus not displayed) after orientation change + if (activity.getService()!= null) { + activity.getService().pluginItems(action, this); + } + + return dialog; + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + handler.removeCallbacks(nextSlideTask); + } + + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + Object[] item_data = (Object[]) parameters.get("loop_loop"); + if (item_data != null && item_data.length > 0) { + nextImage = 0; + images = new Uri[item_data.length]; + for (int i = 0; i < item_data.length; i++) { + Object item_d = item_data[i]; + Map record = (Map) item_d; + record.put("urlPrefix", parameters.get("urlPrefix")); + Uri artworkId = Util.getImageUrl(record, "image"); + images[i] = artworkId; + } + nextSlide(); + handler.postDelayed(nextSlideTask, DELAY); + } + } + + @Override + public Object getClient() { + return getActivity(); + } + + /** + * Create a dialog to show artwork. + *

+ * We call {@link ISqueezeService#pluginItems(Action, IServiceItemListCallback)} with the + * supplied action to asynchronously order an artwork id or URL. When the response + * arrives we load the artwork into the dialog. + *

+ * See Slim/Control/Queries.pm in the slimserver code + */ + public static SlideShow show(BaseActivity activity, Action action) { + // Create and show the dialog + SlideShow dialog = new SlideShow(); + + Bundle args = new Bundle(); + args.putParcelable(Action.class.getName(), action); + dialog.setArguments(args); + + dialog.show(activity.getSupportFragmentManager(), TAG); + return dialog; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Action.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Action.java new file mode 100644 index 000000000..c81816dca --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Action.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; + +import uk.org.ngo.squeezer.Util; + +/** + * Implements action_fields of the LMS SqueezePlay interface. + * http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#.3Cactions_fields.3E + */ +public class Action implements Parcelable { + private static final String INPUT_PLACEHOLDER = "__INPUT__"; + private static final String TAGGEDINPUT_PLACEHOLDER = "__TAGGEDINPUT__"; + + public String urlCommand; + public JsonAction action; + public JsonAction[] choices; + + public Action() { + } + + public static final Creator CREATOR = new Creator() { + @Override + public Action[] newArray(int size) { + return new Action[size]; + } + + @Override + public Action createFromParcel(Parcel source) { + return new Action(source); + } + }; + + public Action(Parcel source) { + urlCommand = source.readString(); + if (urlCommand == null) { + int choiceCount = source.readInt(); + if (choiceCount > 0) { + choices = new JsonAction[choiceCount]; + for (int i = 0; i < choiceCount; i++) { + choices[i] = source.readParcelable(getClass().getClassLoader()); + } + } else { + action = source.readParcelable(getClass().getClassLoader()); + } + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(urlCommand); + if (urlCommand == null) { + dest.writeInt(choices != null ? choices.length : 0); + if (choices != null) { + for (JsonAction action : choices) { + dest.writeParcelable(action, flags); + } + } else { + dest.writeParcelable(action, flags); + } + } + } + + @Override + public int describeContents() { + return 0; + } + + + public InputType getInputType() { + if (action != null && action.params.containsValue(TAGGEDINPUT_PLACEHOLDER)) { + for (Map.Entry entry : action.params.entrySet()) { + if (TAGGEDINPUT_PLACEHOLDER.equals(entry.getValue())) { + switch (entry.getKey()) { + case "search": return InputType.SEARCH; + case "email": return InputType.EMAIL; + case "password": return InputType.PASSWORD; + default: return InputType.TEXT; + } + } + } + } + return InputType.TEXT; + } + + public boolean isContextMenu() { + return (action != null && action.isContextMenu); + } + + public boolean isSlideShow() { + return (action != null && "slideshow".equals(action.params.get("type"))); + } + + @NonNull + @Override + public String toString() { + return "Action{" + + "urlCommand='" + urlCommand + '\'' + + ", action=" + action + + '}'; + } + + /** + * Action which can be sent to the server. + *

+ * It is either received from the server or constructed from the CLI specification + */ + public static class JsonAction extends SlimCommand { + /** If a nextWindow param is given at the json command level, it takes precedence over a nextWindow param at the item level, + * which in turn takes precendence over a nextWindow param at the base level. + * See section for more detail on this parameter. */ + public NextWindow nextWindow; + + public ActionWindow window; + public boolean isContextMenu; + + public JsonAction() { + } + + public static final Creator CREATOR = new Creator() { + @Override + public JsonAction[] newArray(int size) { + return new JsonAction[size]; + } + + @Override + public JsonAction createFromParcel(Parcel source) { + return new JsonAction(source); + } + }; + + protected JsonAction(Parcel in) { + super(in); + nextWindow = NextWindow.fromString(in.readString()); + window = ActionWindow.fromString(in.readString()); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeString(nextWindow == null ? null : nextWindow.toString()); + dest.writeString(window == null ? null : window.isContextMenu ? "1" : "0"); + } + + public Map params(String input) { + if (input == null) { + return params; + } + Map out = new HashMap<>(); + for (Map.Entry entry : params.entrySet()) { + String value = entry.getValue().toString(); + if (TAGGEDINPUT_PLACEHOLDER.equals(value)) { + out.put(entry.getKey(), input); + } else if (INPUT_PLACEHOLDER.equals(value)) { + out.put(input, null); + } else + out.put(entry.getKey(), value); + } + if (out.containsKey("valtag")) { + out.put(Util.getStringOrEmpty(out.get("valtag")), input); + out.remove("valtag"); + } + return out; + } + + @NonNull + @Override + public String toString() { + return "JsonAction{" + + "cmd=" + cmd + + ", params=" + params + + ", nextWindow=" + nextWindow + + '}'; + } + } + + public static class ActionWindow { + public boolean isContextMenu; + + public ActionWindow(boolean isContextMenu) { + this.isContextMenu = isContextMenu; + } + + public static ActionWindow fromString(String s) { + return (s == null) ? null : new ActionWindow("1".equals(s)); + } + } + + public static class NextWindow { + public NextWindowEnum nextWindow; + public String windowId; + + public NextWindow(NextWindowEnum nextWindow) { + this.nextWindow = nextWindow; + } + + public static NextWindow fromString(String s) { + return (s == null ? null : new NextWindow(s)); + } + + private NextWindow(String nextWindow) { + try { + this.nextWindow = NextWindowEnum.valueOf(nextWindow); + } catch (IllegalArgumentException e) { + this.nextWindow = NextWindowEnum.windowId; + windowId = nextWindow; + } + } + + @NonNull + @Override + public String toString() { + return (nextWindow == NextWindowEnum.windowId ? windowId : nextWindow.name()); + } + } + + public enum NextWindowEnum { + nowPlaying, // push to the Now Playing browse window + playlist, // push to the current playlist window + home, // push to the top level "home" window + parent, // push back to the previous window in the stack and refresh that window with the json that created it + parentNoRefresh, // same as parent but do not refresh the window + grandparent, // push back two windows in the stack + refresh, // stay on this window, but resend the cli command that was used to construct it and refresh the window with the freshly returned data + refreshOrigin, // push to the previous window in the stack, but resend the cli command that was used to construct it and refresh the window with the freshly returned data + windowId, // (7.4+)any other value of a window that is present on the window stack and has a "windowId" in it's window fields. Search the window stack backwards until a window with this windowId is found and pop all windows above it. + } + + public enum InputType { + TEXT, SEARCH, EMAIL, PASSWORD + } + +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Alarm.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Alarm.java new file mode 100644 index 000000000..a5c668639 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Alarm.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import uk.org.ngo.squeezer.Util; + + +public class Alarm extends Item { + + @Override + public String getName() { + return String.valueOf(tod); + } + + public Alarm(Map record) { + setId(getString(record, "id")); + tod = getInt(record, "time"); + setDow(getString(record, "dow")); + enabled = getInt(record, "enabled") == 1; + repeat = getInt(record, "repeat") == 1; + playListId = getString(record, "url"); + if ("CURRENT_PLAYLIST".equals(playListId)) playListId = ""; + } + + private int tod; + public int getTod() { + return tod; + } + public void setTod(int tod) { + this.tod = tod; + } + + private boolean repeat; + public boolean isRepeat() { + return repeat; + } + public void setRepeat(boolean repeat) { + this.repeat = repeat; + } + + private boolean enabled; + public boolean isEnabled() { + return enabled; + } + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + private String playListId; + public String getPlayListId() { + return playListId; + } + public void setPlayListId(String playListId) { + this.playListId = playListId; + } + + private Set dow = new TreeSet<>(); + + private void setDow(String dowString) { + dow.clear(); + String[] days = dowString.split(","); + for (String day : days) { + dow.add(Util.getInt(day)); + } + } + + public boolean isDayActive(int day) { + return dow.contains(day); + } + + public void setDay(int day) { + dow.add(day); + } + + public void clearDay(int day) { + dow.remove(day); + } + + private String serializeDow() { + StringBuilder sb = new StringBuilder(); + for (int day : dow) { + if (sb.length() == 0) sb.append(','); + sb.append(day); + } + return sb.toString(); + } + + public static final Creator CREATOR = new Creator() { + public Alarm[] newArray(int size) { + return new Alarm[size]; + } + + public Alarm createFromParcel(Parcel source) { + return new Alarm(source); + } + }; + + private Alarm(Parcel source) { + setId(source.readString()); + tod = source.readInt(); + setDow(source.readString()); + enabled = (source.readInt() != 0); + repeat = (source.readInt() != 0); + playListId = source.readString(); + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getId()); + dest.writeInt(tod); + dest.writeString(serializeDow()); + dest.writeInt(enabled ? 1 : 0); + dest.writeInt(repeat ? 1 : 0); + dest.writeString(playListId); + } + + @NonNull + @Override + public String toString() { + return "id=" + getId() + ", tod=" + getName(); + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlarmPlaylist.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlarmPlaylist.java new file mode 100644 index 000000000..7a8d8a99c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlarmPlaylist.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; + +import java.util.Map; + + +public class AlarmPlaylist extends Item { + + private String title; + + @Override + public String getName() { + return title; + } + + private String category; + public String getCategory() { + return category; + } + public void setCategory(String category) { + this.category = category; + } + + private boolean singleton; + public boolean isSingleton() { + return singleton; + } + + public AlarmPlaylist() { + } + + public AlarmPlaylist(Map record) { + setId(getStringOrEmpty(record, "url")); + title = getString(record, "title"); + category = getString(record, "category"); + singleton = getInt(record, "singleton") == 1; + } + + public static final Creator CREATOR = new Creator() { + public AlarmPlaylist[] newArray(int size) { + return new AlarmPlaylist[size]; + } + + public AlarmPlaylist createFromParcel(Parcel source) { + return new AlarmPlaylist(source); + } + }; + + private AlarmPlaylist(Parcel source) { + setId(source.readString()); + title = source.readString(); + category = source.readString(); + singleton = (source.readInt() == 1); + } + + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getId()); + dest.writeString(title); + dest.writeString(category); + dest.writeInt(singleton ? 1 : 0); + } + + @Override + public String toString() { + return "url=" + getId() + ", title=" + getName(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlertWindow.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlertWindow.java new file mode 100644 index 000000000..8260ec161 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/AlertWindow.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import com.google.common.base.Joiner; + +import java.util.Map; + +import uk.org.ngo.squeezer.Util; + +/** + * The alertWindow is meant for messages that should not leave the screen without the user + * dismissing it explicitly, and as a full window has the ability of passing much larger text + * messages than in a small popup. + */ +public class AlertWindow { + + /** text to display in the title bar of the window */ + public final String title; + + /** The text to display in the body of the window as a text-area widget. */ + public final String text; + + public AlertWindow(Map display) { + title = Util.getString(display, "title"); + Object[] texts = (Object[]) display.get("text"); + String text = Joiner.on('\n').join(texts).replaceAll("\\\\n", "\n"); + this.text = (text.startsWith("\n") ? text.substring(1) : text); + } + + @Override + public String toString() { + return "AlertWindow{" + + "title='" + title + '\'' + + ", text='" + text + '\'' + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/CurrentPlaylistItem.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/CurrentPlaylistItem.java new file mode 100644 index 000000000..d939a5bf9 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/CurrentPlaylistItem.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; +import androidx.annotation.NonNull; + +import java.util.Map; + + +public class CurrentPlaylistItem extends JiveItem { + + @NonNull private final String track; + + @NonNull + public String getTrack() { + return track.isEmpty() ? getName() : track; + } + + @NonNull private final String artist; + + @NonNull + public String getArtist() { + return artist; + } + + @NonNull + private final String album; + + @NonNull + public String getAlbum() { + return album; + } + + public CurrentPlaylistItem(Map record) { + super(record); + track = getStringOrEmpty(record, "track"); + artist = getStringOrEmpty(record, "artist"); + album = getStringOrEmpty(record, "album"); + } + + public static final Creator CREATOR = new Creator() { + @Override + public CurrentPlaylistItem[] newArray(int size) { + return new CurrentPlaylistItem[size]; + } + + @Override + public CurrentPlaylistItem createFromParcel(Parcel source) { + return new CurrentPlaylistItem(source); + } + }; + + private CurrentPlaylistItem(Parcel source) { + super(source); + track = source.readString(); + artist = source.readString(); + album = source.readString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeString(track); + dest.writeString(artist); + dest.writeString(album); + } + /** + * Extend the equality test by looking at additional track information. + * + * @param o The object to test. + * @return + */ + @Override + public boolean equals(Object o) { + if (!super.equals(o)) { + return false; + } + + // super.equals() has already checked that o is not null and is of the same class. + CurrentPlaylistItem s = (CurrentPlaylistItem)o; + + if (! s.getTrack().equals(track)) { + return false; + } + + if (! s.getAlbum().equals(album)) { + return false; + } + + if (! s.getArtist().equals(artist)) { + return false; + } + + if (! s.getIcon().equals(getIcon())) { + return false; + } + + return true; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/DisplayMessage.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/DisplayMessage.java new file mode 100644 index 000000000..624780516 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/DisplayMessage.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.net.Uri; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import android.text.TextUtils; + +import com.google.common.base.Joiner; + +import java.util.HashMap; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; + +/** + * The purpose of the showBriefly is (typically) to show a brief popup message on the display to + * convey something to the user. + */ +public class DisplayMessage { + private static final String TYPE_ICON = "icon"; + private static final String TYPE_TEXT = "text"; + private static final String TYPE_MIXED = "mixed"; + private static final String TYPE_SONG = "song"; + private static final String TYPE_POPUPALBUM = "popupalbum"; + + /** tells SP what style of popup to use. Valid types are 'popupplay', 'icon', 'song', 'mixed', and 'popupalbum'. In 7.6, 'alertWindow' has been added (see next section) */ + public final String type; + + /** duration in milliseconds to display the showBriefly popup. Defaults to 3 seconds. In 7.6, a duration of -1 will create a popup that doesn't go away until dismissed. */ + public final int duration; + + /** used for specific styles to be used in popup windows, e.g. adding a + badge when adding a favorite. */ + public final String style; + + /** The message to show. */ + public final String text; + + /** Remote icon or {@link Uri#EMPTY} */ + @NonNull public Uri icon; + + public DisplayMessage(Map display) { + type = Util.getString(display, "type", TYPE_TEXT); + duration = Util.getInt(display, "duration", 3000); + style = Util.getString(display, "style"); + Object[] texts = (Object[]) display.get("text"); + String text = Joiner.on('\n').join(texts).replaceAll("\\\\n", "\n"); + this.text = (text.startsWith("\n") ? text.substring(1) : text); + icon = Util.getImageUrl(display, display.containsKey("icon-id") ? "icon-id" : "icon"); + } + + public boolean isIcon() { + return (TYPE_ICON.equals(type) && !TextUtils.isEmpty(style)); + } + + public boolean isMixed() { + return (TYPE_MIXED.equals(type)); + } + + public boolean isPopupAlbum() { + return (TYPE_POPUPALBUM.equals(type)); + } + + public boolean isSong() { + return (TYPE_SONG.equals(type)); + } + + /** @return Whether this message has a remote icon associated with it. */ + public boolean hasIcon() { + return !(icon.equals(Uri.EMPTY)); + } + + @Override + public String toString() { + return "DisplayMessage{" + + "type='" + type + '\'' + + ", duration=" + duration + + ", style='" + style + '\'' + + ", icon=" + icon + + ", text='" + text + '\'' + + '}'; + } + + @DrawableRes + public int getIconResource() { + @DrawableRes Integer iconResource = displayMessageIcons.get(style); + return iconResource == null ? 0 : iconResource; + } + + private static Map displayMessageIcons = initializeDisplayMessageIcons(); + + private static Map initializeDisplayMessageIcons() { + Map result = new HashMap<>(); + + result.put("volume", R.drawable.icon_popup_box_volume_bar); + result.put("mute", R.drawable.icon_popup_box_volume_mute); + + result.put("sleep_15", R.drawable.icon_popup_box_sleep_15); + result.put("sleep_30", R.drawable.icon_popup_box_sleep_30); + result.put("sleep_45", R.drawable.icon_popup_box_sleep_45); + result.put("sleep_60", R.drawable.icon_popup_box_sleep_60); + result.put("sleep_90", R.drawable.icon_popup_box_sleep_90); + result.put("sleep_cancel", R.drawable.icon_popup_box_sleep_off); + + result.put("shuffle0", R.drawable.icon_popup_box_shuffle_off); + result.put("shuffle1", R.drawable.icon_popup_box_shuffle); + result.put("shuffle2", R.drawable.icon_popup_box_shuffle_album); + + result.put("repeat0", R.drawable.icon_popup_box_repeat_off); + result.put("repeat1", R.drawable.icon_popup_box_repeat_song); + result.put("repeat2", R.drawable.icon_popup_box_repeat); + + result.put("pause", R.drawable.icon_popup_box_pause); + result.put("play", R.drawable.icon_popup_box_play); + result.put("fwd", R.drawable.icon_popup_box_fwd); + result.put("rew", R.drawable.icon_popup_box_rew); + result.put("stop", R.drawable.icon_popup_box_stop); + + result.put("add", R.drawable.add); + result.put("favorite", R.drawable.icon_popup_favorite); + result.put("lineIn", R.drawable.icon_linein); + + return result; + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/HelpText.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/HelpText.java new file mode 100644 index 000000000..ba2693d0f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/HelpText.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; + +/** + * Implements the of the LMS SqueezePlay interface. + * http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#.3Cinput_fields.3E + */ +public class HelpText { + public String text; + public String token; + + public static HelpText readFromParcel(Parcel source) { + if (source.readInt() == 0) return null; + + HelpText helpText = new HelpText(); + helpText.text = source.readString(); + helpText.token = source.readString(); + + return helpText; + } + + public static void writeToParcel(Parcel dest, HelpText helpText) { + dest.writeInt(helpText == null ? 0 : 1); + if (helpText == null) return; + + dest.writeString(helpText.text); + dest.writeString(helpText.token); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Input.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Input.java new file mode 100644 index 000000000..6ea903408 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Input.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; + +/** + * Implements of the LMS SqueezePlay interface. + * http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#.3Cinput_fields.3E + */ +public class Input { + public int len; + public String allowedChars; + public String inputStyle; + public String title; + public String initialText; + public HelpText help; + public String softbutton1; + public String softbutton2; + + public static Input readFromParcel(Parcel source) { + if (source.readInt() == 0) return null; + + Input input = new Input(); + input.len = source.readInt(); + input.allowedChars = source.readString(); + input.inputStyle = source.readString(); + input.title = source.readString(); + input.initialText = source.readString(); + input.help = HelpText.readFromParcel(source); + input.softbutton1 = source.readString(); + input.softbutton2 = source.readString(); + + return input; + } + + public static void writeToParcel(Parcel dest, Input input) { + dest.writeInt(input == null ? 0 : 1); + if (input == null) return; + + dest.writeInt(input.len); + dest.writeString(input.allowedChars); + dest.writeString(input.inputStyle); + dest.writeString(input.title); + dest.writeString(input.initialText); + HelpText.writeToParcel(dest, input.help); + dest.writeString(input.softbutton1); + dest.writeString(input.softbutton2); + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Item.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Item.java new file mode 100644 index 000000000..a9298f351 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Item.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.net.Uri; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.Map; + +import uk.org.ngo.squeezer.Util; + +/** + * Base class for SqueezeServer data. + *

+ * Has an id, a getName() method and a few helper methods for parsing records from Squeezeserver. + * + * @author Kurt Aaholst + */ +public abstract class Item implements Parcelable { + private String id; + + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + public abstract String getName(); + + @Override + public int hashCode() { + return (getId() != null ? getId().hashCode() : 0); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + + if (o == null) { + return false; + } + + if (o.getClass() != getClass()) { + // There is no guarantee that SqueezeServer items have globally unique IDs. + return false; + } + + // Both might be empty items. For example a Song initialised + // with an empty token map, because no song is currently playing. + if (getId() == null && ((Item) o).getId() == null) { + return true; + } + + return getId() != null && getId().equals(((Item) o).getId()); + } + + @Override + public int describeContents() { + return 0; + } + + Map getRecord(Map record, String recordName) { + return Util.getRecord(record, recordName); + } + + protected int getInt(Map record, String fieldName) { + return Util.getInt(record, fieldName); + } + + protected int getInt(Map record, String fieldName, int defaultValue) { + return Util.getInt(record, fieldName, defaultValue); + } + + protected static String getString(Map record, String fieldName) { + return Util.getString(record, fieldName); + } + + @NonNull + String getStringOrEmpty(Map record, String fieldName) { + return Util.getStringOrEmpty(record, fieldName); + } + + @NonNull + static Uri getImageUrl(Map record, String fieldName) { + return Util.getImageUrl(record, fieldName); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/JiveItem.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/JiveItem.java new file mode 100644 index 000000000..6f954fe15 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/JiveItem.java @@ -0,0 +1,696 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.os.Parcel; +import android.text.TextUtils; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.content.res.AppCompatResources; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Squeezer; +import uk.org.ngo.squeezer.Util; + + +public class JiveItem extends Item { + public static final JiveItem HOME = new JiveItem("home", null, R.string.HOME, 1, Window.WindowStyle.HOME_MENU); + public static final JiveItem CURRENT_PLAYLIST = new JiveItem("status", null, R.string.menu_item_playlist, 1, Window.WindowStyle.PLAY_LIST); + public static final JiveItem EXTRAS = new JiveItem("extras", "home", R.string.EXTRAS, 50, Window.WindowStyle.HOME_MENU); + public static final JiveItem SETTINGS = new JiveItem("settings", "home", R.string.SETTINGS, 1005, Window.WindowStyle.HOME_MENU); + public static final JiveItem ADVANCED_SETTINGS = new JiveItem("advancedSettings", "settings", R.string.ADVANCED_SETTINGS, 105, Window.WindowStyle.TEXT_ONLY); + + /** + * Information that will be requested about songs. + *

+ * a:artist artist name
+ * C:compilation (1 if true, missing otherwise)
+ * j:coverart (1 if available, missing otherwise)
+ * J:artwork_track_id (if available, missing otherwise)
+ * K:artwork_url URL to remote artwork
+ * l:album album name
+ * t:tracknum, if known
+ * u:url Song file url
+ * x:remote 1, if this is a remote track
+ */ + private static final String SONG_TAGS = "aCjJKltux"; + + public static final Creator CREATOR = new Creator() { + @Override + public JiveItem[] newArray(int size) { + return new JiveItem[size]; + } + + @Override + public JiveItem createFromParcel(Parcel source) { + return new JiveItem(source); + } + }; + + private JiveItem(String id, String node, @StringRes int text, int weight, Window.WindowStyle windowStyle) { + this(record(id, node, text, weight)); + window = new Window(); + window.windowStyle = windowStyle; + } + + private static Map record(String id, String node, @StringRes int text, int weight) { + Map record = new HashMap<>(); + record.put("id", id); + record.put("node", node); + record.put("name", Squeezer.getContext().getString(text)); + record.put("weight", weight); + + return record; + } + + + private String id; + @NonNull + private String name; + public String text2; + @NonNull private final Uri icon; + + private String node; + private int weight; + private String type; + + public Action.NextWindow nextWindow; + public Input input; + public String inputValue; + public Window window; + public boolean doAction; + public Action goAction; + public Action playAction; + public Action addAction; + public Action insertAction; + public Action moreAction; + public List subItems; + public boolean showBigArtwork; + public int selectedIndex; + public String[] choiceStrings; + public Boolean checkbox; + public Map checkboxActions; + public Boolean radio; + public Slider slider; + + private SlimCommand downloadCommand; + + public JiveItem() { + name = ""; + icon = Uri.EMPTY; + } + + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + @NonNull + public String getName() { + return name; + } + + public void setName(@NonNull String name) { + this.name = name; + } + + /** The URL to use to download the icon. */ + @NonNull + public Uri getIcon() { + if (icon.equals(Uri.EMPTY) && window != null) { + return window.icon; + } + return icon; + } + + /** + * @return Whether the song has artwork associated with it. + */ + public boolean hasArtwork() { + return ! (getIcon().equals(Uri.EMPTY)); + } + + /** + * @return Icon resource for this item if it is embedded in the Squeezer app, or an empty icon. + */ + public Drawable getIconDrawable(Context context) { + return getIconDrawable(context, R.drawable.icon_pending_artwork); + } + + /** + * @return Icon resource for this item if it is embedded in the Squeezer app, or the supplied default icon. + */ + public Drawable getIconDrawable(Context context, @DrawableRes int defaultIcon) { + @DrawableRes int foreground = getItemIcon(); + if (foreground != 0) { + Drawable background = AppCompatResources.getDrawable(context, R.drawable.icon_background); + Drawable icon = AppCompatResources.getDrawable(context, foreground); + return new LayerDrawable(new Drawable[]{background, icon}); + } + + return AppCompatResources.getDrawable(context, getSlimIcon(defaultIcon)); + } + + @DrawableRes private int getSlimIcon(@DrawableRes int defaultIcon) { + @DrawableRes Integer iconResource = slimIcons.get(id); + return iconResource == null ? defaultIcon : iconResource; + } + + private static Map slimIcons = initializeSlimIcons(); + + private static Map initializeSlimIcons() { + Map result = new HashMap<>(); + + result.put("myMusic", R.drawable.icon_mymusic); + result.put("myMusicArtists", R.drawable.icon_ml_artists); + result.put("myMusicGenres", R.drawable.icon_ml_genres); + result.put("myMusicYears", R.drawable.icon_ml_years); + result.put("myMusicNewMusic", R.drawable.icon_ml_new_music); + result.put("extras", R.drawable.icon_settings_adv); + result.put("settings", R.drawable.icon_settings); + result.put("settingsAlarm", R.drawable.icon_alarm); + result.put("appletCustomizeHome", R.drawable.icon_settings_home); + result.put("settingsPlayerNameChange", R.drawable.icon_settings_name); + result.put("advancedSettings", R.drawable.icon_settings_adv); + + return result; + } + + @DrawableRes private int getItemIcon() { + @DrawableRes Integer iconResource = itemIcons.get(id); + return iconResource == null ? 0 : iconResource; + } + + private static Map itemIcons = initializeItemIcons(); + + private static Map initializeItemIcons() { + Map result = new HashMap<>(); + + result.put("radio", R.drawable.internet_radio); + result.put("radios", R.drawable.internet_radio); + result.put("favorites", R.drawable.favorites); + result.put("globalSearch", R.drawable.search); + result.put("homeSearchRecent", R.drawable.search); + result.put("playerpower", R.drawable.power); + result.put("myMusicSearch", R.drawable.search); + result.put("myMusicSearchArtists", R.drawable.search); + result.put("myMusicSearchAlbums", R.drawable.search); + result.put("myMusicSearchSongs", R.drawable.search); + result.put("myMusicSearchPlaylists", R.drawable.search); + result.put("myMusicSearchRecent", R.drawable.search); + result.put("myMusicAlbums", R.drawable.ml_albums); + result.put("myMusicMusicFolder", R.drawable.ml_folder); + result.put("myMusicPlaylists", R.drawable.ml_playlist); + result.put("randomplay", R.drawable.ml_random); + result.put("settingsShuffle", R.drawable.shuffle); + result.put("settingsRepeat", R.drawable.settings_repeat); + result.put("settingsAudio", R.drawable.settings_audio); + result.put("settingsSleep", R.drawable.settings_sleep); + result.put("settingsSync", R.drawable.settings_sync); + + return result; + } + + + public String getNode() { + return node; + } + + public int getWeight() { + return weight; + } + + public String getType() { + return type; + } + + + public boolean isSelectable() { + return (goAction != null || nextWindow != null || hasSubItems()|| node != null || checkbox != null); + } + + public boolean hasContextMenu() { + return (playAction != null || addAction != null || insertAction != null || moreAction != null || checkbox != null || radio != null); + } + + + public JiveItem(Map record) { + setId(getString(record, record.containsKey("cmd") ? "cmd" : "id")); + splitItemText(getStringOrEmpty(record, record.containsKey("name") ? "name" : "text")); + icon = getImageUrl(record, record.containsKey("icon-id") ? "icon-id" : "icon"); + node = getString(record, "node"); + weight = getInt(record, "weight"); + type = getString(record, "type"); + Map baseRecord = getRecord(record, "base"); + Map baseActions = (baseRecord != null ? getRecord(baseRecord, "actions") : null); + Map baseWindow = (baseRecord != null ? getRecord(baseRecord, "window") : null); + Map actionsRecord = getRecord(record, "actions"); + nextWindow = Action.NextWindow.fromString(getString(record, "nextWindow")); + input = extractInput(getRecord(record, "input")); + window = extractWindow(getRecord(record, "window"), baseWindow); + + // do takes precedence over go + goAction = extractAction("do", baseActions, actionsRecord, record, baseRecord); + doAction = (goAction != null); + if (goAction == null) { + // check if item instructs us to use a different action + String goActionName = record.containsKey("goAction") ? getString(record, "goAction") : "go"; + goAction = extractAction(goActionName, baseActions, actionsRecord, record, baseRecord); + } + + playAction = extractAction("play", baseActions, actionsRecord, record, baseRecord); + addAction = extractAction("add", baseActions, actionsRecord, record, baseRecord); + insertAction = extractAction("add-hold", baseActions, actionsRecord, record, baseRecord); + moreAction = extractAction("more", baseActions, actionsRecord, record, baseRecord); + if (moreAction != null) { + moreAction.action.params.put("xmlBrowseInterimCM", 1); + } + + downloadCommand = extractDownloadAction(record); + + subItems = extractSubItems((Object[]) record.get("item_loop")); + showBigArtwork = record.containsKey("showBigArtwork"); + + selectedIndex = getInt(record, "selectedIndex"); + choiceStrings = Util.getStringArray(record, "choiceStrings"); + if (goAction != null && goAction.action != null && goAction.action.cmd.size() == 0) { + doAction = true; + } + + if (record.containsKey("checkbox")) { + checkbox = (getInt(record, "checkbox") != 0); + checkboxActions = new HashMap<>(); + checkboxActions.put(true, extractAction("on", baseActions, actionsRecord, record, baseRecord)); + checkboxActions.put(false, extractAction("off", baseActions, actionsRecord, record, baseRecord)); + } + + if (record.containsKey("radio")) { + radio = (getInt(record, "radio") != 0); + } + + if (record.containsKey("slider")) { + slider = new Slider(); + slider.min = getInt(record, "min"); + slider.max = getInt(record, "max"); + slider.adjust = getInt(record, "adjust"); + slider.initial = getInt(record, "initial"); + slider.sliderIcons = getString(record, "sliderIcons"); + slider.help = getString(record, "help"); + } + } + + public JiveItem(Parcel source) { + setId(source.readString()); + name = source.readString(); + text2 = source.readString(); + icon = Uri.parse(source.readString()); + node = source.readString(); + weight = source.readInt(); + type = source.readString(); + nextWindow = Action.NextWindow.fromString(source.readString()); + input = Input.readFromParcel(source); + window = source.readParcelable(getClass().getClassLoader()); + goAction = source.readParcelable(getClass().getClassLoader()); + playAction = source.readParcelable(getClass().getClassLoader()); + addAction = source.readParcelable(getClass().getClassLoader()); + insertAction = source.readParcelable(getClass().getClassLoader()); + moreAction = source.readParcelable(getClass().getClassLoader()); + subItems = source.createTypedArrayList(JiveItem.CREATOR); + doAction = (source.readByte() != 0); + showBigArtwork = (source.readByte() != 0); + selectedIndex = source.readInt(); + choiceStrings = source.createStringArray(); + checkbox = (Boolean) source.readValue(getClass().getClassLoader()); + if (checkbox != null) { + checkboxActions = new HashMap<>(); + checkboxActions.put(true, (Action) source.readParcelable(getClass().getClassLoader())); + checkboxActions.put(false, (Action) source.readParcelable(getClass().getClassLoader())); + } + radio = (Boolean) source.readValue(getClass().getClassLoader()); + slider = source.readParcelable(getClass().getClassLoader()); + downloadCommand = source.readParcelable(getClass().getClassLoader()); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getId()); + dest.writeString(name); + dest.writeString(text2); + dest.writeString(icon.toString()); + dest.writeString(node); + dest.writeInt(weight); + dest.writeString(type); + dest.writeString(nextWindow == null ? null : nextWindow.toString()); + Input.writeToParcel(dest, input); + dest.writeParcelable(window, flags); + dest.writeParcelable(goAction, flags); + dest.writeParcelable(playAction, flags); + dest.writeParcelable(addAction, flags); + dest.writeParcelable(insertAction, flags); + dest.writeParcelable(moreAction, flags); + dest.writeTypedList(subItems); + dest.writeByte((byte) (doAction ? 1 : 0)); + dest.writeByte((byte) (showBigArtwork ? 1 : 0)); + dest.writeInt(selectedIndex); + dest.writeStringArray(choiceStrings); + dest.writeValue(checkbox); + if (checkbox != null) { + dest.writeParcelable(checkboxActions.get(true), flags); + dest.writeParcelable(checkboxActions.get(false), flags); + } + dest.writeValue(radio); + dest.writeParcelable(slider, flags); + dest.writeParcelable(downloadCommand, flags); + } + + + public boolean hasInput() { + return hasInputField() || hasChoices(); + } + + public boolean hasInputField() { + return (input != null); + } + + public boolean hasChoices() { + return (choiceStrings.length > 0); + } + + public boolean hasSlider() { + return (slider != null); + } + + public boolean isInputReady() { + return !TextUtils.isEmpty(inputValue); + } + + public boolean hasSubItems() { + return (subItems != null); + } + + public boolean canDownload() { + return downloadCommand != null; + } + + public SlimCommand downloadCommand() { + return downloadCommand; + } + + + @Override + public int describeContents() { + return 0; + } + + @Override + public int hashCode() { + return (getId() != null ? getId().hashCode() : 0); + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + + if (o == null) { + return false; + } + + if (o.getClass() != getClass()) { + // There is no guarantee that SqueezeServer items have globally unique IDs. + return false; + } + + // Both might be empty items. For example a Song initialised + // with an empty token map, because no song is currently playing. + if (getId() == null && ((Item) o).getId() == null) { + return true; + } + + return getId() != null && getId().equals(((Item) o).getId()); + } + + private String toStringOpen() { + return getClass().getSimpleName() + " { id: " + getId() + + ", name: " + getName() + + ", node: " + node + + ", weight: " + getWeight() + + ", go: " + goAction + + ", play: " + playAction + + ", add: " + addAction + + ", insert: " + insertAction + + ", more: " + moreAction + + ", window: " + window; + + } + + @NonNull + @Override + public String toString() { + return toStringOpen() + " }"; + } + + private void splitItemText(String text) { + // This happens enough for regular expressions to be ineffective + int nameEnd = text.indexOf('\n'); + if (nameEnd > 0) { + name = text.substring(0, nameEnd); + text2 = text.substring(nameEnd+1); + } else { + name = text; + text2 = ""; + } + } + + public static Window extractWindow(Map itemWindow, Map baseWindow) { + if (itemWindow == null && baseWindow == null) return null; + + Map params = new HashMap<>(); + if (baseWindow != null) params.putAll(baseWindow); + if (itemWindow != null) params.putAll(itemWindow); + + Window window = new Window(); + window.windowId = getString(params, "windowId"); + window.text = getString(params, "text"); + window.textarea = getString(params, "textarea"); + window.textareaToken = getString(params, "textAreaToken"); + window.help = getString(params, "help"); + window.icon = getImageUrl(params, params.containsKey("icon-id") ? "icon-id" : "icon"); + window.titleStyle = getString(params, "titleStyle"); + + String menuStyle = getString(params, "menuStyle"); + String windowStyle = getString(params, "windowStyle"); + window.windowStyle = Window.WindowStyle.get(windowStyle); + if (window.windowStyle == null) { + window.windowStyle = menu2window.get(menuStyle); + if (window.windowStyle == null) { + window.windowStyle = Window.WindowStyle.TEXT_ONLY; + } + } + + return window; + } + + /** + * legacy map of menuStyles to windowStyles + *

+ * make an educated guess at window style when one is not sent but a menu style is + */ + private static Map menu2window = initializeMenu2Window(); + + private static Map initializeMenu2Window() { + Map result = new HashMap<>(); + + result.put("album", Window.WindowStyle.ICON_LIST); + result.put("playlist", Window.WindowStyle.PLAY_LIST); + + return result; + } + + + private Input extractInput(Map record) { + if (record == null) return null; + + Input input = new Input(); + input.len = getInt(record, "len"); + input.softbutton1 = getString(record, "softbutton1"); + input.softbutton2 = getString(record, "softbutton2"); + input.inputStyle = getString(record, "_inputStyle"); + input.title = getString(record, "title"); + input.initialText = getString(record, "initialText"); + input.allowedChars = getString(record, "allowedChars"); + Map helpRecord = getRecord(record, "help"); + if (helpRecord != null) { + input.help = new HelpText(); + input.help.text = getString(helpRecord, "text"); + input.help.token = getString(helpRecord, "token"); + } + return input; + } + + private Action extractAction(String actionName, Map baseActions, Map itemActions, Map record, Map baseRecord) { + Map actionRecord = null; + Map itemParams = null; + + Object itemAction = (itemActions != null ? itemActions.get(actionName) : null); + if (itemAction instanceof Map) { + actionRecord = (Map) itemAction; + } + if (actionRecord == null && baseActions != null) { + Map baseAction = getRecord(baseActions, actionName); + if (baseAction != null) { + String itemsParams = (String) baseAction.get("itemsParams"); + if (itemsParams != null) { + itemParams = getRecord(record, itemsParams); + if (itemParams != null) { + actionRecord = baseAction; + } + } + } + } + if (actionRecord == null) return null; + + Action actionHolder = new Action(); + + if (actionRecord.containsKey("choices")) { + Object[] choices = (Object[]) actionRecord.get("choices"); + actionHolder.choices = new Action.JsonAction[choices.length]; + for (int i = 0; i < choices.length; i++) { + actionRecord = (Map) choices[i]; + actionHolder.choices[i]= extractJsonAction(baseRecord, actionRecord, itemParams); + } + } else { + actionHolder.action = extractJsonAction(baseRecord, actionRecord, itemParams); + } + + return actionHolder; + } + + private Action.JsonAction extractJsonAction(Map baseRecord, Map actionRecord, Map itemParams) { + Action.JsonAction action = new Action.JsonAction(); + + action.nextWindow = Action.NextWindow.fromString(getString(actionRecord, "nextWindow")); + if (action.nextWindow == null) action.nextWindow = nextWindow; + if (action.nextWindow == null && baseRecord != null) + action.nextWindow = Action.NextWindow.fromString(getString(baseRecord, "nextWindow")); + + action.cmd(Util.getStringArray(actionRecord, "cmd")); + Map params = getRecord(actionRecord, "params"); + if (params != null) { + action.params(params); + } + if (itemParams != null) { + action.params(itemParams); + } + action.param("useContextMenu", "1"); + + Map windowRecord = getRecord(actionRecord, "window"); + if (windowRecord != null) { + action.window = new Action.ActionWindow(getInt(windowRecord, "isContextMenu") != 0); + } + + // LMS may send isContextMenu in the itemParams, but this is ignored by squeezeplay, so we must do the same. + action.isContextMenu = (params != null && params.containsKey("isContextMenu")) || (action.window != null && action.window.isContextMenu); + + return action; + } + + private List extractSubItems(Object[] item_loop) { + if (item_loop != null) { + List items = new ArrayList<>(); + for (Object item_d : item_loop) { + Map record = (Map) item_d; + items.add(new JiveItem(record)); + } + return items; + } + + return null; + } + + private SlimCommand extractDownloadAction(Map record) { + if ("local".equals(getString(record, "trackType")) && (goAction != null || moreAction != null)) { + Action action = (moreAction != null ? moreAction : goAction); + String trackId = getStringOrEmpty(action.action.params, "track_id"); + return new SlimCommand() + .cmd("titles") + .param("tags", SONG_TAGS) + .param("track_id", trackId); + } else if (playAction != null && Collections.singletonList("playlistcontrol").equals(playAction.action.cmd) && "load".equals(playAction.action.params.get("cmd"))) { + if (playAction.action.params.containsKey("folder_id")) { + return new SlimCommand() + .cmd("musicfolder") + .param("tags", "cu") + .param("recursive", "1") + .param("folder_id", playAction.action.params.get("folder_id")); + } else if (playAction.action.params.containsKey("playlist_id")) { + return new SlimCommand() + .cmd("playlists", "tracks") + .param("tags", SONG_TAGS) + .param("playlist_id", playAction.action.params.get("playlist_id")); + } else { + return new SlimCommand() + .cmd("titles") + .param("tags", SONG_TAGS) + .params(getTitlesParams(playAction.action)); + } + } + return null; + } + + public static SlimCommand downloadCommand(String id) { + return new SlimCommand() + .cmd("titles") + .param("tags", SONG_TAGS) + .param("track_id", id); + } + + /** + * Get parameters which can be used in a titles command from the supplied action + */ + private Map getTitlesParams(SlimCommand action) { + Map map = new HashMap<>(); + for (Map.Entry e : action.params.entrySet()) { + if (title_parameters.contains(e.getKey()) && e.getValue() != null) { + map.put(e.getKey(), e.getValue()); + } + } + return map; + } + private static final Set title_parameters = new HashSet<>(Arrays.asList("track_id", "album_id", "artist_id", "genre_id", "year")); + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MenuStatusMessage.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MenuStatusMessage.java new file mode 100644 index 000000000..c142e46a8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MenuStatusMessage.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import androidx.annotation.NonNull; + +import java.util.Arrays; + +/** Holds a menu status message from slimserver. */ +public class MenuStatusMessage { + public static final String ADD = "add"; + public static final String REMOVE = "remove"; + + /** each entry contains a table that needs insertion into the menu */ + @NonNull + public JiveItem[] menuItems; + + // directive for these items is in chunk.data[3] + @NonNull + public String menuDirective; + + // the player ID this notification is for is in chunk.data[4] + @NonNull + public String playerId; + + public MenuStatusMessage(@NonNull String playerId, @NonNull String menuDirective, @NonNull JiveItem[] menuItems) { + this.playerId = playerId; + this.menuDirective = menuDirective; + this.menuItems = menuItems; + } + + @Override + public String toString() { + return "MenuStatusMessage{" + + "playerId='" + playerId + '\'' + + ", menuDirective='" + menuDirective + '\'' + + ", menuItems=" + Arrays.toString(menuItems) + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MusicFolderItem.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MusicFolderItem.java new file mode 100644 index 000000000..fef70fac6 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/MusicFolderItem.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2012 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.util.Map; + +import uk.org.ngo.squeezer.Util; + +/** + * Encapsulate a music folder item on the Squeezeserver. + *

+ * An item has a name and a type. The name is free text, the type may be one of "track", "folder", + * "playlist", or "unknown". + * + * @author nik + */ +public class MusicFolderItem { + + public String id; + public String name; + + /** + * The folder item's type, "track", "folder", "playlist", "unknown". + */ + // XXX: Should be an enum. + public String type; + + @NonNull + public Uri url; + + public String coverId; + + public MusicFolderItem(Map record) { + id = Util.getString(record, "id"); + name = Util.getString(record, "filename"); + type = Util.getString(record, "type"); + url = Uri.parse(Util.getStringOrEmpty(record, "url")); + coverId = Util.getString(record, "coverid"); + } + + @NonNull + @Override + public String toString() { + return "MusicFolderItem{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", type='" + type + '\'' + + ", url=" + url + + '}'; + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Player.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Player.java new file mode 100644 index 000000000..7c192791e --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Player.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; +import android.os.SystemClock; +import androidx.annotation.NonNull; +import androidx.annotation.StringDef; + +import com.google.common.base.Charsets; +import com.google.common.hash.HashCode; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Comparator; +import java.util.Map; + +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.service.event.SongTimeChanged; + + +public class Player extends Item implements Comparable { + + private String mName; + + private final String mIp; + + private final String mModel; + + private final boolean mCanPowerOff; + + /** Hash function to generate at least 64 bits of hashcode from a player's ID. */ + private static final HashFunction mHashFunction = Hashing.goodFastHash(64); + + /** A hash of the player's ID. */ + private final HashCode mHashCode; + + private PlayerState mPlayerState = new PlayerState(); + + /** Is the player connected? */ + private boolean mConnected; + + @Override + public int compareTo(@NonNull Object otherPlayer) { + return this.mName.compareToIgnoreCase(((Player)otherPlayer).mName); + } + + public static class Pref { + /** The types of player preferences. */ + @StringDef({ALARM_DEFAULT_VOLUME, ALARM_FADE_SECONDS, ALARM_SNOOZE_SECONDS, ALARM_TIMEOUT_SECONDS, + ALARMS_ENABLED, PLAY_TRACK_ALBUM, DEFEAT_DESTRUCTIVE_TTP}) + @Retention(RetentionPolicy.SOURCE) + public @interface Name {} + public static final String ALARM_DEFAULT_VOLUME = "alarmDefaultVolume"; + public static final String ALARM_FADE_SECONDS = "alarmfadeseconds"; + public static final String ALARM_SNOOZE_SECONDS = "alarmSnoozeSeconds"; + public static final String ALARM_TIMEOUT_SECONDS = "alarmTimeoutSeconds"; + public static final String ALARMS_ENABLED = "alarmsEnabled"; + public static final String PLAY_TRACK_ALBUM = "playtrackalbum"; + public static final String DEFEAT_DESTRUCTIVE_TTP = "defeatDestructiveTouchToPlay"; + public static final String MEDIA_DIRS = "mediadirs"; + } + + public Player(Map record) { + setId(getString(record, "playerid")); + mIp = getString(record, "ip"); + mName = getString(record, "name"); + mModel = getString(record, "model"); + mCanPowerOff = getInt(record, "canpoweroff") == 1; + mConnected = getInt(record, "connected") == 1; + mHashCode = calcHashCode(); + + if (record.containsKey(Pref.PLAY_TRACK_ALBUM)) { + mPlayerState.prefs.put(Pref.PLAY_TRACK_ALBUM, Util.getString(record, Pref.PLAY_TRACK_ALBUM)); + } + if (record.containsKey(Pref.DEFEAT_DESTRUCTIVE_TTP)) { + mPlayerState.prefs.put(Pref.DEFEAT_DESTRUCTIVE_TTP, Util.getString(record, Pref.DEFEAT_DESTRUCTIVE_TTP)); + } + } + + private HashCode calcHashCode() { + return mHashFunction.hashString(getId(), Charsets.UTF_8); + } + + private Player(Parcel source) { + setId(source.readString()); + mIp = source.readString(); + mName = source.readString(); + mModel = source.readString(); + mCanPowerOff = (source.readByte() == 1); + mConnected = (source.readByte() == 1); + mHashCode = HashCode.fromString(source.readString()); + } + + @NonNull + @Override + public String getName() { + return mName; + } + + public Player setName(@NonNull String name) { + this.mName = name; + return this; + } + + public String getIp() { + return mIp; + } + + public String getModel() { + return mModel; + } + + public boolean isCanpoweroff() { + return mCanPowerOff; + } + + public void setConnected(boolean connected) { + mConnected = connected; + } + + public boolean getConnected() { + return mConnected; + } + + @NonNull + public PlayerState getPlayerState() { + return mPlayerState; + } + + public void setPlayerState(@NonNull PlayerState playerState) { + mPlayerState = playerState; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Player[] newArray(int size) { + return new Player[size]; + } + + @Override + public Player createFromParcel(Parcel source) { + return new Player(source); + } + }; + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getId()); + dest.writeString(mIp); + dest.writeString(mName); + dest.writeString(mModel); + dest.writeByte(mCanPowerOff ? (byte) 1 : (byte) 0); + dest.writeByte(mConnected ? (byte) 1 : (byte) 0); + dest.writeString(mHashCode.toString()); + } + + /** + * Returns a 64 bit identifier for the player. The ID tracked by the server is a unique + * string that identifies the player. It may be -- but is not required to be -- the + * player's MAC address. Rather than assume it is the MAC address, calculate a 64 bit + * hash of the ID and use that. + * + * @return The hash of the player's ID. + */ + public long getIdAsLong() { + return mHashCode.asLong(); + } + + /** + * Comparator to compare two players by ID. + */ + public static final Comparator compareById = new Comparator() { + @Override + public int compare(Player lhs, Player rhs) { + return lhs.getId().compareTo(rhs.getId()); + } + }; + + @NonNull + @Override + public String toString() { + return "Player{" + + "mName='" + mName + '\'' + + ", mIp='" + mIp + '\'' + + ", mModel='" + mModel + '\'' + + ", mCanPowerOff=" + mCanPowerOff + + ", mHashCode=" + mHashCode + + ", mPlayerState=" + mPlayerState + + ", mConnected=" + mConnected + + '}'; + } + + public SongTimeChanged getTrackElapsed() { + double now = SystemClock.elapsedRealtime() / 1000.0; + double trackCorrection = mPlayerState.rate * (now - mPlayerState.statusSeen); + double trackElapsed = (trackCorrection <= 0 ? mPlayerState.getCurrentTimeSecond() : mPlayerState.getCurrentTimeSecond() + trackCorrection); + + return new SongTimeChanged(this, (int) trackElapsed, mPlayerState.getCurrentSongDuration()); + } + + public int getSleepingIn() { + double now = SystemClock.elapsedRealtime() / 1000.0; + double correction = now - mPlayerState.statusSeen; + double remaining = (correction <= 0 ? mPlayerState.getSleep() : mPlayerState.getSleep() - correction); + + return (int) remaining; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/PlayerState.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/PlayerState.java new file mode 100644 index 000000000..db90aa5d7 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/PlayerState.java @@ -0,0 +1,500 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; + +import com.google.common.collect.ImmutableList; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.framework.EnumIdLookup; +import uk.org.ngo.squeezer.framework.EnumWithId; + + +public class PlayerState implements Parcelable { + + public PlayerState() { + } + + public static final Creator CREATOR = new Creator() { + @Override + public PlayerState[] newArray(int size) { + return new PlayerState[size]; + } + + @Override + public PlayerState createFromParcel(Parcel source) { + return new PlayerState(source); + } + }; + + private PlayerState(Parcel source) { + playStatus = source.readString(); + poweredOn = (source.readByte() == 1); + shuffleStatus = ShuffleStatus.valueOf(source.readInt()); + repeatStatus = RepeatStatus.valueOf(source.readInt()); + currentSong = source.readParcelable(getClass().getClassLoader()); + currentPlaylist = source.readString(); + currentPlaylistTimestamp = source.readLong(); + currentPlaylistIndex = source.readInt(); + currentTimeSecond = source.readDouble(); + currentSongDuration = source.readInt(); + currentVolume = source.readInt(); + sleepDuration = source.readInt(); + sleep = source.readInt(); + mSyncMaster = source.readString(); + source.readStringList(mSyncSlaves); + mPlayerSubscriptionType = PlayerSubscriptionType.valueOf(source.readString()); + prefs = source.readHashMap(getClass().getClassLoader()); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(playStatus); + dest.writeByte(poweredOn ? (byte) 1 : (byte) 0); + dest.writeInt(shuffleStatus.getId()); + dest.writeInt(repeatStatus.getId()); + dest.writeParcelable(currentSong, flags); + dest.writeString(currentPlaylist); + dest.writeLong(currentPlaylistTimestamp); + dest.writeInt(currentPlaylistIndex); + dest.writeDouble(currentTimeSecond); + dest.writeInt(currentSongDuration); + dest.writeInt(currentVolume); + dest.writeInt(sleepDuration); + dest.writeDouble(sleep); + dest.writeString(mSyncMaster); + dest.writeStringList(mSyncSlaves); + dest.writeString(mPlayerSubscriptionType.name()); + dest.writeMap(prefs); + } + + @Override + public int describeContents() { + return 0; + } + + private boolean poweredOn; + + private @PlayState String playStatus; + + private ShuffleStatus shuffleStatus; + + private RepeatStatus repeatStatus; + + private CurrentPlaylistItem currentSong; + + /** The name of the current playlist, which may be the empty string. */ + @NonNull + private String currentPlaylist; + + private long currentPlaylistTimestamp; + + private int currentPlaylistTracksNum; + + private int currentPlaylistIndex; + + private boolean remote; + + public boolean waitingToPlay; + + public double rate; + + private double currentTimeSecond; + + private int currentSongDuration; + + public double statusSeen; + + private int currentVolume = -1; + + private int sleepDuration; + + private double sleep; + + /** The player this player is synced to (null if none). */ + @Nullable + private String mSyncMaster; + + /** The players synced to this player. */ + private ImmutableList mSyncSlaves = new ImmutableList.Builder().build(); + + /** How the server is subscribed to the player's status changes. */ + @NonNull + private PlayerSubscriptionType mPlayerSubscriptionType = PlayerSubscriptionType.NOTIFY_NONE; + + /** Map of current values of our the playerprefs we track. See the specific SlimClient */ + @NonNull + public Map prefs = new HashMap<>(); + + public boolean isPlaying() { + return PLAY_STATE_PLAY.equals(playStatus); + } + + /** + * @return the player's state. May be null, which indicates that Squeezer has received + * a "players" response for this player, but has not yet received a status message + * for it. + */ + @Nullable + @PlayState + public String getPlayStatus() { + return playStatus; + } + + public boolean setPlayStatus(@NonNull @PlayState String s) { + if (s.equals(playStatus)) { + return false; + } + + playStatus = s; + + return true; + } + + public boolean isPoweredOn() { + return poweredOn; + } + + public boolean setPoweredOn(boolean state) { + if (state == poweredOn) + return false; + + poweredOn = state; + return true; + } + + public ShuffleStatus getShuffleStatus() { + return shuffleStatus; + } + + public boolean setShuffleStatus(ShuffleStatus status) { + if (status == shuffleStatus) + return false; + + shuffleStatus = status; + return true; + } + + public boolean setShuffleStatus(String s) { + return setShuffleStatus(s != null ? ShuffleStatus.valueOf(Util.getInt(s)) : null); + } + + public RepeatStatus getRepeatStatus() { + return repeatStatus; + } + + public boolean setRepeatStatus(RepeatStatus status) { + if (status == repeatStatus) + return false; + + repeatStatus = status; + return true; + } + + public boolean setRepeatStatus(String s) { + return setRepeatStatus(s != null ? RepeatStatus.valueOf(Util.getInt(s)) : null); + } + + public CurrentPlaylistItem getCurrentSong() { + return currentSong; + } + + public boolean setCurrentSong(CurrentPlaylistItem song) { + if (song.equals(currentSong)) + return false; + + currentSong = song; + return true; + } + + /** @return the name of the current playlist, may be the empty string. */ + @NonNull + public String getCurrentPlaylist() { + return currentPlaylist; + } + + public long getCurrentPlaylistTimestamp() { + return currentPlaylistTimestamp; + } + + public boolean setCurrentPlaylistTimestamp(long value) { + if (value == currentPlaylistTimestamp) + return false; + + currentPlaylistTimestamp = value; + return true; + } + + /** @return the number of tracks in the current playlist */ + public int getCurrentPlaylistTracksNum() { + return currentPlaylistTracksNum; + } + + public int getCurrentPlaylistIndex() { + return currentPlaylistIndex; + } + + public void setCurrentPlaylist(@Nullable String playlist) { + if (playlist == null) + playlist = ""; + currentPlaylist = playlist; + } + + // set the number of tracks in the current playlist + public void setCurrentPlaylistTracksNum(int value) { + currentPlaylistTracksNum = value; + } + + public void setCurrentPlaylistIndex(int value) { + currentPlaylistIndex = value; + } + + public boolean isRemote() { + return remote; + } + + public void setRemote(boolean remote) { + this.remote = remote; + } + + public double getCurrentTimeSecond() { + return currentTimeSecond; + } + + public boolean setCurrentTimeSecond(double value) { + if (value == currentTimeSecond) + return false; + + currentTimeSecond = value; + return true; + } + + public int getCurrentSongDuration() { + return currentSongDuration; + } + + public boolean setCurrentSongDuration(int value) { + if (value == currentSongDuration) + return false; + + currentSongDuration = value; + return true; + } + + public int getCurrentVolume() { + return currentVolume; + } + + public boolean setCurrentVolume(int value) { + if (value == currentVolume) + return false; + + int current = currentVolume; + currentVolume = value; + return (current != -1); // Do not report a change if previous volume was unknown + } + + public int getSleepDuration() { + return sleepDuration; + } + + public boolean setSleepDuration(int sleepDuration) { + if (sleepDuration == this.sleepDuration) + return false; + + this.sleepDuration = sleepDuration; + return true; + } + + /** @return seconds left until the player sleeps. */ + public double getSleep() { + return sleep; + } + + /** + * + * @param sleep seconds left until the player sleeps. + * @return True if the sleep value was changed, false otherwise. + */ + public boolean setSleep(double sleep) { + if (sleep == this.sleep) + return false; + + this.sleep = sleep; + return true; + } + + public boolean setSyncMaster(@Nullable String syncMaster) { + if (syncMaster == null && mSyncMaster == null) + return false; + + if (syncMaster != null) { + if (syncMaster.equals(mSyncMaster)) + return false; + } + + mSyncMaster = syncMaster; + return true; + } + + @Nullable + public String getSyncMaster() { + return mSyncMaster; + } + + public boolean setSyncSlaves(@NonNull List syncSlaves) { + if (syncSlaves.equals(mSyncSlaves)) + return false; + + mSyncSlaves = ImmutableList.copyOf(syncSlaves); + return true; + } + + public ImmutableList getSyncSlaves() { + return mSyncSlaves; + } + + public PlayerSubscriptionType getSubscriptionType() { + return mPlayerSubscriptionType; + } + + public void setSubscriptionType(PlayerSubscriptionType type) { + mPlayerSubscriptionType = type; + } + + @StringDef({PLAY_STATE_PLAY, PLAY_STATE_PAUSE, PLAY_STATE_STOP}) + @Retention(RetentionPolicy.SOURCE) + public @interface PlayState {} + public static final String PLAY_STATE_PLAY = "play"; + public static final String PLAY_STATE_PAUSE = "pause"; + public static final String PLAY_STATE_STOP = "stop"; + + @Override + public String toString() { + return "PlayerState{" + + "poweredOn=" + poweredOn + + ", playStatus='" + playStatus + '\'' + + ", shuffleStatus=" + shuffleStatus + + ", repeatStatus=" + repeatStatus + + ", currentSong=" + currentSong + + ", currentPlaylist='" + currentPlaylist + '\'' + + ", currentPlaylistIndex=" + currentPlaylistIndex + + ", currentTimeSecond=" + currentTimeSecond + + ", currentSongDuration=" + currentSongDuration + + ", currentVolume=" + currentVolume + + ", sleepDuration=" + sleepDuration + + ", sleep=" + sleep + + ", mSyncMaster='" + mSyncMaster + '\'' + + ", mSyncSlaves=" + mSyncSlaves + + ", mPlayerSubscriptionType='" + mPlayerSubscriptionType + '\'' + + '}'; + } + + public enum PlayerSubscriptionType { + NOTIFY_NONE("-"), + NOTIFY_ON_CHANGE("600"); + + private final String status; + + PlayerSubscriptionType(String status) { + this.status = status; + } + + public String getStatus() { + return status; + } + } + + public enum ShuffleStatus implements EnumWithId { + SHUFFLE_OFF(0, R.drawable.btn_shuffle), + SHUFFLE_SONG(1, R.drawable.btn_shuffle_song), + SHUFFLE_ALBUM(2, R.drawable.btn_shuffle_album); + + private final int id; + + private final int icon; + + private static final EnumIdLookup lookup = new EnumIdLookup<>( + ShuffleStatus.class); + + ShuffleStatus(int id, int icon) { + this.id = id; + this.icon = icon; + } + + @Override + public int getId() { + return id; + } + + @DrawableRes + public int getIcon() { + return icon; + } + + public static ShuffleStatus valueOf(int id) { + return lookup.get(id); + } + } + + public enum RepeatStatus implements EnumWithId { + REPEAT_OFF(0, R.drawable.btn_repeat), + REPEAT_ONE(1, R.drawable.btn_repeat_one), + REPEAT_ALL(2, R.drawable.btn_repeat_all); + + private final int id; + + private final int icon; + + private static final EnumIdLookup lookup = new EnumIdLookup<>( + RepeatStatus.class); + + RepeatStatus(int id, int icon) { + this.id = id; + this.icon = icon; + } + + @Override + public int getId() { + return id; + } + + public int getIcon() { + return icon; + } + + public static RepeatStatus valueOf(int id) { + return lookup.get(id); + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Slider.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Slider.java new file mode 100644 index 000000000..7537e02d4 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Slider.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2020 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; +import android.os.Parcelable; + +public class Slider implements Parcelable { + public int min; + public int max; + public int adjust; + public int initial; + public String sliderIcons; + public String help; + + public Slider() { + } + + protected Slider(Parcel in) { + min = in.readInt(); + max = in.readInt(); + adjust = in.readInt(); + initial = in.readInt(); + sliderIcons = in.readString(); + help = in.readString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(min); + dest.writeInt(max); + dest.writeInt(adjust); + dest.writeInt(initial); + dest.writeString(sliderIcons); + dest.writeString(help); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Slider createFromParcel(Parcel in) { + return new Slider(in); + } + + @Override + public Slider[] newArray(int size) { + return new Slider[size]; + } + }; +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/SlimCommand.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/SlimCommand.java new file mode 100644 index 000000000..afd634afa --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/SlimCommand.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2020 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.Util; + +/** + * Data for a command to LMS + */ +public class SlimCommand implements Parcelable { + /** Array of command terms, f.e. ['playlist', 'jump'] */ + public final List cmd = new ArrayList<>(); + + /** Hash of parameters, f.e. {sort = new}. Passed to the server in the form "key:value", f.e. 'sort:new'. */ + public final Map params = new HashMap<>(); + + public SlimCommand() { + } + + protected SlimCommand(Parcel in) { + cmd(in.createStringArrayList()); + params(Util.mapify(in.createStringArray())); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeStringList(cmd); + String[] tokens = new String[params.size()]; + int i = 0; + for (Map.Entry entry : params.entrySet()) { + tokens[i++] = entry.getKey() + ":" + entry.getValue(); + } + dest.writeStringArray(tokens); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public SlimCommand createFromParcel(Parcel in) { + return new SlimCommand(in); + } + + @Override + public SlimCommand[] newArray(int size) { + return new SlimCommand[size]; + } + }; + + public SlimCommand cmd(String... commandTerms) { + return cmd(Arrays.asList(commandTerms)); + } + + public SlimCommand cmd(List commandTerms) { + cmd.addAll(commandTerms); + return this; + } + + public SlimCommand params(Map params) { + this.params.putAll(params); + return this; + } + + public SlimCommand param(String tag, Object value) { + params.put(tag, value); + return this; + } + + public String[] cmd() { + return cmd.toArray(new String[0]); + } + + @NonNull + @Override + public String toString() { + return "Command{" + + "cmd=" + cmd + + ", params=" + params + + '}'; + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Song.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Song.java new file mode 100644 index 000000000..fcc800537 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Song.java @@ -0,0 +1,53 @@ +package uk.org.ngo.squeezer.model; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.Map; + +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.download.DownloadFilenameStructure; +import uk.org.ngo.squeezer.download.DownloadPathStructure; + +public class Song { + public String id; + public String title; + public int trackNum; + public String artist; + public String album; + public String albumArtist; + + @NonNull + public Uri url; + + public Song(Map record) { + id = Util.getString(record, "id"); + title = Util.getString(record, "title"); + trackNum = Util.getInt(record, "tracknum", 1); + artist = Util.getString(record, "artist"); + album = Util.getString(record, "album"); + boolean compilation = (Util.getInt(record, "compilation", 0) == 1); + albumArtist = (compilation ? "Various" : artist); // TODO maybe use the artist role tag ("A") + url = Uri.parse(Util.getStringOrEmpty(record, "url")); + } + + public String getLocalPath(DownloadPathStructure downloadPathStructure, DownloadFilenameStructure downloadFilenameStructure) { + return new File(downloadPathStructure.get(this), downloadFilenameStructure.get(this)).getPath(); + } + + @NonNull + @Override + public String toString() { + return "Song{" + + "id='" + id + '\'' + + ", title='" + title + '\'' + + ", trackNum=" + trackNum + + ", artist='" + artist + '\'' + + ", album='" + album + '\'' + + ", albumArtist='" + albumArtist + '\'' + + ", url='" + url + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Window.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Window.java new file mode 100644 index 000000000..34fbdd26d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/model/Window.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.model; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Implements of the LMS SqueezePlay interface. + * http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#.3Cwindow_fields.3E + */ +public class Window implements Parcelable { + public String text; + public String textarea; + public String textareaToken; + @NonNull public Uri icon = Uri.EMPTY; + public String titleStyle; + public WindowStyle windowStyle; + public String help; + public String windowId; + + public Window() { + } + + protected Window(Parcel in) { + text = in.readString(); + textarea = in.readString(); + textareaToken = in.readString(); + icon = Uri.parse(in.readString()); + titleStyle = in.readString(); + windowStyle = WindowStyle.valueOf(in.readString()); + help = in.readString(); + windowId = in.readString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(text); + dest.writeString(textarea); + dest.writeString(textareaToken); + dest.writeString(icon.toString()); + dest.writeString(titleStyle); + dest.writeString(windowStyle.name()); + dest.writeString(help); + dest.writeString(windowId); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Window createFromParcel(Parcel in) { + return new Window(in); + } + + @Override + public Window[] newArray(int size) { + return new Window[size]; + } + }; + + @Override + public String toString() { + return "Window{" + + "text='" + text + '\'' + + ", textarea='" + textarea + '\'' + + ", textareaToken='" + textareaToken + '\'' + + ", icon='" + icon + '\'' + + ", titleStyle='" + titleStyle + '\'' + + ", windowStyle='" + windowStyle + '\'' + + ", help=" + help + + ", windowId='" + windowId + '\'' + + '}'; + } + + /** + * Window styles from LMS + *

+ * NOTE:
+ * home_menu from LMS just means "hasImage", whereas we use it for Home Menu Items + * http://wiki.slimdevices.com/index.php/HomeMenuItemsVersusSlimbrowseItems + */ + public enum WindowStyle { + HOME_MENU("home_menu"), + ICON_LIST("icon_list"), + PLAY_LIST("play_list"), + TEXT_ONLY("text_list"); + + private static Map ENUM_MAP = initEnumMap(); + private final String id; + + WindowStyle(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + private static Map initEnumMap() { + Map map = new HashMap<>(); + for (WindowStyle windowStyle : WindowStyle.values()) { + map.put(windowStyle.id, windowStyle); + } + return Collections.unmodifiableMap(map); + } + + public static WindowStyle get(String name) { + return ENUM_MAP.get(name); + } + } +} \ No newline at end of file diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseClient.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseClient.java new file mode 100644 index 000000000..caa170b58 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseClient.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import android.os.SystemClock; +import androidx.annotation.NonNull; + +import com.google.common.base.Splitter; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import de.greenrobot.event.EventBus; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Squeezer; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.model.SlimCommand; +import uk.org.ngo.squeezer.service.event.MusicChanged; +import uk.org.ngo.squeezer.service.event.PlayStatusChanged; +import uk.org.ngo.squeezer.service.event.PlayerStateChanged; +import uk.org.ngo.squeezer.service.event.PlayerVolume; +import uk.org.ngo.squeezer.service.event.PlaylistChanged; +import uk.org.ngo.squeezer.service.event.PowerStatusChanged; +import uk.org.ngo.squeezer.service.event.RepeatStatusChanged; +import uk.org.ngo.squeezer.service.event.ShuffleStatusChanged; + +abstract class BaseClient implements SlimClient { + final static int mPageSize = Squeezer.getContext().getResources().getInteger(R.integer.PageSize); + + final AtomicReference username = new AtomicReference<>(); + final AtomicReference password = new AtomicReference<>(); + + final ConnectionState mConnectionState; + + /** Shared event bus for status changes. */ + @NonNull final EventBus mEventBus; + + /** The prefix for URLs for downloads and cover art. */ + String mUrlPrefix; + + BaseClient(@NonNull EventBus eventBus) { + mEventBus = eventBus; + mConnectionState = new ConnectionState(eventBus); + } + + @Override + public ConnectionState getConnectionState() { + return mConnectionState; + } + + @Override + public void requestItems(Player player, String[] cmd, Map params, int start, int pageSize, IServiceItemListCallback callback) { + final BaseClient.BrowseRequest browseRequest = new BaseClient.BrowseRequest<>(player, cmd, params, start, pageSize, callback); + internalRequestItems(browseRequest); + } + + protected abstract void internalRequestItems(BrowseRequest browseRequest); + + @Override + public String getUsername() { + return username.get(); + } + + @Override + public String getPassword() { + return password.get(); + } + + @Override + public String getUrlPrefix() { + return mUrlPrefix; + } + + void parseStatus(final Player player, CurrentPlaylistItem currentSong, Map tokenMap) { + PlayerState playerState = player.getPlayerState(); + playerState.statusSeen = SystemClock.elapsedRealtime() / 1000.0; + + boolean changedPower = playerState.setPoweredOn(Util.getInt(tokenMap, "power") == 1); + boolean changedShuffleStatus = playerState.setShuffleStatus(Util.getString(tokenMap, "playlist shuffle")); + boolean changedRepeatStatus = playerState.setRepeatStatus(Util.getString(tokenMap, "playlist repeat")); + boolean changedPlaylist = playerState.setCurrentPlaylistTimestamp(Util.getLong(tokenMap, "playlist_timestamp")); + playerState.setCurrentPlaylistTracksNum(Util.getInt(tokenMap, "playlist_tracks")); + playerState.setCurrentPlaylistIndex(Util.getInt(tokenMap, "playlist_cur_index")); + playerState.setCurrentPlaylist(Util.getString(tokenMap, "playlist_name")); + boolean changedSleep = playerState.setSleep(Util.getInt(tokenMap, "will_sleep_in")); + boolean changedSleepDuration = playerState.setSleepDuration(Util.getInt(tokenMap, "sleep")); + if (currentSong == null) currentSong = new CurrentPlaylistItem(tokenMap); + boolean changedSong = playerState.setCurrentSong(currentSong); + playerState.setRemote(Util.getInt(tokenMap, "remote") == 1); + playerState.waitingToPlay = Util.getInt(tokenMap, "waitingToPlay") == 1; + playerState.rate = Util.getDouble(tokenMap, "rate"); + boolean changedSongDuration = playerState.setCurrentSongDuration(Util.getInt(tokenMap, "duration")); + boolean changedSongTime = playerState.setCurrentTimeSecond(Util.getDouble(tokenMap, "time")); + boolean changedVolume = playerState.setCurrentVolume(Util.getInt(tokenMap, "mixer volume")); + boolean changedSyncMaster = playerState.setSyncMaster(Util.getString(tokenMap, "sync_master")); + boolean changedSyncSlaves = playerState.setSyncSlaves(Splitter.on(",").omitEmptyStrings().splitToList(Util.getStringOrEmpty(tokenMap, "sync_slaves"))); + + player.setPlayerState(playerState); + + // Kept as its own method because other methods call it, unlike the explicit + // calls to the callbacks below. + updatePlayStatus(player, Util.getString(tokenMap, "mode")); + + // Current playlist + if (changedPlaylist) { + mEventBus.post(new PlaylistChanged(player)); + } + + if (changedPower || changedSleep || changedSleepDuration || changedVolume + || changedSong || changedSongDuration || changedSongTime + || changedSyncMaster || changedSyncSlaves) { + postPlayerStateChanged(player); + } + + // Volume + if (changedVolume) { + mEventBus.post(new PlayerVolume(playerState.getCurrentVolume(), player)); + } + + // Power status + if (changedPower) { + mEventBus.post(new PowerStatusChanged(player)); + } + + // Current song + if (changedSong) { + mEventBus.postSticky(new MusicChanged(player, playerState)); + } + + // Shuffle status. + if (changedShuffleStatus) { + mEventBus.post(new ShuffleStatusChanged(player, playerState.getShuffleStatus())); + } + + // Repeat status. + if (changedRepeatStatus) { + mEventBus.post(new RepeatStatusChanged(player, playerState.getRepeatStatus())); + } + + // Position in song + if (changedSongDuration || changedSongTime) { + postSongTimeChanged(player); + } + } + + protected void postSongTimeChanged(Player player) { + mEventBus.post(player.getTrackElapsed()); + } + + protected void postPlayerStateChanged(Player player) { + mEventBus.post(new PlayerStateChanged(player)); + } + + private void updatePlayStatus(Player player, String playStatus) { + // Handle unknown states. + if (!playStatus.equals(PlayerState.PLAY_STATE_PLAY) && + !playStatus.equals(PlayerState.PLAY_STATE_PAUSE) && + !playStatus.equals(PlayerState.PLAY_STATE_STOP)) { + return; + } + + PlayerState playerState = player.getPlayerState(); + + if (playerState.setPlayStatus(playStatus)) { + mEventBus.post(new PlayStatusChanged(playStatus, player)); + } + } + + protected static class BrowseRequest extends SlimCommand { + private final Player player; + private final boolean fullList; + private int start; + private int itemsPerResponse; + private final IServiceItemListCallback callback; + + BrowseRequest(Player player, String[] cmd, Map params, int start, int itemsPerResponse, IServiceItemListCallback callback) { + this.player = player; + this.cmd(cmd); + this.fullList = (start < 0); + this.start = (fullList ? 0 : start); + this.itemsPerResponse = itemsPerResponse; + this.callback = callback; + if (params != null) this.params(params); + } + + public BrowseRequest update(int start, int itemsPerResponse) { + this.start = start; + this.itemsPerResponse = itemsPerResponse; + return this; + } + + public Player getPlayer() { + return player; + } + + boolean isFullList() { + return (fullList); + } + + public int getStart() { + return start; + } + + int getItemsPerResponse() { + return itemsPerResponse; + } + + public IServiceItemListCallback getCallback() { + return callback; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseListHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseListHandler.java new file mode 100644 index 000000000..6f0fbf380 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/BaseListHandler.java @@ -0,0 +1,63 @@ +package uk.org.ngo.squeezer.service; + +import androidx.fragment.app.Fragment.InstantiationException; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import uk.org.ngo.squeezer.util.Reflection; + +/** + * Base class that constructs a list of model objects based on CLI results from + * the server. + * + * @param Item subclasses. + */ +abstract class BaseListHandler implements ListHandler { + private static final String TAG = BaseListHandler.class.getSimpleName(); + + private List items; + + @SuppressWarnings("unchecked") + private final Class dataType = (Class) Reflection + .getGenericClass(this.getClass(), ListHandler.class, 0); + + private Constructor constructor; + + @Override + public Class getDataType() { + return dataType; + } + + @Override + public List getItems() { + return items; + } + + @Override + public void clear() { + items = new ArrayList() { + private static final long serialVersionUID = 1321113152942485275L; + }; + } + + @Override + public void add(Map record) { + if (constructor == null) { + try { + constructor = dataType.getDeclaredConstructor(Map.class); + } catch (Exception e) { + throw new InstantiationException( + "Unable to create constructor for " + dataType.getName(), e); + } + } + try { + items.add(constructor.newInstance(record)); + } catch (Exception e) { + throw new InstantiationException("Unable to create new " + dataType.getName(), e); + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/CometClient.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/CometClient.java new file mode 100644 index 000000000..bc1a4b7a2 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/CometClient.java @@ -0,0 +1,918 @@ +/* + * Copyright (c) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import android.text.TextUtils; +import android.util.Log; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; + +import org.cometd.bayeux.Channel; +import org.cometd.bayeux.Message; +import org.cometd.bayeux.client.ClientSessionChannel; +import org.cometd.client.BayeuxClient; +import org.cometd.client.transport.ClientTransport; +import org.cometd.client.transport.HttpClientTransport; +import org.cometd.client.transport.TransportListener; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.util.B64Code; + +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import de.greenrobot.event.EventBus; +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.AlertWindow; +import uk.org.ngo.squeezer.model.DisplayMessage; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Alarm; +import uk.org.ngo.squeezer.model.AlarmPlaylist; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.MusicFolderItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.model.SlimCommand; +import uk.org.ngo.squeezer.model.Song; +import uk.org.ngo.squeezer.service.event.AlertEvent; +import uk.org.ngo.squeezer.service.event.DisplayEvent; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.model.MenuStatusMessage; +import uk.org.ngo.squeezer.service.event.PlayerPrefReceived; +import uk.org.ngo.squeezer.service.event.PlayerVolume; +import uk.org.ngo.squeezer.service.event.RegisterSqueezeNetwork; +import uk.org.ngo.squeezer.util.Reflection; + +class CometClient extends BaseClient { + private static final String TAG = CometClient.class.getSimpleName(); + + /** The maximum number of milliseconds to wait before considering a request to the LMS failed */ + private static final int LONG_POLLING_TIMEOUT = 120_000; + + /** {@link java.util.regex.Pattern} that splits strings on forward slash. */ + private static final Pattern mSlashSplitPattern = Pattern.compile("/"); + + /** The channel to publish one-shot requests to. */ + private static final String CHANNEL_SLIM_REQUEST = "/slim/request"; + + /** The format string for the channel to listen to for responses to one-shot requests. */ + private static final String CHANNEL_SLIM_REQUEST_RESPONSE_FORMAT = "/%s/slim/request/%s"; + + /** The channel to publish subscription requests to. */ + private static final String CHANNEL_SLIM_SUBSCRIBE = "/slim/subscribe"; + + /** The channel to publish unsubscribe requests to. */ + private static final String CHANNEL_SLIM_UNSUBSCRIBE = "/slim/unsubscribe"; + + /** The format string for the channel to listen to for server status events. */ + private static final String CHANNEL_SERVER_STATUS_FORMAT = "/%s/slim/serverstatus"; + + /** The format string for the channel to listen to for player status events. */ + private static final String CHANNEL_PLAYER_STATUS_FORMAT = "/%s/slim/playerstatus/%s"; + + /** The format string for the channel to listen to for display status events. */ + private static final String CHANNEL_DISPLAY_STATUS_FORMAT = "/%s/slim/displaystatus/%s"; + + /** The format string for the channel to listen to for menu status events. */ + private static final String CHANNEL_MENU_STATUS_FORMAT = "/%s/slim/menustatus/%s"; + + // Maximum time for wait replies for server capabilities + private static final long HANDSHAKE_TIMEOUT = 4000; + + + /** Handler for off-main-thread work. */ + @NonNull + private final Handler mBackgroundHandler; + + /** Map from an item request command ("players") to the listener class for responses. */ + private final Map, ItemListener> mItemRequestMap; + + /** Map from a request to the listener class for responses. */ + private final Map mRequestMap; + + /** Client to the comet server. */ + @Nullable + private BayeuxClient mBayeuxClient; + + private final Map mPendingRequests + = new ConcurrentHashMap<>(); + + private final Map> mPendingBrowseRequests + = new ConcurrentHashMap<>(); + + private final Queue mCommandQueue = new LinkedList<>(); + private boolean mCurrentCommand; + + private final PublishListener mPublishListener = new PublishListener(); + + // All requests are tagged with a correlation id, which can be used when + // asynchronous responses are received. + private volatile int mCorrelationId = 0; + + CometClient(@NonNull EventBus eventBus) { + super(eventBus); + + HandlerThread handlerThread = new HandlerThread(SqueezeService.class.getSimpleName()); + handlerThread.start(); + mBackgroundHandler = new CliHandler(handlerThread.getLooper()); + + List> itemListeners = Arrays.asList( + new AlarmsListener(), + new AlarmPlaylistsListener(), + new SongListener(), + new MusicFolderListener(), + new JiveItemListener() + ); + ImmutableMap.Builder, ItemListener> builder = ImmutableMap.builder(); + for (ItemListener itemListener : itemListeners) { + builder.put(itemListener.getDataType(), itemListener); + } + mItemRequestMap = builder.build(); + + mRequestMap = ImmutableMap.builder() + .put("playerpref", (player, request, message) -> { + //noinspection WrongConstant + String p2 = (String) message.getDataAsMap().get("_p2"); + mEventBus.post(new PlayerPrefReceived(player, request.cmd.get(1), p2 != null ? p2 : request.cmd.get(2))); + }) + .put("mixer", (player, request, message) -> { + if (request.cmd.get(1).equals("volume")) { + String volume = (String) message.getDataAsMap().get("_volume"); + if (volume != null) { + int newVolume = Integer.valueOf(volume); + player.getPlayerState().setCurrentVolume(newVolume); + mEventBus.post(new PlayerVolume(newVolume, player)); + } else { + command(player, new String[]{"mixer", "volume", "?"}, Collections.emptyMap()); + } + } + }) + .build(); + } + + // Shims around ConnectionState methods. + @Override + public void startConnect(final SqueezeService service) { + Log.i(TAG, "startConnect()"); + // Start the background connect + mBackgroundHandler.post(new Runnable() { + @Override + public void run() { + final Preferences preferences = new Preferences(service); + preferences.setManualDisconnect(false); + final Preferences.ServerAddress serverAddress = preferences.getServerAddress(); + final String username = preferences.getUsername(serverAddress); + final String password = preferences.getPassword(serverAddress); + Log.i(TAG, "Connecting to: " + username + "@" + serverAddress.address()); + + if (!mEventBus.isRegistered(CometClient.this)) { + mEventBus.register(CometClient.this); + } + mConnectionState.setConnectionState(ConnectionState.CONNECTION_STARTED); + final boolean isSqueezeNetwork = serverAddress.squeezeNetwork; + + final HttpClient httpClient = new HttpClient(); + httpClient.setUserAgentField(new HttpField(HttpHeader.USER_AGENT, "Squeezer-squeezer/" + SqueezerBayeuxExtension.getRevision())); + try { + httpClient.start(); + } catch (Exception e) { + mConnectionState.setConnectionError(ConnectionError.START_CLIENT_ERROR); + return; + } + + CometClient.this.username.set(username); + CometClient.this.password.set(password); + + mUrlPrefix = "http://" + serverAddress.address(); + final String url = mUrlPrefix + "/cometd"; + try { + // Neither URLUtil.isValidUrl nor Patterns.WEB_URL works as expected + // Not even create of URL and URI throws reliably so we add some extra checks + URI uri = new URL(url).toURI(); + if (!( + TextUtils.equals(uri.getHost(), serverAddress.host()) + && uri.getPort() == serverAddress.port() + && TextUtils.equals(uri.getPath(), "/cometd") + )) { + throw new IllegalArgumentException("Invalid url: " + url); + } + } catch (Exception e) { + mConnectionState.setConnectionError(ConnectionError.INVALID_URL); + return; + } + + // Set the VM-wide authentication handler (needed by image fetcher and other using + // the standard java http API) + Authenticator.setDefault(new Authenticator() { + @Override + public PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password.toCharArray()); + } + }); + + Map options = new HashMap<>(); + options.put(HttpClientTransport.MAX_NETWORK_DELAY_OPTION, LONG_POLLING_TIMEOUT); + ClientTransport clientTransport; + if (!isSqueezeNetwork) { + clientTransport = new HttpStreamingTransport(url, options, httpClient) { + @Override + protected void customize(org.eclipse.jetty.client.api.Request request) { + if (username != null && password != null) { + String authorization = B64Code.encode(username + ":" + password); + request.header(HttpHeader.AUTHORIZATION, "Basic " + authorization); + } + } + }; + } else { + clientTransport = new HttpStreamingTransport(url, options, httpClient) { + // SN only replies the first connect message + private boolean hasSendConnect; + + @Override + public void send(TransportListener listener, List messages) { + boolean isConnect = Channel.META_CONNECT.equals(messages.get(0).getChannel()); + if (!(isConnect && hasSendConnect)) { + super.send(listener, messages); + if (isConnect) { + hasSendConnect = true; + } + } + } + }; + } + mBayeuxClient = new SqueezerBayeuxClient(url, clientTransport); + mBayeuxClient.addExtension(new SqueezerBayeuxExtension()); + mBayeuxClient.getChannel(Channel.META_HANDSHAKE).addListener((ClientSessionChannel.MessageListener) (channel, message) -> { + if (message.isSuccessful()) { + onConnected(isSqueezeNetwork); + } else { + Log.w(TAG, channel + ": " + message.getJSON()); + + // The bayeux protocol handle failures internally. + // This current client libraries are however incompatible with LMS as new messages to the + // meta channels are ignored. + // So we disconnect here so we can create a new connection. + Map failure = Util.getRecord(message, "failure"); + Message failedMessage = (failure != null) ? (Message) failure.get("message") : null; + if ("forced reconnect".equals(failedMessage.get("error"))) { + disconnect(ConnectionState.RECONNECT); + } else { + Object httpCodeValue = (failure != null) ? failure.get("httpCode") : null; + int httpCode = (httpCodeValue instanceof Integer) ? (int) httpCodeValue : -1; + + disconnect((httpCode == 401) ? ConnectionError.LOGIN_FALIED : ConnectionError.CONNECTION_ERROR); + } + } + }); + mBayeuxClient.getChannel(Channel.META_CONNECT).addListener((ClientSessionChannel.MessageListener) (channel, message) -> { + if (!message.isSuccessful() && (getAdviceAction(message.getAdvice()) == null)) { + // Advices are handled internally by the bayeux protocol, so skip these here + Log.w(TAG, channel + ": " + message.getJSON()); + disconnect(); + } + }); + + mBayeuxClient.handshake(); + } + + private void onConnected(boolean isSqueezeNetwork) { + Log.i(TAG, "Connected, start learning server capabilities"); + mCurrentCommand = false; + mConnectionState.setConnectionState(ConnectionState.CONNECTION_COMPLETED); + + String clientId = mBayeuxClient.getId(); + + mBayeuxClient.getChannel(String.format(CHANNEL_SLIM_REQUEST_RESPONSE_FORMAT, clientId, "*")).subscribe((channel, message) -> { + Request request = mPendingRequests.get(message.getChannel()); + if (request != null) { + request.callback.onResponse(request.player, request, message); + mPendingRequests.remove(message.getChannel()); + } + }); + + mBayeuxClient.getChannel(String.format(CHANNEL_SERVER_STATUS_FORMAT, clientId)).subscribe(CometClient.this::parseServerStatus); + + mBayeuxClient.getChannel(String.format(CHANNEL_PLAYER_STATUS_FORMAT, clientId, "*")).subscribe(CometClient.this::parsePlayerStatus); + + mBayeuxClient.getChannel(String.format(CHANNEL_DISPLAY_STATUS_FORMAT, clientId, "*")).subscribe(CometClient.this::parseDisplayStatus); + + mBayeuxClient.getChannel(String.format(CHANNEL_MENU_STATUS_FORMAT, clientId, "*")).subscribe(CometClient.this::parseMenuStatus); + + // Request server status + publishMessage(serverStatusRequest(), CHANNEL_SLIM_REQUEST, String.format(CHANNEL_SERVER_STATUS_FORMAT, clientId), null); + + // Subscribe to server changes + { + Request request = serverStatusRequest().param("subscribe", "60"); + publishMessage(request, CHANNEL_SLIM_SUBSCRIBE, String.format(CHANNEL_SERVER_STATUS_FORMAT, clientId), null); + } + + // Set a timeout for the handshake + mBackgroundHandler.removeMessages(MSG_HANDSHAKE_TIMEOUT); + mBackgroundHandler.sendEmptyMessageDelayed(MSG_HANDSHAKE_TIMEOUT, HANDSHAKE_TIMEOUT); + + if (isSqueezeNetwork) { + if (needRegister()) { + mEventBus.post(new RegisterSqueezeNetwork()); + } + } + } + }); + } + + private boolean needRegister() { + return mBayeuxClient.getId().startsWith("1X"); + } + + + private void parseServerStatus(ClientSessionChannel channel, Message message) { + Map data = message.getDataAsMap(); + + // We can't distinguish between no connected players and players not received + // so we check the server version which is also set from server status + boolean firstTimePlayersReceived = (getConnectionState().getServerVersion() == null); + + getConnectionState().setMediaDirs(Util.getStringArray(data, Player.Pref.MEDIA_DIRS)); + getConnectionState().setServerVersion((String) data.get("version")); + Object[] item_data = (Object[]) data.get("players_loop"); + final HashMap players = new HashMap<>(); + if (item_data != null) { + for (Object item_d : item_data) { + Map record = (Map) item_d; + if (!record.containsKey(Player.Pref.DEFEAT_DESTRUCTIVE_TTP) && + data.containsKey(Player.Pref.DEFEAT_DESTRUCTIVE_TTP)) { + record.put(Player.Pref.DEFEAT_DESTRUCTIVE_TTP, data.get(Player.Pref.DEFEAT_DESTRUCTIVE_TTP)); + } + Player player = new Player(record); + players.put(player.getId(), player); + } + } + + Map currentPlayers = mConnectionState.getPlayers(); + if (firstTimePlayersReceived || !players.equals(currentPlayers)) { + mConnectionState.setPlayers(players); + } else { + for (Player player : players.values()) { + PlayerState currentPlayerState = currentPlayers.get(player.getId()).getPlayerState(); + if (!player.getPlayerState().prefs.equals(currentPlayerState.prefs)) { + currentPlayerState.prefs = player.getPlayerState().prefs; + postPlayerStateChanged(player); + } + } + } + } + + private void parsePlayerStatus(ClientSessionChannel channel, Message message) { + String[] channelParts = mSlashSplitPattern.split(message.getChannel()); + String playerId = channelParts[channelParts.length - 1]; + Player player = mConnectionState.getPlayer(playerId); + + // XXX: Can we ever see a status for a player we don't know about? + // XXX: Maybe the better thing to do is to add it. + if (player == null) + return; + + Map messageData = message.getDataAsMap(); + + CurrentPlaylistItem currentSong = null; + Object[] item_data = (Object[]) messageData.get("item_loop"); + if (item_data != null && item_data.length > 0) { + Map record = (Map) item_data[0]; + + patchUrlPrefix(record); + record.put("base", messageData.get("base")); + currentSong = new CurrentPlaylistItem(record); + record.remove("base"); + } + parseStatus(player, currentSong, messageData); + } + + @Override + protected void postSongTimeChanged(Player player) { + super.postSongTimeChanged(player); + if (player.getPlayerState().isPlaying()) { + mBackgroundHandler.removeMessages(MSG_TIME_UPDATE); + mBackgroundHandler.sendEmptyMessageDelayed(MSG_TIME_UPDATE, 1000); + } + } + + @Override + protected void postPlayerStateChanged(Player player) { + super.postPlayerStateChanged(player); + if (player.getPlayerState().getSleepDuration() > 0) { + android.os.Message message = mBackgroundHandler.obtainMessage(MSG_STATE_UPDATE, player); + mBackgroundHandler.removeMessages(MSG_STATE_UPDATE); + mBackgroundHandler.sendMessageDelayed(message, 1000); + } + } + + private void parseDisplayStatus(ClientSessionChannel channel, Message message) { + Map display = Util.getRecord(message.getDataAsMap(), "display"); + if (display != null) { + String type = Util.getString(display, "type"); + if ("alertWindow".equals(type)) { + AlertWindow alertWindow = new AlertWindow(display); + mEventBus.post(new AlertEvent(alertWindow)); + } else { + display.put("urlPrefix", mUrlPrefix); + DisplayMessage displayMessage = new DisplayMessage(display); + mEventBus.post(new DisplayEvent(displayMessage)); + } + } + } + + private void parseMenuStatus(ClientSessionChannel channel, Message message) { + Object[] data = (Object[]) message.getData(); + + // each chunk.data[2] contains a table that needs insertion into the menu + Object[] item_data = (Object[]) data[1]; + JiveItem[] menuItems = new JiveItem[item_data.length]; + for (int i = 0; i < item_data.length; i++) { + Map record = (Map) item_data[i]; + patchUrlPrefix(record); + menuItems[i] = new JiveItem(record); + } + + // directive for these items is in chunk.data[3] + String menuDirective = (String) data[2]; + + // the player ID this notification is for is in chunk.data[4] + String playerId = (String) data[3]; + + mConnectionState.menuStatusEvent(new MenuStatusMessage(playerId, menuDirective, menuItems)); + } + + /** + * Add endpoint to fetch further info from a slimserver item + */ + private void patchUrlPrefix(Map record) { + record.put("urlPrefix", mUrlPrefix); + Map window = (Map) record.get("window"); + if (window != null) { + window.put("urlPrefix", mUrlPrefix); + } + } + + private interface ResponseHandler { + void onResponse(Player player, Request request, Message message); + } + + private class PublishListener implements ClientSessionChannel.MessageListener { + @Override + public void onMessage(ClientSessionChannel channel, Message message) { + if (!message.isSuccessful()) { + // TODO remote logging and possible other handling + Log.e(TAG, channel + ": " + message.getJSON()); + } + mBackgroundHandler.sendEmptyMessage(MSG_PUBLISH_RESPONSE_RECIEVED); + } + } + + private abstract class ItemListener extends BaseListHandler implements ResponseHandler { + void parseMessage(String countName, String itemLoopName, Message message) { + @SuppressWarnings("unchecked") + BrowseRequest browseRequest = (BrowseRequest) mPendingBrowseRequests.get(message.getChannel()); + if (browseRequest == null) { + return; + } + + mPendingBrowseRequests.remove(message.getChannel()); + clear(); + Map data = message.getDataAsMap(); + int count = Util.getInt(data.get(countName)); + Map baseRecord = (Map) data.get("base"); + if (baseRecord != null) { + patchUrlPrefix(baseRecord); + } + Object[] item_data = (Object[]) data.get(itemLoopName); + if (item_data != null) { + for (Object item_d : item_data) { + Map record = (Map) item_d; + patchUrlPrefix(record); + if (baseRecord != null) record.put("base", baseRecord); + add(record); + record.remove("base"); + } + } + + // Process the lists for all the registered handlers + final boolean fullList = browseRequest.isFullList(); + final int start = browseRequest.getStart(); + final int end = start + getItems().size(); + int max = 0; + patchUrlPrefix(data); + browseRequest.getCallback().onItemsReceived(count, start, data, getItems(), getDataType()); + if (count > max) { + max = count; + } + + // Check if we need to order more items + if ((fullList || end % mPageSize != 0) && end < max) { + int itemsPerResponse = (end + mPageSize > max ? max - end : fullList ? mPageSize : mPageSize - browseRequest.getItemsPerResponse()); + //XXX support prefix + internalRequestItems(browseRequest.update(end, itemsPerResponse)); + } + } + + void parseMessage(String itemLoopName, Message message) { + parseMessage("count", itemLoopName, message); + } + } + + private class AlarmsListener extends ItemListener { + @Override + public void onResponse(Player player, Request request, Message message) { + parseMessage("alarms_loop", message); + } + } + + private class AlarmPlaylistsListener extends ItemListener { + @Override + public void onResponse(Player player, Request request, Message message) { + parseMessage("item_loop", message); + } + } + + private class SongListener extends ItemListener { + @Override + public void onResponse(Player player, Request request, Message message) { + switch (request.getRequest()) { + default: + parseMessage("titles_loop", message); + break; + case "playlists tracks": + parseMessage("playlisttracks_loop", message); + break; + case "status": + parseMessage("playlist_tracks", "playlist_loop", message); + break; + } + parseMessage("titles_loop", message); + } + } + + private class MusicFolderListener extends ItemListener { + @Override + public void onResponse(Player player, Request request, Message message) { + parseMessage("folder_loop", message); + } + } + + private class JiveItemListener extends ItemListener { + @Override + public void onResponse(Player player, Request request, Message message) { + parseMessage("item_loop", message); + } + } + + public void onEvent(@SuppressWarnings("unused") HandshakeComplete event) { + mBackgroundHandler.removeMessages(MSG_HANDSHAKE_TIMEOUT); + } + + @Override + public void disconnect() { + disconnect(ConnectionState.DISCONNECTED); + } + + private void disconnect(@ConnectionState.ConnectionStates int connectionState) { + if (mBayeuxClient != null) mBackgroundHandler.sendEmptyMessage(MSG_DISCONNECT); + mConnectionState.setConnectionState(connectionState); + } + + private void disconnect(ConnectionError connectionError) { + if (mBayeuxClient != null) mBackgroundHandler.sendEmptyMessage(MSG_DISCONNECT); + mConnectionState.setConnectionError(connectionError); + } + + @Override + public void cancelClientRequests(Object client) { + for (Map.Entry> entry : mPendingBrowseRequests.entrySet()) { + if (entry.getValue().getCallback().getClient() == client) { + mPendingBrowseRequests.remove(entry.getKey()); + } + } + } + + private void exec(ResponseHandler callback, String... cmd) { + exec(request(callback, cmd)); + } + + private String exec(Request request) { + String responseChannel = String.format(CHANNEL_SLIM_REQUEST_RESPONSE_FORMAT, mBayeuxClient.getId(), mCorrelationId++); + if (request.callback != null) mPendingRequests.put(responseChannel, request); + publishMessage(request, CHANNEL_SLIM_REQUEST, responseChannel, null); + return responseChannel; + } + + /** If request is null, this is an unsubscribe to the suplied response channel */ + private void publishMessage(final Request request, final String channel, final String responseChannel, final PublishListener publishListener) { + // Make sure all requests are done in the handler thread + if (mBackgroundHandler.getLooper() == Looper.myLooper()) { + _publishMessage(request, channel, responseChannel, publishListener); + } else { + PublishMessage publishMessage = new PublishMessage(request, channel, responseChannel, publishListener); + android.os.Message message = mBackgroundHandler.obtainMessage(MSG_PUBLISH, publishMessage); + mBackgroundHandler.sendMessage(message); + } + + } + + /** This may only be called from the handler thread */ + private void _publishMessage(Request request, String channel, String responseChannel, PublishListener publishListener) { + if (!mCurrentCommand) { + mCurrentCommand = true; + Map data = new HashMap<>(); + if (request != null) { + data.put("request", request.slimRequest()); + data.put("response", responseChannel); + } else { + data.put("unsubscribe", responseChannel); + } + mBayeuxClient.getChannel(channel).publish(data, publishListener != null ? publishListener : this.mPublishListener); + } else + mCommandQueue.add(new PublishMessage(request, channel, responseChannel, publishListener)); + } + + @Override + protected void internalRequestItems(final BrowseRequest browseRequest) { + Class callbackClass = Reflection.getGenericClass(browseRequest.getCallback().getClass(), IServiceItemListCallback.class, 0); + ItemListener listener = mItemRequestMap.get(callbackClass); + if (listener == null) { + throw new RuntimeException("No handler defined for '" + browseRequest.getCallback().getClass() + "'"); + } + + Request request = request(browseRequest.getPlayer(), listener, browseRequest.cmd()) + .page(browseRequest.getStart(), browseRequest.getItemsPerResponse()) + .params(browseRequest.params); + mPendingBrowseRequests.put(exec(request), browseRequest); + } + + @Override + public void command(Player player, String[] cmd, Map params) { + ResponseHandler callback = mRequestMap.get(cmd[0]); + exec(request(player, callback, cmd).params(params)); + } + + @Override + public void requestPlayerStatus(Player player) { + Request request = statusRequest(player); + publishMessage(request, CHANNEL_SLIM_REQUEST, subscribeResponseChannel(player, CHANNEL_PLAYER_STATUS_FORMAT), null); + } + + @Override + public void subscribePlayerStatus(final Player player, final PlayerState.PlayerSubscriptionType subscriptionType) { + Request request = statusRequest(player) + .param("subscribe", subscriptionType.getStatus()); + publishMessage(request, CHANNEL_SLIM_SUBSCRIBE, subscribeResponseChannel(player, CHANNEL_PLAYER_STATUS_FORMAT), new PublishListener() { + @Override + public void onMessage(ClientSessionChannel channel, Message message) { + super.onMessage(channel, message); + if (message.isSuccessful()) { + player.getPlayerState().setSubscriptionType(subscriptionType); + } + } + }); + } + + @Override + public void subscribeDisplayStatus(Player player, boolean subscribe) { + Request request = request(player, "displaystatus").param("subscribe", subscribe ? "showbriefly" : ""); + publishMessage(request, CHANNEL_SLIM_SUBSCRIBE, subscribeResponseChannel(player, CHANNEL_DISPLAY_STATUS_FORMAT), mPublishListener); + } + + @Override + public void subscribeMenuStatus(Player player, boolean subscribe) { + if (subscribe) + subscribeMenuStatus(player); + else + unsubscribeMenuStatus(player); + } + + private void subscribeMenuStatus(Player player) { + Request request = request(player, "menustatus"); + publishMessage(request, CHANNEL_SLIM_SUBSCRIBE, subscribeResponseChannel(player, CHANNEL_MENU_STATUS_FORMAT), null); + } + + private void unsubscribeMenuStatus(Player player) { + publishMessage(null, CHANNEL_SLIM_UNSUBSCRIBE, subscribeResponseChannel(player, CHANNEL_MENU_STATUS_FORMAT), null); + } + + private String subscribeResponseChannel(Player player, String format) { + return String.format(format, mBayeuxClient.getId(), player.getId()); + } + + private static String getAdviceAction(Map advice) { + String action = null; + if (advice != null && advice.containsKey(Message.RECONNECT_FIELD)) + action = (String)advice.get(Message.RECONNECT_FIELD); + return action; + } + + private static final int MSG_PUBLISH = 1; + private static final int MSG_DISCONNECT = 2; + private static final int MSG_HANDSHAKE_TIMEOUT = 3; + private static final int MSG_PUBLISH_RESPONSE_RECIEVED = 4; + private static final int MSG_TIME_UPDATE = 5; + private static final int MSG_STATE_UPDATE = 6; + private class CliHandler extends Handler { + CliHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(android.os.Message msg) { + switch (msg.what) { + case MSG_PUBLISH: { + PublishMessage message = (PublishMessage) msg.obj; + _publishMessage(message.request, message.channel, message.responseChannel, message.publishListener); + break; + } + case MSG_DISCONNECT: + mBayeuxClient.disconnect(); + break; + case MSG_HANDSHAKE_TIMEOUT: + Log.w(TAG, "LMS handshake timeout: " + mConnectionState); + disconnect(); + break; + case MSG_PUBLISH_RESPONSE_RECIEVED: { + mCurrentCommand = false; + PublishMessage message = mCommandQueue.poll(); + if (message != null) + _publishMessage(message.request, message.channel, message.responseChannel, message.publishListener); + break; + } + case MSG_TIME_UPDATE: { + Player activePlayer = mConnectionState.getActivePlayer(); + if (activePlayer != null) { + postSongTimeChanged(activePlayer); + } + break; + } + case MSG_STATE_UPDATE: { + Player player = (Player) msg.obj; + postPlayerStateChanged(player); + break; + } + } + } + } + + @NonNull + private Request serverStatusRequest() { + return request("serverstatus") + .defaultPage() + .param("prefs", Player.Pref.MEDIA_DIRS + ", " + Player.Pref.DEFEAT_DESTRUCTIVE_TTP) + .param("playerprefs", Player.Pref.PLAY_TRACK_ALBUM + "," + Player.Pref.DEFEAT_DESTRUCTIVE_TTP); + } + + @NonNull + private Request statusRequest(Player player) { + return request(player, "status") + .currentSong() + .param("menu", "menu") + .param("useContextMenu", "1"); + } + + private Request request(Player player, ResponseHandler callback, String... cmd) { + return new Request(player, callback, cmd); + } + + private Request request(Player player, String... cmd) { + return new Request(player, null, cmd); + } + + private Request request(ResponseHandler callback, String... cmd) { + return new Request(null, callback, cmd); + } + + private Request request(String... cmd) { + return new Request(null, null, cmd); + } + + private static class Request extends SlimCommand { + private static final Joiner joiner = Joiner.on(" "); + + private final ResponseHandler callback; + private final Player player; + private PagingParams page; + + private Request(Player player, ResponseHandler callback, String... cmd) { + this.player = player; + this.callback = callback; + this.cmd(cmd); + } + + public Request param(String param, Object value) { + super.param(param, value); + return this; + } + + public Request params(Map params) { + super.params(params); + return this; + } + + private Request page(int start, int page) { + this.page = new PagingParams(String.valueOf(start), String.valueOf(page)); + return this; + } + + private Request defaultPage() { + page = PagingParams._default; + return this; + } + + private Request currentSong() { + page = PagingParams.status; + return this; + } + + public String getRequest() { + return joiner.join(cmd); + } + + List slimRequest() { + List slimRequest = new ArrayList<>(); + + slimRequest.add(player == null ? "" : player.getId()); + List inner = new ArrayList<>(); + slimRequest.add(inner); + inner.addAll(cmd); + for (Map.Entry parameter : params.entrySet()) { + if (parameter.getValue() == null) inner.add(parameter.getKey()); + } + if (page != null) { + inner.add(page.start); + inner.add(page.page); + } + for (Map.Entry parameter : params.entrySet()) { + if (parameter.getValue() != null) inner.add(parameter.getKey() + ":" + parameter.getValue()); + } + + return slimRequest; + } + } + + private static class PagingParams { + private static final PagingParams status = new PagingParams("-", "1"); + private static final PagingParams _default = new PagingParams("0", "255"); + + private final String start; + private final String page; + + private PagingParams(String start, String page) { + this.start = start; + this.page = page; + } + } + + private static class PublishMessage { + final Request request; + final String channel; + final String responseChannel; + final PublishListener publishListener; + + private PublishMessage(Request request, String channel, String responseChannel, PublishListener publishListener) { + this.request = request; + this.channel = channel; + this.responseChannel = responseChannel; + this.publishListener = publishListener; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionError.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionError.java new file mode 100644 index 000000000..60c4d6a57 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionError.java @@ -0,0 +1,8 @@ +package uk.org.ngo.squeezer.service; + +public enum ConnectionError { + START_CLIENT_ERROR, + INVALID_URL, + LOGIN_FALIED, + CONNECTION_ERROR; +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionState.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionState.java new file mode 100644 index 000000000..0d7382f9d --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ConnectionState.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import android.util.Log; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; +import java.util.Vector; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import de.greenrobot.event.EventBus; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.service.event.ActivePlayerChanged; +import uk.org.ngo.squeezer.service.event.ConnectionChanged; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.service.event.HomeMenuEvent; +import uk.org.ngo.squeezer.model.MenuStatusMessage; +import uk.org.ngo.squeezer.service.event.PlayersChanged; + +public class ConnectionState { + + private static final String TAG = "ConnectionState"; + + ConnectionState(@NonNull EventBus eventBus) { + mEventBus = eventBus; + } + + private final EventBus mEventBus; + + // Connection state machine + @IntDef({DISCONNECTED, CONNECTION_STARTED, CONNECTION_FAILED, CONNECTION_COMPLETED, RECONNECT}) + @Retention(RetentionPolicy.SOURCE) + public @interface ConnectionStates {} + /** Ordinarily disconnected from the server. */ + public static final int DISCONNECTED = 0; + /** A connection has been started. */ + public static final int CONNECTION_STARTED = 1; + /** The connection to the server did not complete. */ + public static final int CONNECTION_FAILED = 2; + /** The connection to the server completed, the handshake can start. */ + public static final int CONNECTION_COMPLETED = 3; + /** Create a new connection to the server. */ + public static final int RECONNECT = 5; + + @ConnectionStates + private volatile int mConnectionState = DISCONNECTED; + + /** Map Player IDs to the {@link uk.org.ngo.squeezer.model.Player} with that ID. */ + private final Map mPlayers = new ConcurrentHashMap<>(); + + /** The active player (the player to which commands are sent by default). */ + private final AtomicReference mActivePlayer = new AtomicReference<>(); + + /** Home menu tree as received from slimserver */ + private List homeMenu = new Vector<>(); + + private final AtomicReference serverVersion = new AtomicReference<>(); + + private final AtomicReference mediaDirs = new AtomicReference<>(); + + /** + * Sets a new connection state, and posts a sticky + * {@link uk.org.ngo.squeezer.service.event.ConnectionChanged} event with the new state. + * + * @param connectionState The new connection state. + */ + void setConnectionState(@ConnectionStates int connectionState) { + Log.i(TAG, "setConnectionState(" + mConnectionState + " => " + connectionState + ")"); + updateConnectionState(connectionState); + mEventBus.postSticky(new ConnectionChanged(connectionState)); + } + + void setConnectionError(ConnectionError connectionError) { + Log.i(TAG, "setConnectionError(" + mConnectionState + " => " + connectionError.name() + ")"); + updateConnectionState(CONNECTION_FAILED); + mEventBus.postSticky(new ConnectionChanged(connectionError)); + } + + private void updateConnectionState(@ConnectionStates int connectionState) { + // Clear data if we were previously connected + if (isConnected() && !isConnected(connectionState)) { + mEventBus.removeAllStickyEvents(); + setServerVersion(null); + mPlayers.clear(); + setActivePlayer(null); + } + mConnectionState = connectionState; + } + + public void setPlayers(Map players) { + mPlayers.clear(); + mPlayers.putAll(players); + mEventBus.postSticky(new PlayersChanged(players)); + } + + Player getPlayer(String playerId) { + return mPlayers.get(playerId); + } + + public Map getPlayers() { + return mPlayers; + } + + public Player getActivePlayer() { + return mActivePlayer.get(); + } + + void setActivePlayer(Player player) { + mActivePlayer.set(player); + mEventBus.post(new ActivePlayerChanged(player)); + } + + void setServerVersion(String version) { + if (Util.atomicReferenceUpdated(serverVersion, version)) { + if (version != null) { + HandshakeComplete event = new HandshakeComplete(getServerVersion()); + Log.i(TAG, "Handshake complete: " + event); + mEventBus.postSticky(event); + } + } + } + + void setMediaDirs(String[] mediaDirs) { + this.mediaDirs.set(mediaDirs); + } + + void clearHomeMenu() { + homeMenu.clear(); + } + + void addToHomeMenu(int count, List items) { + homeMenu.addAll(items); + if (homeMenu.size() == count) { + jiveMainNodes(); + mEventBus.postSticky(new HomeMenuEvent(homeMenu)); + } + } + + void menuStatusEvent(MenuStatusMessage event) { + if (event.playerId.equals(getActivePlayer().getId())) { + for (JiveItem menuItem : event.menuItems) { + JiveItem item = null; + for (JiveItem menu : homeMenu) { + if (menuItem.getId().equals(menu.getId())) { + item = menu; + break; + } + } + if (item != null) { + homeMenu.remove(item); + } + if (MenuStatusMessage.ADD.equals(event.menuDirective)) { + homeMenu.add(menuItem); + } + } + mEventBus.postSticky(new HomeMenuEvent(homeMenu)); + } + } + + private void jiveMainNodes() { + addNode(JiveItem.EXTRAS); + addNode(JiveItem.SETTINGS); + addNode(JiveItem.ADVANCED_SETTINGS); + } + + private void addNode(JiveItem jiveItem) { + if (!homeMenu.contains(jiveItem)) + homeMenu.add(jiveItem); + } + + String getServerVersion() { + return serverVersion.get(); + } + + String[] getMediaDirs() { + return mediaDirs.get(); + } + + /** + * @return True if the socket connection to the server has completed. + */ + boolean isConnected() { + return isConnected(mConnectionState); + } + + /** + * @return True if the socket connection to the server has completed. + */ + static boolean isConnected(@ConnectionStates int connectionState) { + return connectionState == CONNECTION_COMPLETED; + } + + /** + * @return True if the socket connection to the server has started, but not yet + * completed (successfully or unsuccessfully). + */ + boolean isConnectInProgress() { + return isConnectInProgress(mConnectionState); + } + + /** + * @return True if the socket connection to the server has started, but not yet + * completed (successfully or unsuccessfully). + */ + static boolean isConnectInProgress(@ConnectionStates int connectionState) { + return connectionState == CONNECTION_STARTED; + } + + @Override + public String toString() { + return "ConnectionState{" + + "mConnectionState=" + mConnectionState + + ", serverVersion=" + serverVersion + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/HttpStreamingTransport.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/HttpStreamingTransport.java new file mode 100644 index 000000000..e72f88f33 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/HttpStreamingTransport.java @@ -0,0 +1,757 @@ +package uk.org.ngo.squeezer.service; + +import android.annotation.TargetApi; +import android.os.Build; +import android.util.Log; + +import org.cometd.bayeux.Channel; +import org.cometd.bayeux.Message; +import org.cometd.client.transport.HttpClientTransport; +import org.cometd.client.transport.MessageClientTransport; +import org.cometd.client.transport.TransportListener; +import org.cometd.common.TransportException; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpDestination; +import org.eclipse.jetty.client.Origin; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.api.Result; +import org.eclipse.jetty.client.http.HttpDestinationOverHTTP; +import org.eclipse.jetty.client.util.BufferingResponseListener; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpStatus; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import java.io.BufferedReader; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.UnsupportedEncodingException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.HttpCookie; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +public class HttpStreamingTransport extends HttpClientTransport implements MessageClientTransport { + public static final String NAME = "streaming"; + public static final String PREFIX = "http-streaming.json"; + public static final String MAX_BUFFER_SIZE_OPTION = "maxBufferSize"; + private static final String TAG = HttpStreamingTransport.class.getSimpleName(); + + private ScheduledExecutorService _scheduler; + private boolean _shutdownScheduler; + + private final Delegate _delegate; + private TransportListener _listener; + + private final HttpClient _httpClient; + private final List _requests = new ArrayList<>(); + private volatile boolean _aborted; + private volatile int _maxBufferSize; + private volatile boolean _appendMessageType; + private volatile CookieManager _cookieManager; + + public HttpStreamingTransport(Map options, HttpClient httpClient) { + this(null, options, httpClient); + } + + public HttpStreamingTransport(String url, Map options, HttpClient httpClient) { + super(NAME, url, options); + _httpClient = httpClient; + _delegate = new Delegate(); + setOptionPrefix(PREFIX); + } + + @Override + public void setMessageTransportListener(TransportListener listener) { + _listener = listener; + } + + @Override + public boolean accept(String bayeuxVersion) { + return true; + } + + @Override + public void init() { + super.init(); + + _aborted = false; + + long defaultMaxNetworkDelay = _httpClient.getIdleTimeout(); + if (defaultMaxNetworkDelay <= 0) + defaultMaxNetworkDelay = 10000; + setMaxNetworkDelay(defaultMaxNetworkDelay); + + _maxBufferSize = getOption(MAX_BUFFER_SIZE_OPTION, 1024 * 1024); + + Pattern uriRegexp = Pattern.compile("(^https?://(((\\[[^\\]]+\\])|([^:/\\?#]+))(:(\\d+))?))?([^\\?#]*)(.*)?"); + Matcher uriMatcher = uriRegexp.matcher(getURL()); + if (uriMatcher.matches()) { + String afterPath = uriMatcher.group(9); + _appendMessageType = afterPath == null || afterPath.trim().length() == 0; + } + _cookieManager = new CookieManager(getCookieStore(), CookiePolicy.ACCEPT_ALL); + + if (_scheduler == null) { + _shutdownScheduler = true; + int threads = Math.max(1, Runtime.getRuntime().availableProcessors() / 4); + ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(threads); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + scheduler.setRemoveOnCancelPolicy(true); + } + _scheduler = scheduler; + } + + } + + @Override + public void abort() { + List requests = new ArrayList<>(); + synchronized (this) { + _aborted = true; + requests.addAll(_requests); + _requests.clear(); + } + for (Request request : requests) { + request.abort(new Exception("Transport " + this + " aborted")); + } + shutdownScheduler(); + } + + @Override + public void terminate() + { + shutdownScheduler(); + super.terminate(); + } + + private void shutdownScheduler() + { + if (_shutdownScheduler) + { + _shutdownScheduler = false; + _scheduler.shutdownNow(); + _scheduler = null; + } + } + + @Override + public void send(final TransportListener listener, final List messages) { + if (!_delegate.connected) connect(getURL(), listener, messages); + if (!_delegate.connected) return; + + if (Channel.META_CONNECT.equals(messages.get(0).getChannel()) || + Channel.META_SUBSCRIBE.equals(messages.get(0).getChannel()) || + Channel.META_HANDSHAKE.equals(messages.get(0).getChannel())) { + delegateSend(listener, messages); + } else { + transportSend(listener, messages); + } + } + + protected void connect(String urlString, TransportListener listener, List messages) { + try { + URL url = new URL(urlString); + String host = url.getHost(); + int port = url.getPort(); + _delegate.connect(host, port); + } catch (IOException e) { + listener.onFailure(e, messages); + } + } + + private void delegateSend(final TransportListener listener, final List messages) { + _delegate.registerMessages(listener, messages); + try { + String content = generateJSON(messages); + + // The onSending() callback must be invoked before the actual send + // otherwise we may have a race condition where the response is so + // fast that it arrives before the onSending() is called. + //Log.v(TAG,"Sending messages " + content); + listener.onSending(messages); + + _delegate.send(content); + } catch (Throwable x) { + _delegate.fail(x, "Exception"); + } + } + + private void transportSend(final TransportListener listener, final List messages) { + String url = getURL(); + final URI uri = URI.create(url); + if (_appendMessageType && messages.size() == 1) { + Message.Mutable message = messages.get(0); + if (message.isMeta()) { + String type = message.getChannel().substring(Channel.META.length()); + if (url.endsWith("/")) + url = url.substring(0, url.length() - 1); + url += type; + } + } + + final Request request = _httpClient.newRequest(url).method(HttpMethod.POST); + request.header(HttpHeader.CONTENT_TYPE.asString(), "text/json;charset=UTF-8"); + + StringBuilder builder = new StringBuilder(); + for (HttpCookie cookie : getCookieStore().get(uri)) { + builder.setLength(0); + builder.append(cookie.getName()).append("=").append(cookie.getValue()); + request.header(HttpHeader.COOKIE.asString(), builder.toString()); + } + + String content = generateJSON(messages); + //Log.v(TAG,"Sending messages " + content); + request.content(new StringContentProvider(content)); + + customize(request); + + synchronized (this) { + if (_aborted) + throw new IllegalStateException("Aborted"); + _requests.add(request); + } + + request.listener(new Request.Listener.Adapter() { + @Override + public void onHeaders(Request request) { + listener.onSending(messages); + } + }); + + long maxNetworkDelay = getMaxNetworkDelay(); + + // Set the idle timeout for this request larger than the total timeout + // so there are no races between the two timeouts + request.idleTimeout(maxNetworkDelay * 2, TimeUnit.MILLISECONDS); + request.timeout(maxNetworkDelay, TimeUnit.MILLISECONDS); + request.send(new BufferingResponseListener(_maxBufferSize) { + @Override + public boolean onHeader(Response response, HttpField field) { + HttpHeader header = field.getHeader(); + if (header != null && (header == HttpHeader.SET_COOKIE || header == HttpHeader.SET_COOKIE2)) { + // We do not allow cookies to be handled by HttpClient, since one + // HttpClient instance is shared by multiple BayeuxClient instances. + // Instead, we store the cookies in the BayeuxClient instance. + Map> cookies = new HashMap<>(1); + cookies.put(field.getName(), Collections.singletonList(field.getValue())); + storeCookies(uri, cookies); + return false; + } + return true; + } + + private void storeCookies(URI uri, Map> cookies) { + try { + _cookieManager.put(uri, cookies); + } catch (IOException x) { + Log.w(TAG, "", x); + } + } + + @Override + public void onComplete(Result result) { + synchronized (HttpStreamingTransport.this) { + _requests.remove(result.getRequest()); + } + + if (result.isFailed()) { + listener.onFailure(result.getFailure(), messages); + return; + } + + Response response = result.getResponse(); + int status = response.getStatus(); + if (status == HttpStatus.OK_200) { + String content = getContentAsString(); + if (content != null && content.length() > 0) { + try { + List messages = parseMessages(content); + //Log.v(TAG, "Received messages " + messages); + for (Message.Mutable message : messages) { + // LMS echoes the data field in the publish response for messages to the + // slim/unsubscribe channel. + // This causes the comet libraries to decide the message is not a publish response. + // We remove the data field for susch messages, to have them correctly recognized + // as publish responses. + if (message.getChannel() != null && message.getChannel().startsWith("/slim/")) { + message.remove(Message.DATA_FIELD); + } + + if (message.isSuccessful() && Channel.META_DISCONNECT.equals(message.getChannel())) { + _delegate.disconnect("Disconnect"); + } + } + listener.onMessages(messages); + } catch (ParseException x) { + listener.onFailure(x, messages); + } + } else { + Map failure = new HashMap<>(2); + // Convert the 200 into 204 (no content) + failure.put("httpCode", 204); + TransportException x = new TransportException(failure); + listener.onFailure(x, messages); + } + } else { + Map failure = new HashMap<>(2); + failure.put("httpCode", status); + TransportException x = new TransportException(failure); + listener.onFailure(x, messages); + } + } + }); + } + + private static void sendText(PrintWriter writer, String json, HttpFields customHeaders) { + StringBuilder msg = new StringBuilder("POST /cometd HTTP/1.1\r\n" + + HttpHeader.CONTENT_TYPE.asString() + ": text/json;charset=UTF-8\r\n" + + HttpHeader.CONTENT_LENGTH.asString() + ": " + json.length() + "\r\n"); + + for (HttpField httpField : customHeaders) { + if (httpField.getHeader() != HttpHeader.ACCEPT_ENCODING) { + msg.append(httpField.getName()).append(": ").append(httpField.getValue()).append("\r\n"); + } + } + msg.append("\r\n").append(json); + //Log.v(TAG,"sendtext: " + msg); + writer.print(msg.toString()); + writer.flush(); + } + + private class Delegate { + private final Socket socket; + private final HttpFields headers; + private PrintWriter writer; + private boolean connected; + + private final Map _exchanges = new ConcurrentHashMap<>(); + private Map _advice; + private long interval; + + public Delegate() { + socket = new Socket(); + Request request = _httpClient.newRequest(getURL()); + customize(request); + headers = request.getHeaders(); + headers.add(getHostField(request)); + } + + private HttpField getHostField(Request request) { + String scheme = request.getScheme().toLowerCase(Locale.ENGLISH); + if (!HttpScheme.HTTP.is(scheme) && !HttpScheme.HTTPS.is(scheme)) + throw new IllegalArgumentException("Invalid protocol " + scheme); + + String host = request.getHost().toLowerCase(Locale.ENGLISH); + Origin origin = new Origin(scheme, host, request.getPort()); + HttpDestination destination = new HttpDestinationOverHTTP(_httpClient, origin); + return destination.getHostField(); + } + + public void connect(String host, int port) throws IOException { + socket.connect(new InetSocketAddress(host, port), 4000); // TODO use proper timeout + connected = true; + writer = new PrintWriter(socket.getOutputStream()); + new ListeningThread(this, socket.getInputStream()).start(); + } + + private void registerMessages(TransportListener listener, List messages) { + synchronized (this) { + for (Message.Mutable message : messages) + registerMessage(message, listener); + } + } + + private void registerMessage(final Message.Mutable message, final TransportListener listener) { + // Calculate max network delay + long maxNetworkDelay = getMaxNetworkDelay(); + if (Channel.META_CONNECT.equals(message.getChannel())) { + Map advice = message.getAdvice(); + if (advice == null) + advice = _advice; + if (advice != null) { + Object timeout = advice.get(Message.TIMEOUT_FIELD); + if (timeout instanceof Number) + maxNetworkDelay += ((Number) timeout).intValue(); + else if (timeout != null) + maxNetworkDelay += Integer.parseInt(timeout.toString()); + } + } + + // Schedule a task to expire if the maxNetworkDelay elapses + final long expiration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + maxNetworkDelay; + ScheduledFuture task = _scheduler.schedule(new Runnable() { + @Override + public void run() { + long now = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + long delay = now - expiration; + if (delay > 5000) // TODO: make the max delay a parameter ? + Log.d(TAG, "Message " + message + " expired " + delay + " ms too late"); + //Log.d(TAG,"Expiring message " + message); + fail(new TimeoutException(), "Expired"); + } + }, maxNetworkDelay, TimeUnit.MILLISECONDS); + + // Register the exchange + // Message responses must have the same messageId as the requests + + Exchange exchange = new Exchange(message, listener, task); + //Log.d(TAG, "Registering " + exchange); + Object existing = _exchanges.put(message.getId(), exchange); + // Paranoid check + if (existing != null) + throw new IllegalStateException(); + } + + private Exchange deregisterMessage(Message.Mutable message) { + Exchange exchange = (message.getId() != null) ? _exchanges.remove(message.getId()) : null; + //Log.d(TAG, "Deregistering " + exchange + " for message " + message); + if (exchange != null) + exchange.task.cancel(false); + + return exchange; + } + + /** + * Workaround missing fields in replies from LMS + * LMS does not put ID on all replies. For such a message, this method tries to find the + * message in _exchanges which this message is a reply to. + *
    + *
  1. For messages with channel META_CONNECT, META_HANDSHAKE or META_SUBSCRIBE we + * look for a message with that channel (assuming that there is only one such message. + *
  2. + *
  3. For messages without channel we check if it has advice action, in which case we look for + * META_CONNECT and META_HANDSHAKE
  4. + *
+ */ + private void fixMessage(Message.Mutable message) { + String channel = message.getChannel(); + + if (Channel.META_CONNECT.equals(channel) || Channel.META_HANDSHAKE.equals(channel) || Channel.META_SUBSCRIBE.equals(channel)) { + for (Exchange exchange : _exchanges.values()) { + if (channel.equals(exchange.message.getChannel())) { + message.setId(exchange.message.getId()); + break; + } + } + } else + if (channel == null && (getAdviceAction(message.getAdvice()) != null)) { + for (Exchange exchange : _exchanges.values()) { + channel = exchange.message.getChannel(); + if (Channel.META_CONNECT.equals(channel) || Channel.META_HANDSHAKE.equals(channel)) { + message.setId(exchange.message.getId()); + message.setChannel(channel); + break; + } + } + } + } + + public void send(String content) { + PrintWriter session; + synchronized (this) { + session = writer; + } + try { + if (session == null) + throw new IOException("Unconnected"); + + sendText(session, content, headers); + } catch (Throwable x) { + fail(x, "Exception"); + } + } + + private void onData(String data) { + try { + List messages = parseMessages (data); + //Log.v(TAG,"Received messages " + data); + onMessages(messages); + } catch (ParseException x) { + fail(x, "Exception"); + } + } + + private void onMessages(List messages) { + for (Message.Mutable message : messages) { + if (isReply(message)) { + if (message.getId() == null) { + fixMessage(message); + } + + // If the server sends an interval with the handshake response, set it to zero + // so we can send the connect message immediately. + // But store the interval so we can use it for subsequent connect messages, if + // the server does not supply an interval in the connect response. + if (Channel.META_HANDSHAKE.equals(message.getChannel()) && message.isSuccessful()) { + Map advice = message.getAdvice(); + if (advice != null && advice.containsKey(Message.INTERVAL_FIELD)) { + interval = ((Number)advice.get(Message.INTERVAL_FIELD)).longValue(); + if (interval > 0) { + advice.put(Message.INTERVAL_FIELD, 0); + } + } + } + + // Remembering the advice must be done before we notify listeners + // otherwise we risk that listeners send a connect message that does + // not take into account the timeout to calculate the maxNetworkDelay + if (Channel.META_CONNECT.equals(message.getChannel()) && message.isSuccessful()) { + Map advice = message.getAdvice(); + if (advice != null) { + // Remember the advice so that we can properly calculate the max network delay + if (advice.get(Message.TIMEOUT_FIELD) != null) + _advice = advice; + } else { + // If the server doesn't send an interval with the connect response, but + // we have one from the handshake response, then use that to avoid sending + // connect messages continuously. + if (interval > 0) { + message.put(Message.ADVICE_FIELD, Collections.singletonMap(Message.INTERVAL_FIELD, interval)); + } + } + } + + Exchange exchange = deregisterMessage(message); + if (exchange != null) { + exchange.listener.onMessages(Collections.singletonList(message)); + } else if (message.containsKey("error")) { + failMessages(null); + + // We send messages with no channel to the handshake listener + if (message.getChannel() == null) { + message.setChannel(Channel.META_HANDSHAKE); + } + + _listener.onFailure(null, Collections.singletonList(message)); + } else { + // If the exchange is missing, then the message has expired, and we do not notify + Log.d(TAG, "Could not find request for reply " + message); + } + } else { + _listener.onMessages(Collections.singletonList(message)); + } + } + } + + private void fail(Throwable failure, String reason) { + disconnect(reason); + failMessages(failure); + } + + private void failMessages(Throwable cause) { + List messages = new ArrayList<>(1); + for (Exchange exchange : new ArrayList<>(_exchanges.values())) { + Message.Mutable message = exchange.message; + if (deregisterMessage(message) == exchange) { + messages.add(message); + exchange.listener.onFailure(cause, messages); + messages.clear(); + } + } + } + + private void disconnect(String reason) { + if (connected) + shutdown(reason); + } + + private void shutdown(String reason) { + connected = false; + writer = null; + Log.v(TAG, "Closing socket, reason: " + reason); + try { + socket.close(); + } catch (IOException x) { + Log.w(TAG, "Could not close socket", x); + } + } + } + + private static class Exchange { + private final Message.Mutable message; + private final TransportListener listener; + private final ScheduledFuture task; + + public Exchange(Message.Mutable message, TransportListener listener, ScheduledFuture task) { + this.message = message; + this.listener = listener; + this.task = task; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " " + message; + } + } + + private static class ListeningThread extends Thread { + private Delegate delegate; + private final BufferedReader reader; + + public ListeningThread(Delegate delegate, InputStream inputStream) throws UnsupportedEncodingException { + this.delegate = delegate; + reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + } + + @Override + public void run() { + while (delegate.connected) { + try { + int status = parseHttpStatus(readLine()); + + boolean chunked = false; + int contentSize = 0; + String headerLine; + while (!"".equals(headerLine = readLine())) { + if ("Transfer-Encoding: chunked".equals(headerLine)) + chunked = true; + int pos = headerLine.indexOf("Content-Length: "); + if (pos == 0) { + contentSize = Integer.parseInt(headerLine.substring("Content-Length: ".length())); + } + } + + if (!chunked) { + String content = read(contentSize); + if (content.length() > 0) { + if (status == HttpStatus.OK_200) { + delegate.onData(content); + } + } else { + Map failure = new HashMap<>(2); + // Convert the 200 into 204 (no content) + failure.put("httpCode", HttpStatus.NO_CONTENT_204); + TransportException x = new TransportException(failure); + delegate.fail(x, "No content"); + } + } else { + String unprocessed = ""; + while (!"0".equals(readLine())) { + String data = readLine(); + Log.v(TAG, "data = " + data); + unprocessed += data; + Log.v(TAG, "unprocessed = " + unprocessed); + if (isValidJson(unprocessed)) { + Log.v(TAG, "JSON is valid! Sending data to parser."); + if (status == HttpStatus.OK_200) { + delegate.onData(unprocessed); + } + unprocessed = ""; + } else { + Log.v(TAG, "JSON is not valid! Appending to next chunk."); + } + } + readLine();//Read final/empty chunk + delegate.disconnect("End of chunks"); + } + + if (status != HttpStatus.OK_200) { + Map failure = new HashMap<>(2); + failure.put("httpCode", status); + TransportException x = new TransportException(failure); + delegate.fail(x, "Unexpected HTTP status code"); + } + } catch (IOException e) { + if (delegate.connected) { + delegate.fail(e, "Server disconnected"); + } + return; + } + } + } + + private boolean isValidJson(String jsonStr) { + try { + JSONTokener tokenizer = new JSONTokener(jsonStr); + while (tokenizer.more()) { + Object json = tokenizer.nextValue(); + if (!(json instanceof JSONObject || json instanceof JSONArray)) { + return false; + } + } + return true; + } catch (JSONException e) { + return false; + } + } + + Pattern httpStatusLinePattern = Pattern.compile("HTTP/1.1 (\\d{3}) \\p{all}+"); + private int parseHttpStatus(String statusLine) { + Matcher m = httpStatusLinePattern.matcher(statusLine); + try { + if (m.find()) { + return Integer.parseInt(m.group(1)); + } + } catch (NumberFormatException e) { + } + return -1; + } + + private String readLine() throws IOException { + String inputLine = reader.readLine(); + if (inputLine == null) { + throw new EOFException(); + } + return inputLine; + } + + private String read(int size) throws IOException { + char[] buffer = new char[size]; + int length = reader.read(buffer); + if (length != size) { + throw new EOFException("Expected " + size + " characters, but got " + length); + } + return new String(buffer); + } + } + + + private static String getAdviceAction(Map advice) + { + String action = null; + if (advice != null && advice.containsKey(Message.RECONNECT_FIELD)) + action = (String)advice.get(Message.RECONNECT_FIELD); + return action; + } + + private static boolean isReply(Message message) { + return message.isMeta() || message.isPublishReply(); + } + + + protected void customize(Request request) + { + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/IRButton.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/IRButton.java new file mode 100644 index 000000000..28ecc9aa3 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/IRButton.java @@ -0,0 +1,20 @@ +package uk.org.ngo.squeezer.service; + +public enum IRButton { + playPreset_1("playPreset_1"), + playPreset_2("playPreset_2"), + playPreset_3("playPreset_3"), + playPreset_4("playPreset_4"), + playPreset_5("playPreset_5"), + playPreset_6("playPreset_6"); + + private String function; + + IRButton(String function) { + this.function = function; + } + + public String getFunction() { + return function; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ISqueezeService.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ISqueezeService.java new file mode 100644 index 000000000..6b0390360 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ISqueezeService.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Collection; + +import de.greenrobot.event.EventBus; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Alarm; +import uk.org.ngo.squeezer.model.AlarmPlaylist; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +public interface ISqueezeService { + /** + * @return the EventBus the activity posts events to. + */ + @NonNull EventBus getEventBus(); + + // Instructing the service to connect to the SqueezeCenter server: + // hostPort is the port of the CLI interface. + void startConnect(); + void disconnect(); + boolean isConnected(); + boolean isConnectInProgress(); + + /** Initiate the flow to register the controller with the server */ + void register(IServiceItemListCallback callback); + + // For the SettingsActivity to notify the Service that a setting changed. + void preferenceChanged(String key); + + // Call this to change the player we are controlling + void setActivePlayer(@NonNull Player player); + + // Returns the player we are currently controlling + @Nullable + Player getActivePlayer(); + + /** + * @return players that the server knows about (irrespective of power, connection, or + * other status). + */ + Collection getPlayers(); + + // XXX: Delete, now that PlayerState is tracked in the player? + PlayerState getActivePlayerState(); + + // Player control + void togglePower(Player player); + void playerRename(Player player, String newName); + void sleep(Player player, int duration); + void playerPref(@Player.Pref.Name String playerPref); + void playerPref(@Player.Pref.Name String playerPref, String value); + void playerPref(Player player, @Player.Pref.Name String playerPref, String value); + + /** + * Synchronises the slave player to the player with masterId. + * + * @param player the player to sync. + * @param masterId ID of the player to sync to. + */ + void syncPlayerToPlayer(@NonNull Player player, @NonNull String masterId); + + /** + * Removes the player with playerId from any sync groups. + * + * @param player the player to be removed from sync groups. + */ + void unsyncPlayer(@NonNull Player player); + + //////////////////// + // Depends on active player: + + String getServerVersion() throws SqueezeService.HandshakeNotCompleteException; + boolean togglePausePlay(); + boolean togglePausePlay(Player player); + boolean play(); + boolean pause(); + boolean stop(); + boolean nextTrack(); + boolean nextTrack(Player player); + boolean previousTrack(); + boolean previousTrack(Player player); + boolean toggleShuffle(); + boolean toggleRepeat(); + boolean playlistIndex(int index); + boolean playlistRemove(int index); + boolean playlistMove(int fromIndex, int toIndex); + boolean playlistClear(); + boolean playlistSave(String name); + boolean button(Player player, IRButton button); + + boolean setSecondsElapsed(int seconds); + + PlayerState getPlayerState(); + String getCurrentPlaylist(); + + /** + * Sets the volume to the absolute volume in newVolume, which will be clamped to the + * interval [0, 100]. + */ + void adjustVolumeTo(Player player, int newVolume); + void adjustVolumeTo(int newVolume); + void adjustVolumeBy(int delta); + + /** Cancel any pending callbacks for client */ + void cancelItemListRequests(Object client); + + /** Alarm list */ + void alarms(int start, IServiceItemListCallback callback); + + /** Alarm playlists */ + void alarmPlaylists(IServiceItemListCallback callback); + + /** Alarm maintenance */ + void alarmAdd(int time); + void alarmDelete(String id); + void alarmSetTime(String id, int time); + void alarmAddDay(String id, int day); + void alarmRemoveDay(String id, int day); + void alarmEnable(String id, boolean enabled); + void alarmRepeat(String id, boolean repeat); + void alarmSetPlaylist(String id, AlarmPlaylist playlist); + + + // Plugins (Radios/Apps (music services)/Favorites) + void pluginItems(int start, String cmd, IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException; + + /** + * Start an asynchronous fetch of the squeezeservers generic menu items. + *

+ * See http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#Go_Do.2C_On_and_Off_actions" + * + * @param start Offset of the first item to fetch. Paging parameters are added automatically. + * @param item Current SBS item with the action, and which may contain parameters for the action. + * @param action go action from SBS. "go" refers to a command that opens a new window (i.e. returns results to browse) + * @param callback This will be called as the items arrive. + * @throws SqueezeService.HandshakeNotCompleteException if this is called before handshake is complete + */ + void pluginItems(int start, JiveItem item, Action action, IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException; + + /** + * Start an asynchronous fetch of the squeezeservers generic menu items with no paging nor extra parameters. + *

+ * See http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#Go_Do.2C_On_and_Off_actions" + * + * @param action go action from SBS. "go" refers to a command that opens a new window (i.e. returns results to browse) + * @param callback This will be called as the items arrive. + * @throws SqueezeService.HandshakeNotCompleteException if this is called before handshake is complete + */ + void pluginItems(Action action, IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException; + + /** + * Perform the supplied SBS do action using parameters in item. + *

+ * See http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#Go_Do.2C_On_and_Off_actions" + * + * @param item Current SBS item with the action, and which may contain parameters for the action. + * @param action do action from SBS. "do" refers to an action to perform that does not return browsable data. + */ + void action(JiveItem item, Action action); + + /** + * Perform the supplied SBS do action + *

+ * See http://wiki.slimdevices.com/index.php/SqueezeCenterSqueezePlayInterface#Go_Do.2C_On_and_Off_actions" + * + * @param action do action from SBS. "do" refers to an action to perform that does not return browsable data. + */ + void action(Action.JsonAction action); + + /** + * Find the specified player + * @param playerId id of the player to find + * @return + */ + Player getPlayer(String playerId) throws PlayerNotFoundException; + /** + * Initiate download of songs for the supplied item. + * + * @param item Song or item with songs to download + */ + void downloadItem(JiveItem item) throws SqueezeService.HandshakeNotCompleteException; + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ListHandler.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ListHandler.java new file mode 100644 index 000000000..91b53243f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ListHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2012 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import java.util.List; +import java.util.Map; + +/** + * Implement this for each extended query format command you wish to support. + * + * @author Kurt Aaholst + */ +interface ListHandler { + /** + * @return The type of item this handler can handle + */ + Class getDataType(); + + /** + * @return The list of items received so far + */ + List getItems(); + + /** + * Prepare for parsing an extended query format response + */ + void clear(); + + /** + * Called for each item received in the current reply. Just store this internally. + * + * @param record Item data from Squeezebox Server + */ + void add(Map record); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/NotificationState.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/NotificationState.java new file mode 100644 index 000000000..9eca19416 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/NotificationState.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import android.net.Uri; + +import com.google.common.base.Joiner; + +public class NotificationState { + public boolean hasPlayer; + public String playerName; + + public boolean hasSong; + public String songName; + public String albumName; + public String artistName; + public Uri artworkUrl; + public boolean playing; + + public String artistAlbum() { + return Joiner.on(" - ").skipNulls().join(artistName, albumName); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + NotificationState that = (NotificationState) o; + + if (hasPlayer != that.hasPlayer) return false; + if (hasSong != that.hasSong) return false; + if (playing != that.playing) return false; + if (playerName != null ? !playerName.equals(that.playerName) : that.playerName != null) + return false; + if (songName != null ? !songName.equals(that.songName) : that.songName != null) + return false; + if (albumName != null ? !albumName.equals(that.albumName) : that.albumName != null) + return false; + if (artistName != null ? !artistName.equals(that.artistName) : that.artistName != null) + return false; + return artworkUrl != null ? artworkUrl.equals(that.artworkUrl) : that.artworkUrl == null; + } + + @Override + public int hashCode() { + int result = (hasPlayer ? 1 : 0); + result = 31 * result + (playerName != null ? playerName.hashCode() : 0); + result = 31 * result + (hasSong ? 1 : 0); + result = 31 * result + (songName != null ? songName.hashCode() : 0); + result = 31 * result + (albumName != null ? albumName.hashCode() : 0); + result = 31 * result + (artistName != null ? artistName.hashCode() : 0); + result = 31 * result + (artworkUrl != null ? artworkUrl.hashCode() : 0); + result = 31 * result + (playing ? 1 : 0); + return result; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/PlayerNotFoundException.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/PlayerNotFoundException.java new file mode 100644 index 000000000..17f38a071 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/PlayerNotFoundException.java @@ -0,0 +1,11 @@ +package uk.org.ngo.squeezer.service; + +import android.content.Context; + +import uk.org.ngo.squeezer.R; + +public class PlayerNotFoundException extends Exception { + public PlayerNotFoundException(Context context) { + super(context.getString(R.string.NO_PLAYER_FOUND)); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServerDisconnectedException.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServerDisconnectedException.java new file mode 100644 index 000000000..10145e4b4 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServerDisconnectedException.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +/** + * Thrown if LMS disconnects before handshake is complete + */ +public class ServerDisconnectedException extends Exception { + public ServerDisconnectedException(String s) { + super(s); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServiceCallback.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServiceCallback.java new file mode 100644 index 000000000..7fc85ae72 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/ServiceCallback.java @@ -0,0 +1,17 @@ +package uk.org.ngo.squeezer.service; + + +/** + * Interface to enable automatic removal of callbacks without the need for the + * programmer to manually unregister the callback. + *

+ * All callbacks must specify the context (usually Activity or Fragment in which + * they run, so they can be unregistered via the Android life cycle methods. + */ +public interface ServiceCallback { + + /** + * @return The context in which the callback runs + */ + Object getClient(); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimClient.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimClient.java new file mode 100644 index 000000000..49e0d21c3 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimClient.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2015 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import java.util.Map; + +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** + * Interface implemented by all network clients of the server. + */ +interface SlimClient { + + /** + * Start a connection LMS. Connection progress/status will be reported via + * {@link de.greenrobot.event.EventBus}. + * + * @param service The service hosting this connection. + */ + void startConnect(final SqueezeService service); + + // XXX: Document + void disconnect(); + + ConnectionState getConnectionState(); + String getUsername(); + String getPassword(); + String getUrlPrefix(); + + /** + * Execute the supplied command. + * + * @param player if non null this command is for a specific player + * @param cmd Array of command terms + * @param params Hash of parameters, f.e. {sort = new}. Passed to the server in the form "key:value", f.e. 'sort:new'. + */ + void command(Player player, String[] cmd, Map params); + + /** + * Send an asynchronous request to the SqueezeboxServer for the specified items. + *

+ * Items are requested in chunks of R.integer.PageSize, and returned + * to the caller via the specified callback. + *

+ * If start is zero, this will order one item, to quickly learn the number of items + * from the server. When the server response with this item it is transferred to the + * caller. The remaining items in the first page are then ordered, and transferred + * to the caller when they arrive. + *

+ * If start is < 0, it means the caller wants the entire list. They are ordered in + * pages, and transferred to the caller as they arrive. + *

+ * Otherwise request a page of items starting from start. + *

+ * + * @param player if non null this command is for a specific player + * @param cmd Array of command terms, f.e. ['playlist', 'jump'] + * @param params Hash of parameters, f.e. {sort = new}. Passed to the server in the form "key:value", f.e. 'sort:new'. + * @param start index of the first item to fetch. -1 means to fetch all items in chunks of pageSize + * @param pageSize Number of items to fetch in each request. + * @param callback Received items are returned in this. + */ + void requestItems(Player player, String[] cmd, Map params, int start, int pageSize, IServiceItemListCallback callback); + + /** + * Notify that the specified client (activity) nno longer wants messages from LMS. + * @param client messages receiver to remove + */ + void cancelClientRequests(Object client); + + void requestPlayerStatus(Player player); + + void subscribePlayerStatus(Player newActivePlayer, PlayerState.PlayerSubscriptionType subscriptionType); + void subscribeDisplayStatus(Player player, boolean subscribe); + void subscribeMenuStatus(Player player, boolean subscribe); +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimDelegate.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimDelegate.java new file mode 100644 index 000000000..c06902679 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SlimDelegate.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2017 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import androidx.annotation.NonNull; + +import java.util.List; +import java.util.Map; + +import de.greenrobot.event.EventBus; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.SlimCommand; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +class SlimDelegate { + + @NonNull private final SlimClient mClient; + + SlimDelegate(@NonNull EventBus eventBus) { + mClient = new CometClient(eventBus); + } + + void startConnect(SqueezeService service) { + mClient.startConnect(service); + } + + void disconnect() { + mClient.disconnect(); + } + + void cancelClientRequests(Object client) { + mClient.cancelClientRequests(client); + } + + + void requestPlayerStatus(Player player) { + mClient.requestPlayerStatus(player); + } + + void subscribePlayerStatus(Player player, PlayerState.PlayerSubscriptionType subscriptionType) { + mClient.subscribePlayerStatus(player, subscriptionType); + } + + void subscribeDisplayStatus(Player player, boolean subscribe) { + mClient.subscribeDisplayStatus(player, subscribe); + } + + void subscribeMenuStatus(Player player, boolean subscribe) { + mClient.subscribeMenuStatus(player, subscribe); + } + + + boolean isConnected() { + return mClient.getConnectionState().isConnected(); + } + + boolean isConnectInProgress() { + return mClient.getConnectionState().isConnectInProgress(); + } + + String getServerVersion() { + return mClient.getConnectionState().getServerVersion(); + } + + Command command(Player player) { + return new Command(mClient, player); + } + + Command command() { + return new Command(mClient); + } + + /** If there is an active player call {@link #command(Player)} with the active player */ + Command activePlayerCommand() { + return new PlayerCommand(mClient, mClient.getConnectionState().getActivePlayer()); + } + + Request requestItems(Player player, int start, IServiceItemListCallback callback) { + return new Request<>(mClient, player, start, callback); + } + + Request requestItems(Player player, IServiceItemListCallback callback) { + return new Request<>(mClient, player, 0, 200, callback); + } + + Request requestItems(int start, IServiceItemListCallback callback) { + return new Request<>(mClient, start, callback); + } + + Request requestItems(IServiceItemListCallback callback) { + return new Request<>(mClient, 0, 200, callback); + } + + public Player getActivePlayer() { + return mClient.getConnectionState().getActivePlayer(); + } + + void setActivePlayer(Player player) { + mClient.getConnectionState().setActivePlayer(player); + } + + Player getPlayer(String playerId) { + return mClient.getConnectionState().getPlayer(playerId); + } + + public Map getPlayers() { + return mClient.getConnectionState().getPlayers(); + } + + void clearHomeMenu() { + mClient.getConnectionState().clearHomeMenu(); + } + + void addToHomeMenu(int count, List items) { + mClient.getConnectionState().addToHomeMenu(count, items); + } + + public String getUsername() { + return mClient.getUsername(); + } + + public String getPassword() { + return mClient.getPassword(); + } + + String getUrlPrefix() { + return mClient.getUrlPrefix(); + } + + String[] getMediaDirs() { + return mClient.getConnectionState().getMediaDirs(); + } + + static class Command extends SlimCommand { + final SlimClient slimClient; + final protected Player player; + + private Command(SlimClient slimClient, Player player) { + this.slimClient = slimClient; + this.player = player; + } + + private Command(SlimClient slimClient) { + this(slimClient, null); + } + + @Override + public Command cmd(String... commandTerms) { + super.cmd(commandTerms); + return this; + } + + @Override + public Command cmd(List commandTerms) { + super.cmd(commandTerms); + return this; + } + + @Override + public Command params(Map params) { + super.params(params); + return this; + } + + @Override + public Command param(String tag, Object value) { + super.param(tag, value); + return this; + } + + protected void exec() { + slimClient.command(player, cmd(), params); + } + } + + static class PlayerCommand extends Command { + + private PlayerCommand(SlimClient slimClient, Player player) { + super(slimClient, player); + } + + @Override + protected void exec() { + if (player != null) super.exec(); + } + } + + static class Request extends Command { + private final IServiceItemListCallback callback; + private final int start; + private final int pageSize; + + private Request(SlimClient slimClient, Player player, int start, int pageSize, IServiceItemListCallback callback) { + super(slimClient, player); + this.callback = callback; + this.start = start; + this.pageSize = pageSize; + } + + private Request(SlimClient slimClient, Player player, int start, IServiceItemListCallback callback) { + this(slimClient, player, start, BaseClient.mPageSize, callback); + } + + private Request(SlimClient slimClient, int start, IServiceItemListCallback callback) { + this(slimClient, null, start, BaseClient.mPageSize, callback); + } + + private Request(SlimClient slimClient, int start, int pageSize, IServiceItemListCallback callback) { + this(slimClient, null, start, pageSize, callback); + } + + @Override + protected void exec() { + slimClient.requestItems(player, cmd.toArray(new String[0]), params, start, pageSize, callback); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezeService.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezeService.java new file mode 100644 index 000000000..952ea663f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezeService.java @@ -0,0 +1,1431 @@ +/* + * Copyright (c) 2009 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.MediaMetadata; +import android.net.Uri; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.util.Log; +import android.widget.RemoteViews; + +import com.google.common.io.Files; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import uk.org.ngo.squeezer.NowPlayingActivity; +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Squeezer; +import uk.org.ngo.squeezer.Util; +import uk.org.ngo.squeezer.download.DownloadDatabase; +import uk.org.ngo.squeezer.model.Action; +import uk.org.ngo.squeezer.model.JiveItem; +import uk.org.ngo.squeezer.model.MusicFolderItem; +import uk.org.ngo.squeezer.model.SlimCommand; +import uk.org.ngo.squeezer.itemlist.IServiceItemListCallback; +import uk.org.ngo.squeezer.model.Alarm; +import uk.org.ngo.squeezer.model.AlarmPlaylist; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; +import uk.org.ngo.squeezer.model.Song; +import uk.org.ngo.squeezer.service.event.ConnectionChanged; +import uk.org.ngo.squeezer.service.event.HandshakeComplete; +import uk.org.ngo.squeezer.service.event.MusicChanged; +import uk.org.ngo.squeezer.service.event.PlayStatusChanged; +import uk.org.ngo.squeezer.service.event.PlayerStateChanged; +import uk.org.ngo.squeezer.service.event.PlayersChanged; +import uk.org.ngo.squeezer.service.event.SongTimeChanged; +import uk.org.ngo.squeezer.util.ImageFetcher; +import uk.org.ngo.squeezer.util.NotificationUtil; +import uk.org.ngo.squeezer.util.Scrobble; + +/** + * Persistent service which acts as an interface to for activities to communicate with LMS. + *

+ * The interface is documented here {@link ISqueezeService} + *

+ * The service lifecycle is managed as both a bound and a started servic. as follows. + *

    + *
  • On connect to LMS call Context.start[Foreground]Service and Service.startForeground
  • + *
  • On disconnect from LMS call Service.stopForeground and Service.stopSelf
  • + *
  • bind to the SqueezeService in activities in onCreate
  • + *
  • unbind the SqueezeService in activities onDestroy
  • + *
+ * This means the service will as long as there is a Squeezer or we are connected to LMS activity. + * When we are connected to LMS it runs as a foreground service and a notification is displayed. + */ +public class SqueezeService extends Service { + + private static final String TAG = "SqueezeService"; + + public static final String NOTIFICATION_CHANNEL_ID = "channel_squeezer_1"; + private static final int PLAYBACKSERVICE_STATUS = 1; + public static final int DOWNLOAD_ERROR = 2; + + /** Service-specific eventbus. All events generated by the service will be sent here. */ + private final EventBus mEventBus = new EventBus(); + + /** Executor for off-main-thread work. */ + @NonNull + private final ScheduledThreadPoolExecutor mExecutor = new ScheduledThreadPoolExecutor(1); + + /** True if the handshake with the server has completed, otherwise false. */ + private volatile boolean mHandshakeComplete = false; + + /** Media session to associate with ongoing notifications. */ + private MediaSessionCompat mMediaSession; + + /** Are the service currently in the foregrund */ + private volatile boolean foreGround; + + /** The most recent notifcation. */ + private NotificationState ongoingNotification; + + private final SlimDelegate mDelegate = new SlimDelegate(mEventBus); + + /** + * Is scrobbling enabled? + */ + private boolean scrobblingEnabled; + + /** + * Was scrobbling enabled? + */ + private boolean scrobblingPreviouslyEnabled; + + int mFadeInSecs; + + private static final String ACTION_NEXT_TRACK = "uk.org.ngo.squeezer.service.ACTION_NEXT_TRACK"; + private static final String ACTION_PREV_TRACK = "uk.org.ngo.squeezer.service.ACTION_PREV_TRACK"; + private static final String ACTION_PLAY = "uk.org.ngo.squeezer.service.ACTION_PLAY"; + private static final String ACTION_PAUSE = "uk.org.ngo.squeezer.service.ACTION_PAUSE"; + private static final String ACTION_CLOSE = "uk.org.ngo.squeezer.service.ACTION_CLOSE"; + + private final BroadcastReceiver deviceIdleModeReceiver = new BroadcastReceiver() { + @Override + @RequiresApi(api = Build.VERSION_CODES.M) + public void onReceive(Context context, Intent intent) { + // On M and above going in to Doze mode suspends the network but does not shut down + // existing network connections or cause them to generate exceptions. Explicitly + // disconnect here, so that resuming from Doze mode forces a reconnect. See + // https://github.com/nikclayton/android-squeezer/issues/177. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + + if (pm.isDeviceIdleMode()) { + Log.d(TAG, "Entering doze mode, disconnecting"); + disconnect(); + } + } + } + }; + + + /** + * Thrown when the service is asked to send a command to the server before the server + * handshake completes. + */ + public static class HandshakeNotCompleteException extends IllegalStateException { + public HandshakeNotCompleteException() { + super(); + } + + public HandshakeNotCompleteException(String message) { + super(message); + } + + public HandshakeNotCompleteException(String message, Throwable cause) { + super(message, cause); + } + + public HandshakeNotCompleteException(Throwable cause) { + super(cause); + } + } + + @Override + public void onCreate() { + super.onCreate(); + + // Clear leftover notification in case this service previously got killed while playing + NotificationManagerCompat nm = NotificationManagerCompat.from(this); + nm.cancel(PLAYBACKSERVICE_STATUS); + + cachePreferences(); + + setWifiLock(((WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE)).createWifiLock( + WifiManager.WIFI_MODE_FULL, "Squeezer_WifiLock")); + + mEventBus.register(this, 1); // Get events before other subscribers + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + registerReceiver(deviceIdleModeReceiver, new IntentFilter( + PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + try{ + if(intent != null && intent.getAction()!= null ) { + switch (intent.getAction()) { + case ACTION_NEXT_TRACK: + squeezeService.nextTrack(); + break; + case ACTION_PREV_TRACK: + squeezeService.previousTrack(); + break; + case ACTION_PLAY: + squeezeService.play(); + break; + case ACTION_PAUSE: + squeezeService.pause(); + break; + case ACTION_CLOSE: + squeezeService.disconnect(); + break; + } + } + } catch(Exception e) { + Log.w(TAG, "Error executing intent: ", e); + } + return START_STICKY; + } + + /** + * Cache the value of various preferences. + */ + private void cachePreferences() { + final SharedPreferences preferences = getSharedPreferences(Preferences.NAME, MODE_PRIVATE); + scrobblingEnabled = preferences.getBoolean(Preferences.KEY_SCROBBLE_ENABLED, false); + mFadeInSecs = preferences.getInt(Preferences.KEY_FADE_IN_SECS, 0); + } + + @Override + public IBinder onBind(Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mMediaSession = new MediaSessionCompat(getApplicationContext(), "squeezer"); + } + return (IBinder) squeezeService; + } + + @Override + public boolean onUnbind(Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (mMediaSession != null) { + mMediaSession.release(); + } + } + return super.onUnbind(intent); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disconnect(); + mEventBus.unregister(this); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + unregisterReceiver(deviceIdleModeReceiver); + } catch (IllegalArgumentException e) { + // Do nothing. This can occur in testing when we destroy the service before the + // receiver is registered. + } + } + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + disconnect(); + super.onTaskRemoved(rootIntent); + } + + void disconnect() { + mDelegate.disconnect(); + } + + @Nullable public PlayerState getActivePlayerState() { + Player activePlayer = mDelegate.getActivePlayer(); + return activePlayer == null ? null : activePlayer.getPlayerState(); + + } + + /** + * The player state change might warrant a new subscription type (e.g., if the + * player didn't have a sleep duration set, and now does). + */ + public void onEvent(PlayerStateChanged event) { + updatePlayerSubscription(event.player, calculateSubscriptionTypeFor(event.player)); + } + + /** + * Updates the playing status of the current player. + *

+ * Updates the Wi-Fi lock and ongoing status notification as necessary. + */ + public void onEvent(PlayStatusChanged event) { + if (event.player.equals(mDelegate.getActivePlayer())) { + updateWifiLock(event.player.getPlayerState().isPlaying()); + updateOngoingNotification(); + } + + updatePlayerSubscription(event.player, calculateSubscriptionTypeFor(event.player)); + } + + /** + * Change the player that is controlled by Squeezer (the "active" player). + * + * @param newActivePlayer The new active player. May be null, in which case no players + * are controlled. + */ + void changeActivePlayer(@Nullable final Player newActivePlayer) { + Player prevActivePlayer = mDelegate.getActivePlayer(); + + // Do nothing if the player hasn't actually changed. + if (prevActivePlayer == newActivePlayer) { + return; + } + + mDelegate.setActivePlayer(newActivePlayer); + if (prevActivePlayer != null) { + mDelegate.subscribeDisplayStatus(prevActivePlayer, false); + mDelegate.subscribeMenuStatus(prevActivePlayer, false); + } + if (newActivePlayer != null) { + mDelegate.subscribeDisplayStatus(newActivePlayer, true); + mDelegate.subscribeMenuStatus(newActivePlayer, true); + } + updateAllPlayerSubscriptionStates(); + + Log.i(TAG, "Active player now: " + newActivePlayer); + + // If this is a new player then start an async fetch of its status. + if (newActivePlayer != null) { + mDelegate.requestPlayerStatus(newActivePlayer); + + // Start an asynchronous fetch of the squeezeservers "home menu" items + // See http://wiki.slimdevices.com/index.php/SqueezePlayAndSqueezeCenterPlugins + mDelegate.clearHomeMenu(); + mDelegate.requestItems(newActivePlayer, 0, new IServiceItemListCallback() { + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + mDelegate.addToHomeMenu(count, items); + } + + @Override + public Object getClient() { + return SqueezeService.this; + } + }).cmd("menu").param("direct", "1").exec(); + } + + // NOTE: this involves a write and can block (sqlite lookup via binder call), so + // should be done off-thread, so we can process service requests & send our callback + // as quickly as possible. + mExecutor.execute(() -> { + final SharedPreferences preferences = Squeezer.getContext().getSharedPreferences(Preferences.NAME, + Squeezer.MODE_PRIVATE); + SharedPreferences.Editor editor = preferences.edit(); + + if (newActivePlayer == null) { + Log.v(TAG, "Clearing " + Preferences.KEY_LAST_PLAYER); + editor.remove(Preferences.KEY_LAST_PLAYER); + } else { + Log.v(TAG, "Saving " + Preferences.KEY_LAST_PLAYER + "=" + newActivePlayer.getId()); + editor.putString(Preferences.KEY_LAST_PLAYER, newActivePlayer.getId()); + } + + editor.apply(); + }); + } + + /** + * Adjusts the subscription to players' status updates. + */ + private void updateAllPlayerSubscriptionStates() { + for (Player player : mDelegate.getPlayers().values()) { + updatePlayerSubscription(player, calculateSubscriptionTypeFor(player)); + } + } + + /** + * Determine the correct status subscription type for the given player, based on + * how frequently we need to know its status. + */ + private PlayerState.PlayerSubscriptionType calculateSubscriptionTypeFor(Player player) { + Player activePlayer = mDelegate.getActivePlayer(); + + if (mEventBus.hasSubscriberForEvent(PlayerStateChanged.class) || + (mEventBus.hasSubscriberForEvent(SongTimeChanged.class) && player.equals(activePlayer))) { + return PlayerState.PlayerSubscriptionType.NOTIFY_ON_CHANGE; + } else { + return PlayerState.PlayerSubscriptionType.NOTIFY_NONE; + } + } + + /** + * Manage subscription to a player's status updates. + * + * @param player player to manage. + * @param playerSubscriptionType the new subscription type + */ + private void updatePlayerSubscription( + Player player, + @NonNull PlayerState.PlayerSubscriptionType playerSubscriptionType) { + PlayerState playerState = player.getPlayerState(); + + // Do nothing if the player subscription type hasn't changed. This prevents sending a + // subscription update "status" message which will be echoed back by the server and + // trigger processing of the status message by the service. + if (playerState.getSubscriptionType().equals(playerSubscriptionType)) { + return; + } + + mDelegate.subscribePlayerStatus(player, playerSubscriptionType); + } + + /** + * Manages the state of any ongoing notification based on the player and connection state. + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private void updateOngoingNotification() { + PlayerState activePlayerState = getActivePlayerState(); + + // Update scrobble state, if either we're currently scrobbling, or we + // were (to catch the case where we started scrobbling a song, and the + // user went in to settings to disable scrobbling). + if (scrobblingEnabled || scrobblingPreviouslyEnabled) { + scrobblingPreviouslyEnabled = scrobblingEnabled; + Scrobble.scrobbleFromPlayerState(this, activePlayerState); + } + + NotificationState notificationState = notificationState(); + + // Compare the current state with the state when the notification was last updated. + // If there are no changes (same song, same playing state) then there's nothing to do. + if (notificationState.equals(ongoingNotification)) { + return; + } + ongoingNotification = notificationState; + + final NotificationManagerCompat nm = NotificationManagerCompat.from(this); + final NotificationData notificationData = new NotificationData(notificationState); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final MediaMetadataCompat.Builder metaBuilder = new MediaMetadataCompat.Builder(); + metaBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, notificationState.artistName); + metaBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, notificationState.albumName); + metaBuilder.putString(MediaMetadata.METADATA_KEY_TITLE, notificationState.songName); + mMediaSession.setMetadata(metaBuilder.build()); + + ImageFetcher.getInstance(this).loadImage(notificationState.artworkUrl, + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height), + (data, bitmap) -> { + if (bitmap == null) { + bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.icon_pending_artwork); + } + + metaBuilder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap); + metaBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap); + mMediaSession.setMetadata(metaBuilder.build()); + notificationData.builder.setLargeIcon(bitmap); + nm.notify(PLAYBACKSERVICE_STATUS, notificationData.builder.build()); + }); + } else { + Notification notification = notificationData.builder.build(); + notification.bigContentView = notificationData.expandedView; + + nm.notify(PLAYBACKSERVICE_STATUS, notification); + + ImageFetcher.getInstance(this).loadImage(this, notificationState.artworkUrl, notificationData.normalView, R.id.album, + getResources().getDimensionPixelSize(R.dimen.album_art_icon_normal_notification_width), + getResources().getDimensionPixelSize(R.dimen.album_art_icon_normal_notification_height), + nm, PLAYBACKSERVICE_STATUS, notification); + ImageFetcher.getInstance(this).loadImage(this, notificationState.artworkUrl, notificationData.expandedView, R.id.album, + getResources().getDimensionPixelSize(R.dimen.album_art_icon_expanded_notification_width), + getResources().getDimensionPixelSize(R.dimen.album_art_icon_expanded_notification_height), + nm, PLAYBACKSERVICE_STATUS, notification); + } + } + + private class NotificationData { + private final NotificationCompat.Builder builder; + private RemoteViews normalView; + private RemoteViews expandedView; + + /** + * Prepare a notification builder from the supplied notification state. + */ + @TargetApi(21) + private NotificationData(NotificationState notificationState) { + PendingIntent nextPendingIntent = getPendingIntent(ACTION_NEXT_TRACK); + PendingIntent prevPendingIntent = getPendingIntent(ACTION_PREV_TRACK); + PendingIntent playPendingIntent = getPendingIntent(ACTION_PLAY); + PendingIntent pausePendingIntent = getPendingIntent(ACTION_PAUSE); + PendingIntent closePendingIntent = getPendingIntent(ACTION_CLOSE); + + Intent showNowPlaying = new Intent(SqueezeService.this, NowPlayingActivity.class) + .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + PendingIntent pIntent = PendingIntent.getActivity(SqueezeService.this, 0, showNowPlaying, 0); + + + NotificationUtil.createNotificationChannel(SqueezeService.this, NOTIFICATION_CHANNEL_ID, + "Squeezer ongoing notification", + "Notifications of player and connection state", + NotificationManagerCompat.IMPORTANCE_LOW, false, NotificationCompat.VISIBILITY_PUBLIC); + builder = new NotificationCompat.Builder(SqueezeService.this, NOTIFICATION_CHANNEL_ID); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setContentIntent(pIntent); + builder.setSmallIcon(R.drawable.squeezer_notification); + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); + builder.setShowWhen(false); + builder.setContentTitle(notificationState.songName); + builder.setContentText(notificationState.artistAlbum()); + builder.setSubText(notificationState.playerName); + builder.setStyle(new androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(2, 3) + .setMediaSession(mMediaSession.getSessionToken())); + + // Don't set an ongoing notification, otherwise wearable's won't show it. + builder.setOngoing(false); + + builder.setDeleteIntent(closePendingIntent); + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_disconnect, "Disconnect", closePendingIntent)); + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_previous, "Previous", prevPendingIntent)); + if (notificationState.playing) { + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_pause, "Pause", pausePendingIntent)); + } else { + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_play, "Play", playPendingIntent)); + } + builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_next, "Next", nextPendingIntent)); + } else { + normalView = new RemoteViews(SqueezeService.this.getPackageName(), R.layout.notification_player_normal); + expandedView = new RemoteViews(SqueezeService.this.getPackageName(), R.layout.notification_player_expanded); + + builder.setOngoing(true); + builder.setCategory(NotificationCompat.CATEGORY_SERVICE); + builder.setSmallIcon(R.drawable.squeezer_notification); + + normalView.setImageViewBitmap(R.id.next, vectorToBitmap(R.drawable.ic_action_next)); + normalView.setOnClickPendingIntent(R.id.next, nextPendingIntent); + + expandedView.setImageViewBitmap(R.id.disconnect, vectorToBitmap(R.drawable.ic_action_disconnect)); + expandedView.setOnClickPendingIntent(R.id.disconnect, closePendingIntent); + expandedView.setImageViewBitmap(R.id.previous, vectorToBitmap(R.drawable.ic_action_previous)); + expandedView.setOnClickPendingIntent(R.id.previous, prevPendingIntent); + expandedView.setImageViewBitmap(R.id.next, vectorToBitmap(R.drawable.ic_action_next)); + expandedView.setOnClickPendingIntent(R.id.next, nextPendingIntent); + + builder.setContent(normalView); + builder.setCustomBigContentView(expandedView); + + normalView.setTextViewText(R.id.trackname, notificationState.songName); + normalView.setTextViewText(R.id.artist_album, notificationState.artistAlbum()); + + expandedView.setTextViewText(R.id.trackname, notificationState.songName); + expandedView.setTextViewText(R.id.artist_album, notificationState.artistAlbum()); + expandedView.setTextViewText(R.id.player_name, notificationState.playerName); + + if (notificationState.playing) { + normalView.setImageViewBitmap(R.id.pause, vectorToBitmap(R.drawable.ic_action_pause)); + normalView.setOnClickPendingIntent(R.id.pause, pausePendingIntent); + + expandedView.setImageViewBitmap(R.id.pause, vectorToBitmap(R.drawable.ic_action_pause)); + expandedView.setOnClickPendingIntent(R.id.pause, pausePendingIntent); + } else { + normalView.setImageViewBitmap(R.id.pause, vectorToBitmap(R.drawable.ic_action_play)); + normalView.setOnClickPendingIntent(R.id.pause, playPendingIntent); + + expandedView.setImageViewBitmap(R.id.pause, vectorToBitmap(R.drawable.ic_action_play)); + expandedView.setOnClickPendingIntent(R.id.pause, playPendingIntent); + } + + builder.setContentTitle(notificationState.songName); + builder.setContentText(getString(R.string.notification_playing_text, notificationState.playerName)); + builder.setContentIntent(pIntent); + } + } + } + + private Bitmap vectorToBitmap(@DrawableRes int vectorResource) { + return Util.vectorToBitmap(this, vectorResource, 0xAA); + } + + /** + * Build current notification state based on the player and connection state. + */ + private NotificationState notificationState() { + NotificationState notificationState = new NotificationState(); + + final Player activePlayer = mDelegate.getActivePlayer(); + notificationState.hasPlayer = (activePlayer != null); + if (notificationState.hasPlayer) { + final PlayerState activePlayerState = activePlayer.getPlayerState(); + + notificationState.playing = activePlayerState.isPlaying(); + + final CurrentPlaylistItem currentSong = activePlayerState.getCurrentSong(); + notificationState.hasSong = (currentSong != null); + if (currentSong != null) { + notificationState.songName = currentSong.getName(); + notificationState.albumName = currentSong.getAlbum(); + notificationState.artistName = currentSong.getArtist(); + notificationState.artworkUrl = currentSong.getIcon(); + notificationState.playerName = activePlayer.getName(); + } + } + + return notificationState; + } + + /** + * @param action The action to be performed. + * @return A new {@link PendingIntent} for {@literal action} that will update any existing + * intents that use the same action. + */ + @NonNull + private PendingIntent getPendingIntent(@NonNull String action){ + Intent intent = new Intent(this, SqueezeService.class); + intent.setAction(action); + return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + public void onEvent(ConnectionChanged event) { + if (ConnectionState.isConnected(event.connectionState) || + ConnectionState.isConnectInProgress(event.connectionState)) { + startForeground(); + } else { + mHandshakeComplete = false; + stopForeground(); + } + } + + private void startForeground() { + if (!foreGround) { + Log.i(TAG, "startForeground"); + foreGround = true; + + NotificationState notificationState = notificationState(); + NotificationData notificationData = new NotificationData(notificationState); + Notification notification = notificationData.builder.build(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final MediaMetadataCompat.Builder metaBuilder = new MediaMetadataCompat.Builder(); + metaBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, notificationState.artistName); + metaBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, notificationState.albumName); + metaBuilder.putString(MediaMetadata.METADATA_KEY_TITLE, notificationState.songName); + mMediaSession.setMetadata(metaBuilder.build()); + } else { + notification.bigContentView = notificationData.expandedView; + } + + // Start it and have it run forever (until it shuts itself down). + // This is required so swapping out the activity (and unbinding the + // service connection in onDestroy) doesn't cause the service to be + // killed due to zero refcount. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(new Intent(this, SqueezeService.class)); + } else { + startService(new Intent(this, SqueezeService.class)); + } + + // Call startForeground immediately after startForegroundService + startForeground(PLAYBACKSERVICE_STATUS, notification); + } + } + + private void stopForeground() { + Log.i(TAG, "stopForeground"); + foreGround = false; + ongoingNotification = null; + stopForeground(true); + stopSelf(); + } + + public void onEvent(HandshakeComplete event) { + mHandshakeComplete = true; + } + + public void onEvent(MusicChanged event) { + if (event.player.equals(mDelegate.getActivePlayer())) { + updateOngoingNotification(); + } + } + + public void onEvent(PlayersChanged event) { + // Figure out the new active player, let everyone know. + changeActivePlayer(getPreferredPlayer(event.players.values())); + } + + /** + * @return The player that should be chosen as the (new) active player. This is either the + * last active player (if known), the first player the server knows about if there are + * connected players, or null if there are no connected players. + */ + private @Nullable Player getPreferredPlayer(Collection players) { + final SharedPreferences preferences = Squeezer.getContext().getSharedPreferences(Preferences.NAME, + Context.MODE_PRIVATE); + final String lastConnectedPlayer = preferences.getString(Preferences.KEY_LAST_PLAYER, + null); + Log.i(TAG, "lastConnectedPlayer was: " + lastConnectedPlayer); + + Log.i(TAG, "players empty?: " + players.isEmpty()); + for (Player player : players) { + if (player.getId().equals(lastConnectedPlayer)) { + return player; + } + } + return !players.isEmpty() ? players.iterator().next() : null; + } + + /** A download request will be passed to the download manager for each song called back to this */ + private final IServiceItemListCallback songDownloadCallback = new IServiceItemListCallback() { + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + final Preferences preferences = new Preferences(SqueezeService.this); + for (Song song : items) { + Log.i(TAG, "downloadSong(" + song + ")"); + Uri downloadUrl = Util.getDownloadUrl(mDelegate.getUrlPrefix(), song.id); + if (preferences.isDownloadUseServerPath()) { + downloadSong(downloadUrl, song.title, song.album, song.artist, getLocalFile(song.url)); + } else { + final String lastPathSegment = song.url.getLastPathSegment(); + final String fileExtension = Files.getFileExtension(lastPathSegment); + final String localPath = song.getLocalPath(preferences.getDownloadPathStructure(), preferences.getDownloadFilenameStructure()); + downloadSong(downloadUrl, song.title, song.album, song.artist, localPath + "." + fileExtension); + } + } + } + + @Override + public Object getClient() { + return this; + } + }; + + /** + * For each item called to this: + * If it is a folder: recursive lookup items in the folder + * If is is a track: Enqueue a download request to the download manager + */ + private final IServiceItemListCallback musicFolderDownloadCallback = new IServiceItemListCallback() { + @Override + public void onItemsReceived(int count, int start, Map parameters, List items, Class dataType) { + for (MusicFolderItem item : items) { + if ("track".equals(item.type)) { + Log.i(TAG, "downloadMusicFolderTrack(" + item + ")"); + SlimCommand command = JiveItem.downloadCommand(item.id); + mDelegate.requestItems(-1, songDownloadCallback).params(command.params).cmd(command.cmd()).exec(); + } + } + } + + @Override + public Object getClient() { + return this; + } + }; + + private void downloadSong(@NonNull Uri url, String title, String album, String artist, String localPath) { + Log.i(TAG, "downloadSong(" + title + "): " + url); + if (url.equals(Uri.EMPTY)) { + return; + } + + if (localPath == null) { + return; + } + + // Convert VFAT-unfriendly characters to "_". + localPath = localPath.replaceAll("[?<>\\\\:*|\"]", "_"); + DownloadDatabase downloadDatabase = new DownloadDatabase(this); + String credentials = mDelegate.getUsername() + ":" + mDelegate.getPassword(); + downloadDatabase.registerDownload(this, credentials, url, localPath, title, album, artist); + } + + /** + * Tries to get the path relative to the server music library. + *

+ * If this is not possible resort to the last path segment of the server path. + */ + @Nullable + private String getLocalFile(@NonNull Uri serverUrl) { + String serverPath = serverUrl.getPath(); + String mediaDir = null; + String path; + for (String dir : mDelegate.getMediaDirs()) { + if (serverPath.startsWith(dir)) { + mediaDir = dir; + break; + } + } + if (mediaDir != null) { + path = serverPath.substring(mediaDir.length()); + } else { + // Note: if serverUrl is the empty string this can return null. + path = serverUrl.getLastPathSegment(); + } + + return path; + } + + + private WifiManager.WifiLock wifiLock; + + void setWifiLock(WifiManager.WifiLock wifiLock) { + this.wifiLock = wifiLock; + } + + void updateWifiLock(boolean state) { + // TODO: this might be running in the wrong thread. Is wifiLock thread-safe? + if (state && !wifiLock.isHeld()) { + Log.v(TAG, "Locking wifi while playing."); + wifiLock.acquire(); + } + if (!state && wifiLock.isHeld()) { + Log.v(TAG, "Unlocking wifi."); + try { + wifiLock.release(); + // Seen a crash here with: + // + // Permission Denial: broadcastIntent() requesting a sticky + // broadcast + // from pid=29506, uid=10061 requires + // android.permission.BROADCAST_STICKY + // + // Catching the exception (which seems harmless) seems better + // than requesting an additional permission. + + // Seen a crash here with + // + // java.lang.RuntimeException: WifiLock under-locked + // Squeezer_WifiLock + // + // Both crashes occurred when the wifi was disabled, on HTC Hero + // devices running 2.1-update1. + } catch (SecurityException e) { + Log.v(TAG, "Caught odd SecurityException releasing wifilock"); + } + } + } + + private final ISqueezeService squeezeService = new SqueezeServiceBinder(); + private class SqueezeServiceBinder extends Binder implements ISqueezeService { + + @Override + @NonNull + public EventBus getEventBus() { + return mEventBus; + } + + @Override + public void adjustVolumeTo(Player player, int newVolume) { + mDelegate.command(player).cmd("mixer", "volume", String.valueOf(Math.min(100, Math.max(0, newVolume)))).exec(); + } + + @Override + public void adjustVolumeTo(int newVolume) { + mDelegate.activePlayerCommand().cmd("mixer", "volume", String.valueOf(Math.min(100, Math.max(0, newVolume)))).exec(); + } + + @Override + public void adjustVolumeBy(int delta) { + if (delta > 0) { + mDelegate.activePlayerCommand().cmd("mixer", "volume", "+" + delta).exec(); + } else if (delta < 0) { + mDelegate.activePlayerCommand().cmd("mixer", "volume", String.valueOf(delta)).exec(); + } + } + + @Override + public boolean isConnected() { + return mDelegate.isConnected(); + } + + @Override + public boolean isConnectInProgress() { + return mDelegate.isConnectInProgress(); + } + + @Override + public void startConnect() { + mDelegate.startConnect(SqueezeService.this); + } + + @Override + public void disconnect() { + if (!isConnected()) { + return; + } + SqueezeService.this.disconnect(); + } + + @Override + public void register(IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException { + if (!mHandshakeComplete) { + throw new HandshakeNotCompleteException("Handshake with server has not completed."); + } + // We register ourselves as a player. This will come back in serverstatus, so we get an + // active player, which is required for the register_sn command: + // [ "playerid", [ "register_sn", 0, 100, "login_password", "email:...", "password:..." ] ] + // We then start register flow with the command: + // [ "", [ "register", 0, 100, "login_password", "service:SN" ] ] + // This is same command squeezeplay uses, and allows connect to an existing account or + // create a new. + // This way we can use server side logic and we don't have to store account credentials + // locally. + String macId = new Preferences(SqueezeService.this).getMacId(); + mDelegate.command().cmd("playerRegister", null, macId, "Squeezer-" + Build.MODEL).exec(); + mDelegate.requestItems(callback).cmd("register").param("service", "SN").exec(); + } + + @Override + public void togglePower(Player player) { + mDelegate.command(player).cmd("power").exec(); + } + + @Override + public void playerRename(Player player, String newName) { + mDelegate.command(player).cmd("name", newName).exec(); + } + + @Override + public void sleep(Player player, int duration) { + mDelegate.command(player).cmd("sleep", String.valueOf(duration)).exec(); + } + + @Override + public void syncPlayerToPlayer(@NonNull Player slave, @NonNull String masterId) { + Player master = mDelegate.getPlayer(masterId); + mDelegate.command(master).cmd("sync", slave.getId()).exec(); + } + + @Override + public void unsyncPlayer(@NonNull Player player) { + mDelegate.command(player).cmd("sync", "-").exec(); + } + + + @Override + @Nullable + public PlayerState getActivePlayerState() { + Player activePlayer = getActivePlayer(); + if (activePlayer == null) { + return null; + } + + return activePlayer.getPlayerState(); + } + + /** + * Issues a query for given player preference. + */ + @Override + public void playerPref(@Player.Pref.Name String playerPref) { + playerPref(playerPref, "?"); + } + + @Override + public void playerPref(@Player.Pref.Name String playerPref, String value) { + mDelegate.activePlayerCommand().cmd("playerpref", playerPref, value).exec(); + } + + @Override + public void playerPref(Player player, @Player.Pref.Name String playerPref, String value) { + mDelegate.command(player).cmd("playerpref", playerPref, value).exec(); + } + + @Override + public String getServerVersion() throws HandshakeNotCompleteException { + if (!mHandshakeComplete) { + throw new HandshakeNotCompleteException("Handshake with server has not completed."); + } + return mDelegate.getServerVersion(); + } + + private String fadeInSecs() { + return mFadeInSecs > 0 ? " " + mFadeInSecs : ""; + } + + @Override + public boolean togglePausePlay() { + return togglePausePlay(getActivePlayer()); + } + @Override + public boolean togglePausePlay(Player player) { + if (!isConnected()) { + return false; + } + + + // May be null (e.g., connected to a server with no connected + // players. TODO: Handle this better, since it's not obvious in the + // UI. + if (player == null) + return false; + + PlayerState activePlayerState = player.getPlayerState(); + @PlayerState.PlayState String playStatus = activePlayerState.getPlayStatus(); + + // May be null -- race condition when connecting to a server that + // has a player. Squeezer knows the player exists, but has not yet + // determined its state. + if (playStatus == null) + return false; + + if (playStatus.equals(PlayerState.PLAY_STATE_PLAY)) { + // NOTE: we never send ambiguous "pause" toggle commands (without the '1') + // because then we'd get confused when they came back in to us, not being + // able to differentiate ours coming back on the listen channel vs. those + // of those idiots at the dinner party messing around. + mDelegate.command(player).cmd("pause", "1").exec(); + return true; + } + + if (playStatus.equals(PlayerState.PLAY_STATE_STOP)) { + mDelegate.command(player).cmd("play", fadeInSecs()).exec(); + return true; + } + + if (playStatus.equals(PlayerState.PLAY_STATE_PAUSE)) { + mDelegate.command(player).cmd("pause", "0", fadeInSecs()).exec(); + return true; + } + + return true; + } + + @Override + public boolean play() { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("play", fadeInSecs()).exec(); + return true; + } + + @Override + public boolean pause() { + if(!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("pause", "1", fadeInSecs()).exec(); + return true; + } + + @Override + public boolean stop() { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("stop").exec(); + return true; + } + + @Override + public boolean nextTrack() { + return nextTrack(getActivePlayer()); + } + @Override + public boolean nextTrack(Player player) { + if (!isConnected() || !isPlaying()) { + return false; + } + mDelegate.command(player).cmd("button", "jump_fwd").exec(); + return true; + } + + @Override + public boolean previousTrack() { + return previousTrack(getActivePlayer()); + } + + @Override + public boolean previousTrack(Player player) { + if (!isConnected() || !isPlaying()) { + return false; + } + mDelegate.command(player).cmd("button", "jump_rew").exec(); + return true; + } + + @Override + public boolean toggleShuffle() { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("button", "shuffle").exec(); + return true; + } + + @Override + public boolean toggleRepeat() { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("button", "repeat").exec(); + return true; + } + + /** + * Start playing the song in the current playlist at the given index. + * + * @param index the index to jump to + */ + @Override + public boolean playlistIndex(int index) { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("playlist", "index", String.valueOf(index), fadeInSecs()).exec(); + return true; + } + + @Override + public boolean playlistRemove(int index) { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("playlist" ,"delete", String.valueOf(index)).exec(); + return true; + } + + @Override + public boolean playlistMove(int fromIndex, int toIndex) { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("playlist", "move", String.valueOf(fromIndex), String.valueOf(toIndex)).exec(); + return true; + } + + @Override + public boolean playlistClear() { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("playlist", "clear").exec(); + return true; + } + + @Override + public boolean playlistSave(String name) { + if (!isConnected()) { + return false; + } + mDelegate.activePlayerCommand().cmd("playlist", "save", name).exec(); + return true; + } + + @Override + public boolean button(Player player, IRButton button) { + if (!isConnected()) { + return false; + } + mDelegate.command(player).cmd("button", button.getFunction()).exec(); + return true; + } + + private boolean isPlaying() { + PlayerState playerState = getActivePlayerState(); + return playerState != null && playerState.isPlaying(); + } + + /** + * Change the player that is controlled by Squeezer (the "active" player). + * + * @param newActivePlayer May be null, in which case no players are controlled. + */ + @Override + public void setActivePlayer(@Nullable final Player newActivePlayer) { + changeActivePlayer(newActivePlayer); + } + + @Override + @Nullable + public Player getActivePlayer() { + return mDelegate.getActivePlayer(); + } + + @Override + public Collection getPlayers() { + return mDelegate.getPlayers().values(); + } + + @Override + public Player getPlayer(String playerId) throws PlayerNotFoundException { + for (Player player : getPlayers()) { + if (player.getId().equals(playerId)) { + return player; + } + } + throw new PlayerNotFoundException(SqueezeService.this); + } + + @Override + public PlayerState getPlayerState() { + return getActivePlayerState(); + } + + /** + * @return null if there is no active player, otherwise the name of the current playlist, + * which may be the empty string. + */ + @Override + @Nullable + public String getCurrentPlaylist() { + PlayerState playerState = getActivePlayerState(); + + if (playerState == null) + return null; + + return playerState.getCurrentPlaylist(); + } + + @Override + public boolean setSecondsElapsed(int seconds) { + if (!isConnected()) { + return false; + } + if (seconds < 0) { + return false; + } + + mDelegate.activePlayerCommand().cmd("time", String.valueOf(seconds)).exec(); + + return true; + } + + @Override + public void preferenceChanged(String key) { + Log.i(TAG, "Preference changed: " + key); + cachePreferences(); + } + + + @Override + public void cancelItemListRequests(Object client) { + mDelegate.cancelClientRequests(client); + } + + @Override + public void alarms(int start, IServiceItemListCallback callback) { + if (!isConnected()) { + return; + } + mDelegate.requestItems(getActivePlayer(), start, callback).cmd("alarms").param("filter", "all").exec(); + } + + @Override + public void alarmPlaylists(IServiceItemListCallback callback) { + if (!isConnected()) { + return; + } + // The LMS documentation states that + // The "alarm playlists" returns all the playlists, sounds, favorites etc. available to alarms. + // This will however return only one playlist: the current playlist. + // Inspection of the LMS code reveals that the "alarm playlists" command takes the + // customary and parameters, but these are interpreted as + // categories (eg. Favorites, Natural Sounds etc.), but the returned list is flattened, + // i.e. contains all items of the requested categories. + // So we order all playlists without paging. + mDelegate.requestItems(callback).cmd("alarm", "playlists").exec(); + } + + @Override + public void alarmAdd(int time) { + if (!isConnected()) { + return; + } + mDelegate.activePlayerCommand().cmd("alarm", "add").param("time", time).exec(); + } + + @Override + public void alarmDelete(String id) { + if (!isConnected()) { + return; + } + mDelegate.activePlayerCommand().cmd("alarm", "delete").param("id", id).exec(); + } + + @Override + public void alarmSetTime(String id, int time) { + if (!isConnected()) { + return; + } + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id).param("time", time).exec(); + } + + @Override + public void alarmAddDay(String id, int day) { + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id).param("dowAdd", day).exec(); + } + + @Override + public void alarmRemoveDay(String id, int day) { + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id).param("dowDel", day).exec(); + } + + @Override + public void alarmEnable(String id, boolean enabled) { + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id).param("enabled", enabled ? "1" : "0").exec(); + } + + @Override + public void alarmRepeat(String id, boolean repeat) { + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id).param("repeat", repeat ? "1" : "0").exec(); + } + + @Override + public void alarmSetPlaylist(String id, AlarmPlaylist playlist) { + mDelegate.activePlayerCommand().cmd("alarm", "update").param("id", id) + .param("url", "".equals(playlist.getId()) ? "0" : playlist.getId()).exec(); + } + + /* Start an asynchronous fetch of the squeezeservers generic menu items */ + @Override + public void pluginItems(int start, String cmd, IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException { + if (!mHandshakeComplete) { + throw new HandshakeNotCompleteException("Handshake with server has not completed."); + } + mDelegate.requestItems(getActivePlayer(), start, callback).cmd(cmd).param("menu", "menu").exec(); + } + + /* Start an asynchronous fetch of the squeezeservers generic menu items */ + @Override + public void pluginItems(int start, JiveItem item, Action action, IServiceItemListCallback callback) throws SqueezeService.HandshakeNotCompleteException { + if (!mHandshakeComplete) { + throw new HandshakeNotCompleteException("Handshake with server has not completed."); + } + mDelegate.requestItems(getActivePlayer(), start, callback).cmd(action.action.cmd).params(action.action.params(item.inputValue)).exec(); + } + + @Override + public void pluginItems(Action action, IServiceItemListCallback callback) throws HandshakeNotCompleteException { + // We cant use paging for context menu items as LMS does some "magic" + // See XMLBrowser.pm ("xmlBrowseInterimCM" and "# Cannot do this if we might screw up paging") + mDelegate.requestItems(getActivePlayer(), callback).cmd(action.action.cmd).params(action.action.params).exec(); + } + + @Override + public void action(JiveItem item, Action action) { + if (!isConnected()) { + return; + } + mDelegate.command(getActivePlayer()).cmd(action.action.cmd).params(action.action.params(item.inputValue)).exec(); + } + + @Override + public void action(Action.JsonAction action) { + if (!isConnected()) { + return; + } + mDelegate.command(getActivePlayer()).cmd(action.cmd).params(action.params).exec(); + } + + @Override + public void downloadItem(JiveItem item) throws HandshakeNotCompleteException { + Log.i(TAG, "downloadItem(" + item + ")"); + SlimCommand command = item.downloadCommand(); + IServiceItemListCallback callback = ("musicfolder".equals(command.cmd.get(0))) ? musicFolderDownloadCallback : songDownloadCallback; + mDelegate.requestItems(-1, callback).params(command.params).cmd(command.cmd()).exec(); + } + } + + /** + * Calculate and set player subscription states every time a client of the bus + * un/registers. + *

+ * For example, this ensures that if a new client subscribes and needs real + * time updates, the player subscription states will be updated accordingly. + */ + class EventBus extends de.greenrobot.event.EventBus { + + @Override + public void register(Object subscriber) { + super.register(subscriber); + updateAllPlayerSubscriptionStates(); + } + + @Override + public void register(Object subscriber, int priority) { + super.register(subscriber, priority); + updateAllPlayerSubscriptionStates(); + } + + @Override + public void post(Object event) { + Log.v("EventBus", "post() " + event.getClass().getSimpleName() + ": " + event); + super.post(event); + } + + @Override + public void postSticky(Object event) { + Log.v("EventBus", "postSticky() " + event.getClass().getSimpleName() + ": " + event); + super.postSticky(event); + } + + @Override + public void registerSticky(Object subscriber) { + super.registerSticky(subscriber); + updateAllPlayerSubscriptionStates(); + } + + @Override + public void registerSticky(Object subscriber, int priority) { + super.registerSticky(subscriber, priority); + updateAllPlayerSubscriptionStates(); + } + + @Override + public synchronized void unregister(Object subscriber) { + super.unregister(subscriber); + updateAllPlayerSubscriptionStates(); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxClient.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxClient.java new file mode 100644 index 000000000..ed3249add --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxClient.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2016 KKurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service; + +import android.util.Log; + +import org.cometd.bayeux.Message; +import org.cometd.client.BayeuxClient; +import org.cometd.client.transport.ClientTransport; + +import java.util.List; + +import uk.org.ngo.squeezer.BuildConfig; + +/** + * {@link BayeuxClient} implementation for the Squeezer App. + *

+ * This is responsible for logging. + */ +class SqueezerBayeuxClient extends BayeuxClient { + private static final String TAG = SqueezerBayeuxClient.class.getSimpleName(); + + SqueezerBayeuxClient(String url, ClientTransport transport, ClientTransport... transports) { + super(url, transport, transports); + } + + @Override + public void onSending(List messages) { + super.onSending(messages); + for (Message message : messages) { + if (BuildConfig.DEBUG) { + Log.v(TAG, "SEND: " + message.getJSON()); + } + } + } + + @Override + public void onMessages(List messages) { + super.onMessages(messages); + for (Message message : messages) { + if (BuildConfig.DEBUG) { + Log.v(TAG, "RECV: " + message.getJSON()); + } + } + } + + @Override + public void onFailure(Throwable failure, List messages) { + super.onFailure(failure, messages); + for (Message message : messages) { + if (BuildConfig.DEBUG) { + Log.v(TAG, "FAIL: " + message.getJSON()); + } + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxExtension.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxExtension.java new file mode 100644 index 000000000..237bf81ac --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/SqueezerBayeuxExtension.java @@ -0,0 +1,64 @@ +package uk.org.ngo.squeezer.service; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import org.cometd.bayeux.Channel; +import org.cometd.bayeux.Message; +import org.cometd.bayeux.client.ClientSession; + +import java.util.HashMap; +import java.util.Map; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.Squeezer; + +class SqueezerBayeuxExtension extends ClientSession.Extension.Adapter { + private Map ext = initExt(); + + private Map initExt() { + Map ext = new HashMap<>(); + Context context = Squeezer.getContext(); + Preferences preferences = new Preferences(context); + ext.put("mac", getMacId(preferences)); + ext.put("rev", getRevision(context)); + ext.put("uuid", getUuid(preferences)); + + return ext; + } + + private static String getMacId(Preferences preferences) { + return preferences.getMacId(); + } + + private static String getUuid(Preferences preferences) { + return preferences.getUuid(); + } + + public static String getRevision() { + return getRevision(Squeezer.getContext()); + } + + private static String getRevision(Context context) { + PackageManager pm = Squeezer.getContext().getPackageManager(); + String rev; + try { + PackageInfo packageInfo = pm.getPackageInfo(context.getPackageName(), 0); + rev = packageInfo.versionName; + } catch (PackageManager.NameNotFoundException e) { + rev = "1.0"; + } + return rev; + } + + @Override + public boolean sendMeta(ClientSession session, Message.Mutable message) { + // mysqueezebox.com requires an ext field in the handshake message + if (Channel.META_HANDSHAKE.equals(message.getChannel())) { + message.put(Message.EXT_FIELD, ext); + } + + return true; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ActivePlayerChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ActivePlayerChanged.java new file mode 100644 index 000000000..f98b44309 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ActivePlayerChanged.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import java.util.Map; + +import uk.org.ngo.squeezer.model.Player; + +/** + * Event sent when the player that is controlled by Squeezer (the "active" player) has changed. + */ +public class ActivePlayerChanged { + /** The active player. May be null. */ + public final Player player; + + public ActivePlayerChanged(Player player) { + this.player = player; + } + + @Override + public String toString() { + return "{player: " + player + "}"; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/AlertEvent.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/AlertEvent.java new file mode 100644 index 000000000..9b2e2cddd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/AlertEvent.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.AlertWindow; + +/** Event sent when a alert window message is received. */ +public class AlertEvent { + /** The message to show. */ + @NonNull + public final AlertWindow message; + + public AlertEvent(@NonNull AlertWindow message) { + this.message = message; + } + + @Override + public String toString() { + return "AlertEvent{" + + "message=" + message + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ConnectionChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ConnectionChanged.java new file mode 100644 index 000000000..7e1f39a02 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ConnectionChanged.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import uk.org.ngo.squeezer.service.ConnectionError; +import uk.org.ngo.squeezer.service.ConnectionState; + +/** + * Event posted whenever the connection state to the server changes. + */ +public class ConnectionChanged { + /** The new connection state. */ + @ConnectionState.ConnectionStates + public int connectionState; + public ConnectionError connectionError; + + public ConnectionChanged(@ConnectionState.ConnectionStates int connectionState) { + this.connectionState = connectionState; + } + + public ConnectionChanged(ConnectionError connectionError) { + this.connectionState = ConnectionState.CONNECTION_FAILED; + this.connectionError = connectionError; + } + + @Override + public String toString() { + return "ConnectionChanged{" + connectionState + '}'; + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/DisplayEvent.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/DisplayEvent.java new file mode 100644 index 000000000..251624097 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/DisplayEvent.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.DisplayMessage; + +/** Event sent when a display status message is received. */ +public class DisplayEvent { + /** The message to show. */ + @NonNull + public final DisplayMessage message; + + public DisplayEvent(@NonNull DisplayMessage message) { + this.message = message; + } + + @Override + public String toString() { + return "DisplayEvent{" + + "message=" + message + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HandshakeComplete.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HandshakeComplete.java new file mode 100644 index 000000000..72a2c582c --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HandshakeComplete.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +/** + * Event sent after handshaking with the server is complete. + */ +public class HandshakeComplete { + /** Server version */ + public final String version; + + public HandshakeComplete(String version) { + this.version = version; + } + + @Override + public String toString() { + return "HandshakeComplete{" + + "version='" + version + '\'' + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HomeMenuEvent.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HomeMenuEvent.java new file mode 100644 index 000000000..934f50e22 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/HomeMenuEvent.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2019 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import java.util.List; + +import uk.org.ngo.squeezer.model.JiveItem; + +/** Event sent when the home menu has changed. */ +public class HomeMenuEvent { + + @NonNull + public List menuItems; + + public HomeMenuEvent(@NonNull List menuItems) { + this.menuItems = menuItems; + } + + @NonNull + @Override + public String toString() { + return "HomeMenuEvent{" + + "menuItems=" + menuItems + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/MusicChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/MusicChanged.java new file mode 100644 index 000000000..92471cba0 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/MusicChanged.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** Event sent when the track the player is playing changes. */ +public class MusicChanged { + /** The player with changed state. */ + @NonNull public final Player player; + + /** The active player's new state. */ + @NonNull + public final PlayerState playerState; + + public MusicChanged(@NonNull Player player, @NonNull PlayerState playerState) { + this.player = player; + this.playerState = playerState; + } + + @Override + public String toString() { + return "MusicChanged{" + + "player=" + player + + ", playerState=" + playerState + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayStatusChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayStatusChanged.java new file mode 100644 index 000000000..dfea8a1d7 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayStatusChanged.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** Event sent when a player's play status changes. */ +public class PlayStatusChanged { + /** The new play status. */ + @NonNull + @PlayerState.PlayState + public final String playStatus; + + /** The affected player. */ + @NonNull + public final Player player; + + public PlayStatusChanged(@NonNull @PlayerState.PlayState String playStatus, @NonNull Player player) { + this.playStatus = playStatus; + this.player = player; + } + + @Override + public String toString() { + return "PlayStatusChanged{" + + "playStatus='" + playStatus + '\'' + + ", player=" + player + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerPrefReceived.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerPrefReceived.java new file mode 100644 index 000000000..10b727552 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerPrefReceived.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2014 Kurt Aaholst. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when a player preference value is received. */ +public class PlayerPrefReceived { + /** The player that owns the preference. */ + @NonNull public final Player player; + + /** The name of the preference that was received. */ + @NonNull public final @Player.Pref.Name String pref; + + /** The value of the preference. */ + @NonNull public final String value; + + public PlayerPrefReceived(@NonNull Player player, @NonNull @Player.Pref.Name String pref, @NonNull String value) { + this.player = player; + this.pref = pref; + this.value = value; + } + + @Override + public String toString() { + return "PlayerPrefReceived{" + + "player=" + player + + ", pref='" + pref + '\'' + + ", value='" + value + '\'' + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerStateChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerStateChanged.java new file mode 100644 index 000000000..80bb5b9dd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerStateChanged.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when a player's state has changed. */ +public class PlayerStateChanged { + /** The player with changed state. */ + @NonNull public final Player player; + + public PlayerStateChanged(@NonNull Player player) { + this.player = player; + } + + @Override + public String toString() { + return "PlayerStateChanged{" + + "player=" + player + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerVolume.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerVolume.java new file mode 100644 index 000000000..d8ffe9438 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayerVolume.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when a player's volume has changed. */ +public class PlayerVolume { + /** The player's new volume. */ + public final int volume; + + /** The player that was affected. */ + @NonNull + public final Player player; + + public PlayerVolume(int volume, @NonNull Player player) { + this.volume = volume; + this.player = player; + } + + @Override + public String toString() { + return "PlayerVolume{" + + "volume=" + volume + + ", player=" + player + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayersChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayersChanged.java new file mode 100644 index 000000000..efefae347 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlayersChanged.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import java.util.Map; + +import uk.org.ngo.squeezer.model.Player; + +/** + * Event sent when the list of players connected to the server has changed. + */ +public class PlayersChanged { + /** The players connected to the Squeezeserver. May be empty. */ + public final Map players; + + public PlayersChanged(Map players) { + this.players = players; + } + + @Override + public String toString() { + return "{players: " + players + "}"; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlaylistChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlaylistChanged.java new file mode 100644 index 000000000..6ec2d7a0f --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PlaylistChanged.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when a player's play status changes. */ +public class PlaylistChanged { + /** The affected player. */ + @NonNull + public final Player player; + + public PlaylistChanged(@NonNull Player player) { + this.player = player; + } + + @Override + public String toString() { + return "PlayStatusChanged{" + + "player=" + player + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PowerStatusChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PowerStatusChanged.java new file mode 100644 index 000000000..fb5f94512 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/PowerStatusChanged.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when the power status of the player has changed. */ +public class PowerStatusChanged { + /** The player with changed state. */ + @NonNull public final Player player; + + public PowerStatusChanged(@NonNull Player player) { + this.player = player; + } + + @NonNull + @Override + public String toString() { + return "PowerStatusChanged{" + + "player=" + player + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RegisterSqueezeNetwork.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RegisterSqueezeNetwork.java new file mode 100644 index 000000000..27e079c4e --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RegisterSqueezeNetwork.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +/** + * Event sent if we need to register with SN (SqueezeNetwork). + */ +public class RegisterSqueezeNetwork { + + @Override + public String toString() { + return "RegisterSqueezeNetwork{" + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RepeatStatusChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RepeatStatusChanged.java new file mode 100644 index 000000000..3cf4e68df --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/RepeatStatusChanged.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** Event sent when the repeat status of the player has changed. */ +public class RepeatStatusChanged { + /** The player with changed state. */ + @NonNull public final Player player; + + /** The new repeat status. */ + @NonNull + public final PlayerState.RepeatStatus repeatStatus; + + public RepeatStatusChanged(@NonNull Player player, @NonNull PlayerState.RepeatStatus repeatStatus) { + this.player = player; + this.repeatStatus = repeatStatus; + } + + @Override + public String toString() { + return "RepeatStatusChanged{" + + "player=" + player + + ", repeatStatus=" + repeatStatus + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ShuffleStatusChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ShuffleStatusChanged.java new file mode 100644 index 000000000..20b06af71 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/ShuffleStatusChanged.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; +import uk.org.ngo.squeezer.model.PlayerState; + +/** Event sent when the shuffle status of the player has changed. */ +public class ShuffleStatusChanged { + /** The player with changed state. */ + @NonNull public final Player player; + + /** The new shuffle status. */ + @NonNull + public final PlayerState.ShuffleStatus shuffleStatus; + + public ShuffleStatusChanged(@NonNull Player player, @NonNull PlayerState.ShuffleStatus shuffleStatus) { + this.player = player; + this.shuffleStatus = shuffleStatus; + } + + @Override + public String toString() { + return "ShuffleStatusChanged{" + + "player=" + player + + ", shuffleStatus=" + shuffleStatus + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/SongTimeChanged.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/SongTimeChanged.java new file mode 100644 index 000000000..ceba1489b --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/service/event/SongTimeChanged.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.service.event; + +import androidx.annotation.NonNull; + +import uk.org.ngo.squeezer.model.Player; + +/** Event sent when the duration or current play position of the current song has changed. */ +public class SongTimeChanged { + /** The player with changed state. */ + @NonNull + public final Player player; + + /** The current position of the player in the song, measured in seconds. */ + public final int currentPosition; + + /** The song's duration, measured in seconds. */ + public final int duration; + + public SongTimeChanged(@NonNull Player player, int currentPosition, int duration) { + this.player = player; + this.currentPosition = currentPosition; + this.duration = duration; + } + + @Override + public String toString() { + return "SongTimeChanged{" + + "player=" + player + + ", currentPosition=" + currentPosition + + ", duration=" + duration + + '}'; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/AsyncTask.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/AsyncTask.java new file mode 100644 index 000000000..3ca0525bd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/AsyncTask.java @@ -0,0 +1,640 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * ************************************* Copied from JB release framework: + * https://android.googlesource.com/platform/frameworks/base/+/jb-release/core/java/android/os/AsyncTask.java + *

+ * so that threading behavior on all OS versions is the same and we can tweak behavior by using + * executeOnExecutor() if needed. + *

+ * There are 3 changes in this copy of AsyncTask: -pre-HC a single thread executor is used for + * serial operation (Executors.newSingleThreadExecutor) and is the default -the default + * THREAD_POOL_EXECUTOR was changed to use DiscardOldestPolicy -a new fixed thread pool called + * DUAL_THREAD_EXECUTOR was added ************************************* + *

+ *

AsyncTask enables proper and easy use of the UI thread. This class allows to perform + * background operations and publish results on the UI thread without having to manipulate threads + * and/or handlers.

+ *

+ *

AsyncTask is designed to be a helper class around {@link Thread} and {@link Handler} and does + * not constitute a generic threading framework. AsyncTasks should ideally be used for short + * operations (a few seconds at the most.) If you need to keep threads running for long periods of + * time, it is highly recommended you use the various APIs provided by the + * java.util.concurrent pacakge such as {@link Executor}, {@link ThreadPoolExecutor} + * and {@link FutureTask}.

+ *

+ *

An asynchronous task is defined by a computation that runs on a background thread and whose + * result is published on the UI thread. An asynchronous task is defined by 3 generic types, called + * Params, Progress and Result, and 4 steps, called + * onPreExecute, doInBackground, onProgressUpdate and + * onPostExecute.

+ *

+ *

Developer Guides

For more information about using + * tasks and threads, read the Processes + * and Threads developer guide.

+ *

+ *

Usage

AsyncTask must be subclassed to be used. The subclass will override at least + * one method ({@link #doInBackground}), and most often will override a second one ({@link + * #onPostExecute}.)

+ *

+ *

Here is an example of subclassing:

 private class
+ * DownloadFilesTask extends AsyncTask<URL, Integer, Long> { protected Long
+ * doInBackground(URL... urls) { int count = urls.length; long totalSize = 0; for (int i = 0; i <
+ * count; i++) { totalSize += Downloader.downloadFile(urls[i]); publishProgress((int) ((i / (float)
+ * count) * 100)); // Escape early if cancel() is called if (isCancelled()) break; } return
+ * totalSize; }
+ * 

+ * protected void onProgressUpdate(Integer... progress) { setProgressPercent(progress[0]); } + *

+ * protected void onPostExecute(Long result) { showDialog("Downloaded " + result + " bytes"); } } + *

+ *

+ *

Once created, a task is executed very simply:

 new
+ * DownloadFilesTask().execute(url1, url2, url3); 
+ *

+ *

AsyncTask's generic types

The three types used by an asynchronous task are the + * following:

  1. Params, the type of the parameters sent to the task upon + * execution.
  2. Progress, the type of the progress units published during the + * background computation.
  3. Result, the type of the result of the background + * computation.

Not all types are always used by an asynchronous task. To mark a type + * as unused, simply use the type {@link Void}:

 private class MyTask extends
+ * AsyncTask<Void, Void, Void> { ... } 
+ *

+ *

The 4 steps

When an asynchronous task is executed, the task goes through 4 steps:

+ *
  1. {@link #onPreExecute()}, invoked on the UI thread immediately after the task is + * executed. This step is normally used to setup the task, for instance by showing a progress bar in + * the user interface.
  2. {@link #doInBackground}, invoked on the background thread + * immediately after {@link #onPreExecute()} finishes executing. This step is used to perform + * background computation that can take a long time. The parameters of the asynchronous task are + * passed to this step. The result of the computation must be returned by this step and will be + * passed back to the last step. This step can also use {@link #publishProgress} to publish one or + * more units of progress. These values are published on the UI thread, in the {@link + * #onProgressUpdate} step.
  3. {@link #onProgressUpdate}, invoked on the UI thread after a + * call to {@link #publishProgress}. The timing of the execution is undefined. This method is used + * to display any form of progress in the user interface while the background computation is still + * executing. For instance, it can be used to animate a progress bar or show logs in a text + * field.
  4. {@link #onPostExecute}, invoked on the UI thread after the background computation + * finishes. The result of the background computation is passed to this step as a parameter.
  5. + *
+ *

+ *

Cancelling a task

A task can be cancelled at any time by invoking {@link + * #cancel(boolean)}. Invoking this method will cause subsequent calls to {@link #isCancelled()} to + * return true. After invoking this method, {@link #onCancelled(Object)}, instead of {@link + * #onPostExecute(Object)} will be invoked after {@link #doInBackground(Object[])} returns. To + * ensure that a task is cancelled as quickly as possible, you should always check the return value + * of {@link #isCancelled()} periodically from {@link #doInBackground(Object[])}, if possible + * (inside a loop for instance.)

+ *

+ *

Threading rules

There are a few threading rules that must be followed for this class + * to work properly:

  • The AsyncTask class must be loaded on the UI thread. This is done + * automatically as of {@link android.os.Build.VERSION_CODES#JELLY_BEAN}.
  • The task instance + * must be created on the UI thread.
  • {@link #execute} must be invoked on the UI + * thread.
  • Do not call {@link #onPreExecute()}, {@link #onPostExecute}, {@link + * #doInBackground}, {@link #onProgressUpdate} manually.
  • The task can be executed only once + * (an exception will be thrown if a second execution is attempted.)
+ *

+ *

Memory observability

AsyncTask guarantees that all callback calls are synchronized in + * such a way that the following operations are safe without explicit synchronizations.

    + *
  • Set member fields in the constructor or {@link #onPreExecute}, and refer to them in {@link + * #doInBackground}.
  • Set member fields in {@link #doInBackground}, and refer to them in {@link + * #onProgressUpdate} and {@link #onPostExecute}.
+ *

+ *

Order of execution

When first introduced, AsyncTasks were executed serially on a + * single background thread. Starting with {@link android.os.Build.VERSION_CODES#DONUT}, this was + * changed to a pool of threads allowing multiple tasks to operate in parallel. Starting with {@link + * android.os.Build.VERSION_CODES#HONEYCOMB}, tasks are executed on a single thread to avoid common + * application errors caused by parallel execution.

If you truly want parallel execution, you + * can invoke {@link #executeOnExecutor(java.util.concurrent.Executor, Object[])} with {@link + * #THREAD_POOL_EXECUTOR}.

+ */ +public abstract class AsyncTask { + + private static final String LOG_TAG = "AsyncTask"; + + private static final int CORE_POOL_SIZE = 5; + + private static final int MAXIMUM_POOL_SIZE = 128; + + private static final int KEEP_ALIVE = 1; + + private static final ThreadFactory sThreadFactory = new ThreadFactory() { + private final AtomicInteger mCount = new AtomicInteger(1); + + @Override + public Thread newThread(@NonNull Runnable r) { + return new Thread(r, "AsyncTask #" + mCount.getAndIncrement()); + } + }; + + private static final BlockingQueue sPoolWorkQueue = + new LinkedBlockingQueue(10); + + /** + * An {@link Executor} that can be used to execute tasks in parallel. + */ + public static final Executor THREAD_POOL_EXECUTOR + = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE, + TimeUnit.SECONDS, sPoolWorkQueue, sThreadFactory, + new ThreadPoolExecutor.DiscardOldestPolicy()); + + /** + * An {@link Executor} that executes tasks one at a time in serial order. This serialization is + * global to a particular process. + */ + public static final Executor SERIAL_EXECUTOR = new SerialExecutor(); + + public static final Executor DUAL_THREAD_EXECUTOR = + Executors.newFixedThreadPool(2, sThreadFactory); + + private static final int MESSAGE_POST_RESULT = 0x1; + + private static final int MESSAGE_POST_PROGRESS = 0x2; + + private static final InternalHandler sHandler = new InternalHandler(); + + private static volatile Executor sDefaultExecutor = SERIAL_EXECUTOR; + + private final WorkerRunnable mWorker; + + private final FutureTask mFuture; + + @AsyncTaskStatus private volatile int mStatus = PENDING; + + private final AtomicBoolean mCancelled = new AtomicBoolean(); + + private final AtomicBoolean mTaskInvoked = new AtomicBoolean(); + + private static class SerialExecutor implements Executor { + + final ArrayDeque mTasks = new ArrayDeque(); + + Runnable mActive; + + @Override + public synchronized void execute(@NonNull final Runnable r) { + mTasks.offer(new Runnable() { + @Override + public void run() { + try { + r.run(); + } finally { + scheduleNext(); + } + } + }); + if (mActive == null) { + scheduleNext(); + } + } + + protected synchronized void scheduleNext() { + if ((mActive = mTasks.poll()) != null) { + THREAD_POOL_EXECUTOR.execute(mActive); + } + } + } + + /** + * Indicates the current status of the task. Each status will be set only once during the + * lifetime of a task. + */ + @IntDef({PENDING, RUNNING, FINISHED}) + @Retention(RetentionPolicy.SOURCE) + public @interface AsyncTaskStatus {} + /** The task has not been executed yet. */ + public static final int PENDING = 0; + /** The task is running. */ + public static final int RUNNING = 1; + /** The {@link AsyncTask#onPostExecute(Object)} has finished. */ + public static final int FINISHED = 2; + + /** + * @hide Used to force static handler to be created. + */ + public static void init() { + sHandler.getLooper(); + } + + /** + * @hide + */ + public static void setDefaultExecutor(Executor exec) { + sDefaultExecutor = exec; + } + + /** + * Creates a new asynchronous task. This constructor must be invoked on the UI thread. + */ + public AsyncTask() { + mWorker = new WorkerRunnable() { + @Override + public Result call() throws Exception { + mTaskInvoked.set(true); + + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + //noinspection unchecked + return postResult(doInBackground(mParams)); + } + }; + + mFuture = new FutureTask(mWorker) { + @Override + protected void done() { + try { + postResultIfNotInvoked(get()); + } catch (InterruptedException e) { + android.util.Log.w(LOG_TAG, e); + } catch (ExecutionException e) { + throw new RuntimeException("An error occured while executing doInBackground()", + e.getCause()); + } catch (CancellationException e) { + postResultIfNotInvoked(null); + } + } + }; + } + + private void postResultIfNotInvoked(Result result) { + final boolean wasTaskInvoked = mTaskInvoked.get(); + if (!wasTaskInvoked) { + postResult(result); + } + } + + private Result postResult(Result result) { + @SuppressWarnings("unchecked") + Message message = sHandler.obtainMessage(MESSAGE_POST_RESULT, + new AsyncTaskResult(this, result)); + message.sendToTarget(); + return result; + } + + /** + * Returns the current status of this task. + * + * @return The current status. + */ + @AsyncTaskStatus + public final int getStatus() { + return mStatus; + } + + /** + * Override this method to perform a computation on a background thread. The specified + * parameters are the parameters passed to {@link #execute} by the caller of this task. + *

+ * This method can call {@link #publishProgress} to publish updates on the UI thread. + * + * @param params The parameters of the task. + * + * @return A result, defined by the subclass of this task. + * + * @see #onPreExecute() + * @see #onPostExecute + * @see #publishProgress + */ + protected abstract Result doInBackground(Params... params); + + /** + * Runs on the UI thread before {@link #doInBackground}. + * + * @see #onPostExecute + * @see #doInBackground + */ + @SuppressWarnings("EmptyMethod") + protected void onPreExecute() { + } + + /** + *

Runs on the UI thread after {@link #doInBackground}. The specified result is the value + * returned by {@link #doInBackground}.

+ *

+ *

This method won't be invoked if the task was cancelled.

+ * + * @param result The result of the operation computed by {@link #doInBackground}. + * + * @see #onPreExecute + * @see #doInBackground + * @see #onCancelled(Object) + */ + @SuppressWarnings({"UnusedDeclaration"}) + protected void onPostExecute(Result result) { + } + + /** + * Runs on the UI thread after {@link #publishProgress} is invoked. The specified values are the + * values passed to {@link #publishProgress}. + * + * @param values The values indicating progress. + * + * @see #publishProgress + * @see #doInBackground + */ + @SuppressWarnings({"UnusedDeclaration"}) + protected void onProgressUpdate(Progress... values) { + } + + /** + *

Runs on the UI thread after {@link #cancel(boolean)} is invoked and {@link + * #doInBackground(Object[])} has finished.

+ *

+ *

The default implementation simply invokes {@link #onCancelled()} and ignores the result. + * If you write your own implementation, do not call super.onCancelled(result).

+ * + * @param result The result, if any, computed in {@link #doInBackground(Object[])}, can be null + * + * @see #cancel(boolean) + * @see #isCancelled() + */ + @SuppressWarnings({"UnusedParameters"}) + protected void onCancelled(Result result) { + onCancelled(); + } + + /** + *

Applications should preferably override {@link #onCancelled(Object)}. This method is + * invoked by the default implementation of {@link #onCancelled(Object)}.

+ *

+ *

Runs on the UI thread after {@link #cancel(boolean)} is invoked and {@link + * #doInBackground(Object[])} has finished.

+ * + * @see #onCancelled(Object) + * @see #cancel(boolean) + * @see #isCancelled() + */ + @SuppressWarnings("EmptyMethod") + protected void onCancelled() { + } + + /** + * Returns true if this task was cancelled before it completed normally. If you are + * calling {@link #cancel(boolean)} on the task, the value returned by this method should be + * checked periodically from {@link #doInBackground(Object[])} to end the task as soon as + * possible. + * + * @return true if task was cancelled before it completed + * + * @see #cancel(boolean) + */ + public final boolean isCancelled() { + return mCancelled.get(); + } + + /** + *

Attempts to cancel execution of this task. This attempt will fail if the task has already + * completed, already been cancelled, or could not be cancelled for some other reason. If + * successful, and this task has not started when cancel is called, this task should + * never run. If the task has already started, then the mayInterruptIfRunning parameter + * determines whether the thread executing this task should be interrupted in an attempt to stop + * the task.

+ *

+ *

Calling this method will result in {@link #onCancelled(Object)} being invoked on the UI + * thread after {@link #doInBackground(Object[])} returns. Calling this method guarantees that + * {@link #onPostExecute(Object)} is never invoked. After invoking this method, you should check + * the value returned by {@link #isCancelled()} periodically from {@link + * #doInBackground(Object[])} to finish the task as early as possible.

+ * + * @param mayInterruptIfRunning true if the thread executing this task should be + * interrupted; otherwise, in-progress tasks are allowed to complete. + * + * @return false if the task could not be cancelled, typically because it has already + * completed normally; true otherwise + * + * @see #isCancelled() + * @see #onCancelled(Object) + */ + public final boolean cancel(boolean mayInterruptIfRunning) { + mCancelled.set(true); + return mFuture.cancel(mayInterruptIfRunning); + } + + /** + * Waits if necessary for the computation to complete, and then retrieves its result. + * + * @return The computed result. + * + * @throws CancellationException If the computation was cancelled. + * @throws ExecutionException If the computation threw an exception. + * @throws InterruptedException If the current thread was interrupted while waiting. + */ + public final Result get() throws InterruptedException, ExecutionException { + return mFuture.get(); + } + + /** + * Waits if necessary for at most the given time for the computation to complete, and then + * retrieves its result. + * + * @param timeout Time to wait before cancelling the operation. + * @param unit The time unit for the timeout. + * + * @return The computed result. + * + * @throws CancellationException If the computation was cancelled. + * @throws ExecutionException If the computation threw an exception. + * @throws InterruptedException If the current thread was interrupted while waiting. + * @throws TimeoutException If the wait timed out. + */ + public final Result get(long timeout, TimeUnit unit) throws InterruptedException, + ExecutionException, TimeoutException { + return mFuture.get(timeout, unit); + } + + /** + * Executes the task with the specified parameters. The task returns itself (this) so that the + * caller can keep a reference to it. + *

+ *

Note: this function schedules the task on a queue for a single background thread or pool + * of threads depending on the platform version. When first introduced, AsyncTasks were + * executed serially on a single background thread. Starting with {@link + * android.os.Build.VERSION_CODES#DONUT}, this was changed to a pool of threads allowing + * multiple tasks to operate in parallel. Starting {@link android.os.Build.VERSION_CODES#HONEYCOMB}, + * tasks are back to being executed on a single thread to avoid common application errors caused + * by parallel execution. If you truly want parallel execution, you can use the {@link + * #executeOnExecutor} version of this method with {@link #THREAD_POOL_EXECUTOR}; however, see + * commentary there for warnings on its use. + *

+ *

This method must be invoked on the UI thread. + * + * @param params The parameters of the task. + * + * @return This instance of AsyncTask. + * + * @throws IllegalStateException If {@link #getStatus()} returns either {@link + * AsyncTask.AsyncTaskStatus#RUNNING} or {@link AsyncTask.AsyncTaskStatus#FINISHED}. + * @see #executeOnExecutor(java.util.concurrent.Executor, Object[]) + * @see #execute(Runnable) + */ + public final AsyncTask execute(Params... params) { + return executeOnExecutor(sDefaultExecutor, params); + } + + /** + * Executes the task with the specified parameters. The task returns itself (this) so that the + * caller can keep a reference to it. + *

+ *

This method is typically used with {@link #THREAD_POOL_EXECUTOR} to allow multiple tasks + * to run in parallel on a pool of threads managed by AsyncTask, however you can also use your + * own {@link Executor} for custom behavior. + *

+ *

Warning: Allowing multiple tasks to run in parallel from a thread pool is + * generally not what one wants, because the order of their operation is not defined. + * For example, if these tasks are used to modify any state in common (such as writing a file + * due to a button click), there are no guarantees on the order of the modifications. Without + * careful work it is possible in rare cases for the newer version of the data to be + * over-written by an older one, leading to obscure data loss and stability issues. Such + * changes are best executed in serial; to guarantee such work is serialized regardless of + * platform version you can use this function with {@link #SERIAL_EXECUTOR}. + *

+ *

This method must be invoked on the UI thread. + * + * @param exec The executor to use. {@link #THREAD_POOL_EXECUTOR} is available as a convenient + * process-wide thread pool for tasks that are loosely coupled. + * @param params The parameters of the task. + * + * @return This instance of AsyncTask. + * + * @throws IllegalStateException If {@link #getStatus()} returns either {@link + * AsyncTask.AsyncTaskStatus#RUNNING} or + * {@link AsyncTask.AsyncTaskStatus#FINISHED}. + * @see #execute(Object[]) + */ + public final AsyncTask executeOnExecutor(Executor exec, + Params... params) { + if (mStatus != PENDING) { + switch (mStatus) { + case RUNNING: + throw new IllegalStateException("Cannot execute task:" + + " the task is already running."); + case FINISHED: + throw new IllegalStateException("Cannot execute task:" + + " the task has already been executed " + + "(a task can be executed only once)"); + } + } + + mStatus = RUNNING; + + onPreExecute(); + + mWorker.mParams = params; + exec.execute(mFuture); + + return this; + } + + /** + * Convenience version of {@link #execute(Object...)} for use with a simple Runnable object. See + * {@link #execute(Object[])} for more information on the order of execution. + * + * @see #execute(Object[]) + * @see #executeOnExecutor(java.util.concurrent.Executor, Object[]) + */ + public static void execute(Runnable runnable) { + sDefaultExecutor.execute(runnable); + } + + /** + * This method can be invoked from {@link #doInBackground} to publish updates on the UI thread + * while the background computation is still running. Each call to this method will trigger the + * execution of {@link #onProgressUpdate} on the UI thread. + *

+ * {@link #onProgressUpdate} will note be called if the task has been canceled. + * + * @param values The progress values to update the UI with. + * + * @see #onProgressUpdate + * @see #doInBackground + */ + protected final void publishProgress(Progress... values) { + if (!isCancelled()) { + sHandler.obtainMessage(MESSAGE_POST_PROGRESS, + new AsyncTaskResult(this, values)).sendToTarget(); + } + } + + private void finish(Result result) { + if (isCancelled()) { + onCancelled(result); + } else { + onPostExecute(result); + } + mStatus = FINISHED; + } + + private static class InternalHandler extends Handler { + + @SuppressWarnings({"unchecked", "RawUseOfParameterizedType"}) + @Override + public void handleMessage(Message msg) { + AsyncTaskResult result = (AsyncTaskResult) msg.obj; + switch (msg.what) { + case MESSAGE_POST_RESULT: + // There is only one result + result.mTask.finish(result.mData[0]); + break; + case MESSAGE_POST_PROGRESS: + result.mTask.onProgressUpdate(result.mData); + break; + } + } + } + + private static abstract class WorkerRunnable implements Callable { + + Params[] mParams; + } + + @SuppressWarnings({"RawUseOfParameterizedType"}) + private static class AsyncTaskResult { + + final AsyncTask mTask; + + final Data[] mData; + + AsyncTaskResult(AsyncTask task, Data... data) { + mTask = task; + mData = data; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/CompoundButtonWrapper.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/CompoundButtonWrapper.java new file mode 100644 index 000000000..486f22e01 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/CompoundButtonWrapper.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.widget.CompoundButton; + +/** + * Helper class to prevent {@link CompoundButton.OnCheckedChangeListener#onCheckedChanged(CompoundButton, boolean)} + * from being called when the checked state is set pragmatically. (i.e. by {@link CompoundButton#setChecked(boolean)}) +*/ +public class CompoundButtonWrapper { + private final CompoundButton button; + private CompoundButton.OnCheckedChangeListener onCheckedChangeListener; + + public CompoundButtonWrapper(CompoundButton button) { + this.button = button; + } + + public CompoundButton getButton() { + return button; + } + + public void setChecked(boolean checked) { + button.setOnCheckedChangeListener(null); + button.setChecked(checked); + button.setOnCheckedChangeListener(onCheckedChangeListener); + } + + public void setOncheckedChangeListener(CompoundButton.OnCheckedChangeListener listener) { + this.onCheckedChangeListener = listener; + button.setOnCheckedChangeListener(listener); + } + + public void setEnabled(boolean enabled) { + button.setEnabled(enabled); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/DiskLruCache.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/DiskLruCache.java new file mode 100644 index 000000000..9747f7d65 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/DiskLruCache.java @@ -0,0 +1,959 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import androidx.annotation.NonNull; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.io.Writer; +import java.lang.reflect.Array; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + ****************************************************************************** + * Taken from the JB source code, can be found in: + * libcore/luni/src/main/java/libcore/io/DiskLruCache.java + * or direct link: + * https://android.googlesource.com/platform/libcore/+/android-4.1.1_r1/luni/src/main/java/libcore/io/DiskLruCache.java + ****************************************************************************** + * + * A cache that uses a bounded amount of space on a filesystem. Each cache + * entry has a string key and a fixed number of values. Values are byte + * sequences, accessible as streams or files. Each value must be between {@code + * 0} and {@code Integer.MAX_VALUE} bytes in length. + * + *

The cache stores its data in a directory on the filesystem. This + * directory must be exclusive to the cache; the cache may delete or overwrite + * files from its directory. It is an error for multiple processes to use the + * same cache directory at the same time. + * + *

This cache limits the number of bytes that it will store on the + * filesystem. When the number of stored bytes exceeds the limit, the cache will + * remove entries in the background until the limit is satisfied. The limit is + * not strict: the cache may temporarily exceed it while waiting for files to be + * deleted. The limit does not include filesystem overhead or the cache + * journal so space-sensitive applications should set a conservative limit. + * + *

Clients call {@link #edit} to create or update the values of an entry. An + * entry may have only one editor at one time; if a value is not available to be + * edited then {@link #edit} will return null. + *

    + *
  • When an entry is being created it is necessary to + * supply a full set of values; the empty value should be used as a + * placeholder if necessary. + *
  • When an entry is being edited, it is not necessary + * to supply data for every value; values default to their previous + * value. + *
+ * Every {@link #edit} call must be matched by a call to {@link Editor#commit} + * or {@link Editor#abort}. Committing is atomic: a read observes the full set + * of values as they were before or after the commit, but never a mix of values. + * + *

Clients call {@link #get} to read a snapshot of an entry. The read will + * observe the value at the time that {@link #get} was called. Updates and + * removals after the call do not impact ongoing reads. + * + *

This class is tolerant of some I/O errors. If files are missing from the + * filesystem, the corresponding entries will be dropped from the cache. If + * an error occurs while writing a cache value, the edit will fail silently. + * Callers should handle other problems by catching {@code IOException} and + * responding appropriately. + */ +public final class DiskLruCache implements Closeable { + static final String JOURNAL_FILE = "journal"; + static final String JOURNAL_FILE_TMP = "journal.tmp"; + static final String MAGIC = "libcore.io.DiskLruCache"; + static final String VERSION_1 = "1"; + static final long ANY_SEQUENCE_NUMBER = -1; + private static final String CLEAN = "CLEAN"; + private static final String DIRTY = "DIRTY"; + private static final String REMOVE = "REMOVE"; + private static final String READ = "READ"; + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int IO_BUFFER_SIZE = 8 * 1024; + + /* + * This cache uses a journal file named "journal". A typical journal file + * looks like this: + * libcore.io.DiskLruCache + * 1 + * 100 + * 2 + * + * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 + * DIRTY 335c4c6028171cfddfbaae1a9c313c52 + * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 + * REMOVE 335c4c6028171cfddfbaae1a9c313c52 + * DIRTY 1ab96a171faeeee38496d8b330771a7a + * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 + * READ 335c4c6028171cfddfbaae1a9c313c52 + * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 + * + * The first five lines of the journal form its header. They are the + * constant string "libcore.io.DiskLruCache", the disk cache's version, + * the application's version, the value count, and a blank line. + * + * Each of the subsequent lines in the file is a record of the state of a + * cache entry. Each line contains space-separated values: a state, a key, + * and optional state-specific values. + * o DIRTY lines track that an entry is actively being created or updated. + * Every successful DIRTY action should be followed by a CLEAN or REMOVE + * action. DIRTY lines without a matching CLEAN or REMOVE indicate that + * temporary files may need to be deleted. + * o CLEAN lines track a cache entry that has been successfully published + * and may be read. A publish line is followed by the lengths of each of + * its values. + * o READ lines track accesses for LRU. + * o REMOVE lines track entries that have been deleted. + * + * The journal file is appended to as cache operations occur. The journal may + * occasionally be compacted by dropping redundant lines. A temporary file named + * "journal.tmp" will be used during compaction; that file should be deleted if + * it exists when the cache is opened. + */ + + private final File directory; + private final File journalFile; + private final File journalFileTmp; + private final int appVersion; + private final long maxSize; + private final int valueCount; + private long size = 0; + private Writer journalWriter; + private final LinkedHashMap lruEntries + = new LinkedHashMap(0, 0.75f, true); + private int redundantOpCount; + + /** + * To differentiate between old and current snapshots, each entry is given + * a sequence number each time an edit is committed. A snapshot is stale if + * its sequence number is not equal to its entry's sequence number. + */ + private long nextSequenceNumber = 0; + + /* From java.util.Arrays */ + @SuppressWarnings("unchecked") + private static T[] copyOfRange(T[] original, int start, int end) { + final int originalLength = original.length; // For exception priority compatibility. + if (start > end) { + throw new IllegalArgumentException(); + } + if (start < 0 || start > originalLength) { + throw new ArrayIndexOutOfBoundsException(); + } + final int resultLength = end - start; + final int copyLength = Math.min(resultLength, originalLength - start); + final T[] result = (T[]) Array + .newInstance(original.getClass().getComponentType(), resultLength); + System.arraycopy(original, start, result, 0, copyLength); + return result; + } + + /** + * Returns the remainder of 'reader' as a string, closing it when done. + */ + public static String readFully(Reader reader) throws IOException { + try { + StringWriter writer = new StringWriter(); + char[] buffer = new char[1024]; + int count; + while ((count = reader.read(buffer)) != -1) { + writer.write(buffer, 0, count); + } + return writer.toString(); + } finally { + reader.close(); + } + } + + /** + * Returns the ASCII characters up to but not including the next "\r\n", or + * "\n". + * + * @throws java.io.EOFException if the stream is exhausted before the next newline + * character. + */ + public static String readAsciiLine(InputStream in) throws IOException { + // TODO: support UTF-8 here instead + + StringBuilder result = new StringBuilder(80); + while (true) { + int c = in.read(); + if (c == -1) { + throw new EOFException(); + } else if (c == '\n') { + break; + } + + result.append((char) c); + } + int length = result.length(); + if (length > 0 && result.charAt(length - 1) == '\r') { + result.setLength(length - 1); + } + return result.toString(); + } + + /** + * Closes 'closeable', ignoring any checked exceptions. Does nothing if 'closeable' is null. + */ + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + /** + * Recursively delete everything in {@code dir}. + */ + // TODO: this should specify paths as Strings rather than as Files + public static void deleteContents(File dir) throws IOException { + File[] files = dir.listFiles(); + if (files == null) { + throw new IllegalArgumentException("not a directory: " + dir); + } + for (File file : files) { + if (file.isDirectory()) { + deleteContents(file); + } + if (!file.delete()) { + throw new IOException("failed to delete file: " + file); + } + } + } + + /** This cache uses a single background thread to evict entries. */ + private final ExecutorService executorService = new ThreadPoolExecutor(0, 1, + 60L, TimeUnit.SECONDS, new LinkedBlockingQueue()); + private final Callable cleanupCallable = new Callable() { + @Override + public Void call() throws Exception { + synchronized (DiskLruCache.this) { + if (journalWriter == null) { + return null; // closed + } + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; + } + } + return null; + } + }; + + private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { + this.directory = directory; + this.appVersion = appVersion; + this.journalFile = new File(directory, JOURNAL_FILE); + this.journalFileTmp = new File(directory, JOURNAL_FILE_TMP); + this.valueCount = valueCount; + this.maxSize = maxSize; + } + + /** + * Opens the cache in {@code directory}, creating a cache if none exists + * there. + * + * @param directory a writable directory + * @param appVersion + * @param valueCount the number of values per cache entry. Must be positive. + * @param maxSize the maximum number of bytes this cache should use to store + * @throws IOException if reading or writing the cache directory fails + */ + public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) + throws IOException { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + if (valueCount <= 0) { + throw new IllegalArgumentException("valueCount <= 0"); + } + + // prefer to pick up where we left off + DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + if (cache.journalFile.exists()) { + try { + cache.readJournal(); + cache.processJournal(); + cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), + IO_BUFFER_SIZE); + return cache; + } catch (IOException journalIsCorrupt) { +// System.logW("DiskLruCache " + directory + " is corrupt: " +// + journalIsCorrupt.getMessage() + ", removing"); + cache.delete(); + } + } + + // create a new empty cache + directory.mkdirs(); + cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + cache.rebuildJournal(); + return cache; + } + + private void readJournal() throws IOException { + InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE); + try { + String magic = readAsciiLine(in); + String version = readAsciiLine(in); + String appVersionString = readAsciiLine(in); + String valueCountString = readAsciiLine(in); + String blank = readAsciiLine(in); + if (!MAGIC.equals(magic) + || !VERSION_1.equals(version) + || !Integer.toString(appVersion).equals(appVersionString) + || !Integer.toString(valueCount).equals(valueCountString) + || !"".equals(blank)) { + throw new IOException("unexpected journal header: [" + + magic + ", " + version + ", " + valueCountString + ", " + blank + "]"); + } + + while (true) { + try { + readJournalLine(readAsciiLine(in)); + } catch (EOFException endOfJournal) { + break; + } + } + } finally { + closeQuietly(in); + } + } + + private void readJournalLine(String line) throws IOException { + String[] parts = line.split(" "); + if (parts.length < 2) { + throw new IOException("unexpected journal line: " + line); + } + + String key = parts[1]; + if (parts[0].equals(REMOVE) && parts.length == 2) { + lruEntries.remove(key); + return; + } + + Entry entry = lruEntries.get(key); + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + + if (parts[0].equals(CLEAN) && parts.length == 2 + valueCount) { + entry.readable = true; + entry.currentEditor = null; + entry.setLengths(copyOfRange(parts, 2, parts.length)); + } else if (parts[0].equals(DIRTY) && parts.length == 2) { + entry.currentEditor = new Editor(entry); + } else //noinspection StatementWithEmptyBody + if (parts[0].equals(READ) && parts.length == 2) { + // this work was already done by calling lruEntries.get() + } else { + throw new IOException("unexpected journal line: " + line); + } + } + + /** + * Computes the initial size and collects garbage as a part of opening the + * cache. Dirty entries are assumed to be inconsistent and will be deleted. + */ + private void processJournal() throws IOException { + deleteIfExists(journalFileTmp); + for (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) { + Entry entry = i.next(); + if (entry.currentEditor == null) { + for (int t = 0; t < valueCount; t++) { + size += entry.lengths[t]; + } + } else { + entry.currentEditor = null; + for (int t = 0; t < valueCount; t++) { + deleteIfExists(entry.getCleanFile(t)); + deleteIfExists(entry.getDirtyFile(t)); + } + i.remove(); + } + } + } + + /** + * Creates a new journal that omits redundant information. This replaces the + * current journal if it exists. + */ + private synchronized void rebuildJournal() throws IOException { + if (journalWriter != null) { + journalWriter.close(); + } + + Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE); + writer.write(MAGIC); + writer.write("\n"); + writer.write(VERSION_1); + writer.write("\n"); + writer.write(Integer.toString(appVersion)); + writer.write("\n"); + writer.write(Integer.toString(valueCount)); + writer.write("\n"); + writer.write("\n"); + + for (Entry entry : lruEntries.values()) { + if (entry.currentEditor != null) { + writer.write(DIRTY + ' ' + entry.key + '\n'); + } else { + writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + } + } + + writer.close(); + journalFileTmp.renameTo(journalFile); + journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE); + } + + private static void deleteIfExists(File file) throws IOException { +// try { +// Libcore.os.remove(file.getPath()); +// } catch (ErrnoException errnoException) { +// if (errnoException.errno != OsConstants.ENOENT) { +// throw errnoException.rethrowAsIOException(); +// } +// } + if (file.exists() && !file.delete()) { + throw new IOException(); + } + } + + /** + * Returns a snapshot of the entry named {@code key}, or null if it doesn't + * exist is not currently readable. If a value is returned, it is moved to + * the head of the LRU queue. + */ + public synchronized Snapshot get(String key) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null) { + return null; + } + + if (!entry.readable) { + return null; + } + + /* + * Open all streams eagerly to guarantee that we see a single published + * snapshot. If we opened streams lazily then the streams could come + * from different edits. + */ + InputStream[] ins = new InputStream[valueCount]; + try { + for (int i = 0; i < valueCount; i++) { + ins[i] = new FileInputStream(entry.getCleanFile(i)); + } + } catch (FileNotFoundException e) { + // a file must have been deleted manually! + return null; + } + + redundantOpCount++; + journalWriter.append(READ + ' ' + key + '\n'); + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return new Snapshot(key, entry.sequenceNumber, ins); + } + + /** + * Returns an editor for the entry named {@code key}, or null if another + * edit is in progress. + */ + public Editor edit(String key) throws IOException { + return edit(key, ANY_SEQUENCE_NUMBER); + } + + private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER + && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { + return null; // snapshot is stale + } + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } else if (entry.currentEditor != null) { + return null; // another edit is in progress + } + + Editor editor = new Editor(entry); + entry.currentEditor = editor; + + // flush the journal before creating files to prevent file leaks + journalWriter.write(DIRTY + ' ' + key + '\n'); + journalWriter.flush(); + return editor; + } + + /** + * Returns the directory where this cache stores its data. + */ + public File getDirectory() { + return directory; + } + + /** + * Returns the maximum number of bytes that this cache should use to store + * its data. + */ + public long maxSize() { + return maxSize; + } + + /** + * Returns the number of bytes currently being used to store the values in + * this cache. This may be greater than the max size if a background + * deletion is pending. + */ + public synchronized long size() { + return size; + } + + private synchronized void completeEdit(Editor editor, boolean success) throws IOException { + Entry entry = editor.entry; + if (entry.currentEditor != editor) { + throw new IllegalStateException(); + } + + // if this edit is creating the entry for the first time, every index must have a value + if (success && !entry.readable) { + for (int i = 0; i < valueCount; i++) { + if (!entry.getDirtyFile(i).exists()) { + editor.abort(); + throw new IllegalStateException("edit didn't create file " + i); + } + } + } + + for (int i = 0; i < valueCount; i++) { + File dirty = entry.getDirtyFile(i); + if (success) { + if (dirty.exists()) { + File clean = entry.getCleanFile(i); + dirty.renameTo(clean); + long oldLength = entry.lengths[i]; + long newLength = clean.length(); + entry.lengths[i] = newLength; + size = size - oldLength + newLength; + } + } else { + deleteIfExists(dirty); + } + } + + redundantOpCount++; + entry.currentEditor = null; + if (entry.readable | success) { + entry.readable = true; + journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + if (success) { + entry.sequenceNumber = nextSequenceNumber++; + } + } else { + lruEntries.remove(entry.key); + journalWriter.write(REMOVE + ' ' + entry.key + '\n'); + } + + if (size > maxSize || journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + } + + /** + * We only rebuild the journal when it will halve the size of the journal + * and eliminate at least 2000 ops. + */ + private boolean journalRebuildRequired() { + final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000; + return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD + && redundantOpCount >= lruEntries.size(); + } + + /** + * Drops the entry for {@code key} if it exists and can be removed. Entries + * actively being edited cannot be removed. + * + * @return true if an entry was removed. + */ + public synchronized boolean remove(String key) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null || entry.currentEditor != null) { + return false; + } + + for (int i = 0; i < valueCount; i++) { + File file = entry.getCleanFile(i); + if (!file.delete()) { + throw new IOException("failed to delete " + file); + } + size -= entry.lengths[i]; + entry.lengths[i] = 0; + } + + redundantOpCount++; + journalWriter.append(REMOVE + ' ' + key + '\n'); + lruEntries.remove(key); + + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return true; + } + + /** + * Returns true if this cache has been closed. + */ + public boolean isClosed() { + return journalWriter == null; + } + + private void checkNotClosed() { + if (journalWriter == null) { + throw new IllegalStateException("cache is closed"); + } + } + + /** + * Force buffered operations to the filesystem. + */ + public synchronized void flush() throws IOException { + checkNotClosed(); + trimToSize(); + journalWriter.flush(); + } + + /** + * Closes this cache. Stored values will remain on the filesystem. + */ + @Override + public synchronized void close() throws IOException { + if (journalWriter == null) { + return; // already closed + } + for (Entry entry : new ArrayList(lruEntries.values())) { + if (entry.currentEditor != null) { + entry.currentEditor.abort(); + } + } + trimToSize(); + journalWriter.close(); + journalWriter = null; + } + + private void trimToSize() throws IOException { + while (size > maxSize) { +// Map.Entry toEvict = lruEntries.eldest(); + final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); + remove(toEvict.getKey()); + } + } + + /** + * Closes the cache and deletes all of its stored values. This will delete + * all files in the cache directory including files that weren't created by + * the cache. + */ + public void delete() throws IOException { + close(); + deleteContents(directory); + } + + private void validateKey(String key) { + if (key.contains(" ") || key.contains("\n") || key.contains("\r")) { + throw new IllegalArgumentException( + "keys must not contain spaces or newlines: \"" + key + "\""); + } + } + + private static String inputStreamToString(InputStream in) throws IOException { + return readFully(new InputStreamReader(in, UTF_8)); + } + + /** + * A snapshot of the values for an entry. + */ + public final class Snapshot implements Closeable { + private final String key; + private final long sequenceNumber; + private final InputStream[] ins; + + private Snapshot(String key, long sequenceNumber, InputStream[] ins) { + this.key = key; + this.sequenceNumber = sequenceNumber; + this.ins = ins; + } + + /** + * Returns an editor for this snapshot's entry, or null if either the + * entry has changed since this snapshot was created or if another edit + * is in progress. + */ + public Editor edit() throws IOException { + return DiskLruCache.this.edit(key, sequenceNumber); + } + + /** + * Returns the unbuffered stream with the value for {@code index}. + */ + public InputStream getInputStream(int index) { + return ins[index]; + } + + /** + * Returns the string value for {@code index}. + */ + public String getString(int index) throws IOException { + return inputStreamToString(getInputStream(index)); + } + + @Override + public void close() { + for (InputStream in : ins) { + closeQuietly(in); + } + } + } + + /** + * Edits the values for an entry. + */ + public final class Editor { + private final Entry entry; + private boolean hasErrors; + + private Editor(Entry entry) { + this.entry = entry; + } + + /** + * Returns an unbuffered input stream to read the last committed value, + * or null if no value has been committed. + */ + public InputStream newInputStream(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + return null; + } + return new FileInputStream(entry.getCleanFile(index)); + } + } + + /** + * Returns the last committed value as a string, or null if no value + * has been committed. + */ + public String getString(int index) throws IOException { + InputStream in = newInputStream(index); + return in != null ? inputStreamToString(in) : null; + } + + /** + * Returns a new unbuffered output stream to write the value at + * {@code index}. If the underlying output stream encounters errors + * when writing to the filesystem, this edit will be aborted when + * {@link #commit} is called. The returned output stream does not throw + * IOExceptions. + */ + public OutputStream newOutputStream(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index))); + } + } + + /** + * Sets the value at {@code index} to {@code value}. + */ + public void set(int index, String value) throws IOException { + Writer writer = null; + try { + writer = new OutputStreamWriter(newOutputStream(index), UTF_8); + writer.write(value); + } finally { + closeQuietly(writer); + } + } + + /** + * Commits this edit so it is visible to readers. This releases the + * edit lock so another edit may be started on the same key. + */ + public void commit() throws IOException { + if (hasErrors) { + completeEdit(this, false); + remove(entry.key); // the previous entry is stale + } else { + completeEdit(this, true); + } + } + + /** + * Aborts this edit. This releases the edit lock so another edit may be + * started on the same key. + */ + public void abort() throws IOException { + completeEdit(this, false); + } + + private class FaultHidingOutputStream extends FilterOutputStream { + private FaultHidingOutputStream(OutputStream out) { + super(out); + } + + @Override public void write(int oneByte) { + try { + out.write(oneByte); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void write(@NonNull byte[] buffer, int offset, int length) { + try { + out.write(buffer, offset, length); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void close() { + try { + out.close(); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void flush() { + try { + out.flush(); + } catch (IOException e) { + hasErrors = true; + } + } + } + } + + private final class Entry { + private final String key; + + /** Lengths of this entry's files. */ + private final long[] lengths; + + /** True if this entry has ever been published */ + private boolean readable; + + /** The ongoing edit or null if this entry is not being edited. */ + private Editor currentEditor; + + /** The sequence number of the most recently committed edit to this entry. */ + private long sequenceNumber; + + private Entry(String key) { + this.key = key; + this.lengths = new long[valueCount]; + } + + public String getLengths() throws IOException { + StringBuilder result = new StringBuilder(); + for (long size : lengths) { + result.append(' ').append(size); + } + return result.toString(); + } + + /** + * Set lengths using decimal numbers like "10123". + */ + private void setLengths(String[] strings) throws IOException { + if (strings.length != valueCount) { + throw invalidLengths(strings); + } + + try { + for (int i = 0; i < strings.length; i++) { + lengths[i] = Long.parseLong(strings[i]); + } + } catch (NumberFormatException e) { + throw invalidLengths(strings); + } + } + + private IOException invalidLengths(String[] strings) throws IOException { + throw new IOException("unexpected journal line: " + Arrays.toString(strings)); + } + + public File getCleanFile(int i) { + return new File(directory, key + "." + i); + } + + public File getDirtyFile(int i) { + return new File(directory, key + "." + i + ".tmp"); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageCache.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageCache.java new file mode 100644 index 000000000..28ef5deff --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageCache.java @@ -0,0 +1,643 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.app.ActivityManager; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.os.Environment; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentManager; +import androidx.collection.LruCache; +import android.util.Log; + +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import uk.org.ngo.squeezer.BuildConfig; + +/** + * This class holds our bitmap caches (memory and disk). + */ +public class ImageCache { + + private static final String TAG = "ImageCache"; + + // Default memory cache size + private static final int DEFAULT_MEM_CACHE_SIZE = 1024 * 1024 * 5; // 5MB + + // Default disk cache size + private static final int MAX_DISK_CACHE_SIZE = 1024 * 1024 * 100; // 100MB + + // Default disk cache percent + private static final float DEFAULT_DISK_CACHE_SIZE_PERCENT = 0.2f; + + // Compression settings when writing images to disk cache + private static final CompressFormat DEFAULT_COMPRESS_FORMAT = CompressFormat.JPEG; + + private static final int DEFAULT_COMPRESS_QUALITY = 70; + + private static final int DISK_CACHE_INDEX = 0; + + // Constants to easily toggle various caches + private static final boolean DEFAULT_MEM_CACHE_ENABLED = true; + + private static final boolean DEFAULT_DISK_CACHE_ENABLED = true; + + private static final boolean DEFAULT_CLEAR_DISK_CACHE_ON_START = false; + + private static final boolean DEFAULT_INIT_DISK_CACHE_ON_CREATE = false; + + private DiskLruCache mDiskLruCache; + + private LruCache mMemoryCache; + + private ImageCacheParams mCacheParams; + + private final Object mDiskCacheLock = new Object(); + + private boolean mDiskCacheStarting = true; + + private static final HashFunction mHashFunction = Hashing.md5(); + + /** + * Creating a new ImageCache object using the specified parameters. + * + * @param cacheParams The cache parameters to use to initialize the cache + */ + public ImageCache(ImageCacheParams cacheParams) { + init(cacheParams); + } + + /** + * Creating a new ImageCache object using the default parameters. + * + * @param context The context to use + * @param uniqueName A unique name that will be appended to the cache directory + */ + public ImageCache(Context context, String uniqueName) { + init(new ImageCacheParams(context, uniqueName)); + } + + /** + * Find and return an existing ImageCache stored in a {@link RetainFragment}, if not found a new + * one is created using the supplied params and saved to a {@link RetainFragment}. + * + * @param fragmentManager The fragment manager to use when dealing with the retained fragment. + * @param cacheParams The cache parameters to use if creating the ImageCache + * + * @return An existing retained ImageCache object or a new one if one did not exist + */ + public static ImageCache findOrCreateCache( + FragmentManager fragmentManager, ImageCacheParams cacheParams) { + + // Search for, or create an instance of the non-UI RetainFragment + final RetainFragment mRetainFragment = RetainFragment.getInstance(TAG, fragmentManager); + + // See if we already have an ImageCache stored in RetainFragment + ImageCache imageCache = (ImageCache) mRetainFragment.get(TAG); + + // No existing ImageCache, create one and store it in RetainFragment + if (imageCache == null) { + imageCache = new ImageCache(cacheParams); + mRetainFragment.put(TAG, imageCache); + } + + return imageCache; + } + + /** + * Initialize the cache, providing all parameters. + * + * @param cacheParams The cache parameters to initialize the cache + */ + private void init(ImageCacheParams cacheParams) { + mCacheParams = cacheParams; + + // Set up memory cache + if (mCacheParams.memoryCacheEnabled) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Memory cache created (size = " + mCacheParams.memCacheSize + ")"); + } + mMemoryCache = new LruCache(mCacheParams.memCacheSize) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return (bitmap.getRowBytes() * bitmap.getHeight()); + } + }; + } + + // By default the disk cache is not initialized here as it should be initialized + // on a separate thread due to disk access. + if (cacheParams.initDiskCacheOnCreate) { + // Set up disk cache + initDiskCache(); + } + } + + /** + * Initializes the disk cache. Note that this includes disk access so this should not be + * executed on the main/UI thread. By default an ImageCache does not initialize the disk cache + * when it is created, instead you should call initDiskCache() to initialize it on a background + * thread. + */ + public void initDiskCache() { + // Set up disk cache + synchronized (mDiskCacheLock) { + if (mDiskLruCache == null || mDiskLruCache.isClosed()) { + File diskCacheDir = mCacheParams.diskCacheDir; + if (mCacheParams.diskCacheEnabled && diskCacheDir != null) { + if (!diskCacheDir.exists()) { + diskCacheDir.mkdirs(); + } + long usableSpace = getUsableSpace(diskCacheDir); + long diskCacheSize = Math.round(Math.min( + usableSpace * mCacheParams.diskCacheSizePercent, + MAX_DISK_CACHE_SIZE)); + Log.d(TAG, "Usable space: " + usableSpace); + Log.d(TAG, " Cache size: " + diskCacheSize); + + if (usableSpace > diskCacheSize) { + try { + mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, diskCacheSize); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache initialized in " + diskCacheDir); + } + } catch (final IOException e) { + mCacheParams.diskCacheDir = null; + Log.e(TAG, "initDiskCache - " + e); + } + } + } + } + mDiskCacheStarting = false; + mDiskCacheLock.notifyAll(); + } + } + + /** + * Adds a bitmap to both memory and disk cache. + * + * @param data Unique identifier for the bitmap to store + * @param bitmap The bitmap to store + */ + public void addBitmapToCache(String data, Bitmap bitmap) { + if (data == null || bitmap == null) { + return; + } + + addBitmapToMemoryCache(data, bitmap); + addBitmapToDiskCache(data, bitmap); + } + + /** + * Adds a bitmap to the memory cache. + * + * @param data Unique identifier for the bitmap to store + * @param bitmap The bitmap to store + */ + public void addBitmapToMemoryCache(String data, Bitmap bitmap) { + if (data == null || bitmap == null) { + return; + } + + // Add to memory cache + if (mMemoryCache != null && mMemoryCache.get(data) == null) { + mMemoryCache.put(data, bitmap); + } + } + + /** + * Adds a bitmap to the disk cache. + * + * @param data Unique identifier for the bitmap to store + * @param bitmap The bitmap to store + */ + public void addBitmapToDiskCache(String data, Bitmap bitmap) { + if (data == null || bitmap == null) { + return; + } + + synchronized (mDiskCacheLock) { + // Add to disk cache + if (mDiskLruCache != null) { + final String key = hashKeyForDisk(data); + OutputStream out = null; + try { + DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); + if (snapshot == null) { + final DiskLruCache.Editor editor = mDiskLruCache.edit(key); + if (editor != null) { + out = editor.newOutputStream(DISK_CACHE_INDEX); + bitmap.compress( + mCacheParams.compressFormat, mCacheParams.compressQuality, out); + editor.commit(); + out.close(); + } + } else { + snapshot.getInputStream(DISK_CACHE_INDEX).close(); + } + } catch (final IOException e) { + Log.e(TAG, "addBitmapToCache - " + e); + } catch (Exception e) { + Log.e(TAG, "addBitmapToCache - " + e); + } finally { + try { + if (out != null) { + out.close(); + } + } catch (IOException e) { + } + } + } + } + } + + /** + * Adds a byte[] to the disk cache. + * + * @param data Unique identifier for the bitmap to store + * @param bytes The bytes to store + */ + public void addBytesToDiskCache(String data, byte[] bytes) { + if (data == null || bytes.length == 0) { + return; + } + + synchronized (mDiskCacheLock) { + // Add to disk cache + if (mDiskLruCache != null) { + final String key = hashKeyForDisk(data); + OutputStream out = null; + try { + DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); + if (snapshot == null) { + final DiskLruCache.Editor editor = mDiskLruCache.edit(key); + if (editor != null) { + out = editor.newOutputStream(DISK_CACHE_INDEX); + out.write(bytes); + editor.commit(); + out.close(); + } + } else { + snapshot.getInputStream(DISK_CACHE_INDEX).close(); + } + } catch (final IOException e) { + Log.e(TAG, "addBitmapToCache - " + e); + } catch (Exception e) { + Log.e(TAG, "addBitmapToCache - " + e); + } finally { + try { + if (out != null) { + out.close(); + } + } catch (IOException e) { + } + } + } + } + } + + /** + * Get from memory cache. + * + * @param data Unique identifier for which item to get + * + * @return The bitmap if found in cache, null otherwise + */ + public Bitmap getBitmapFromMemCache(String data) { + Bitmap bitmap = null; + + if (mMemoryCache != null) { + bitmap = mMemoryCache.get(data); + + /* + Log.d(TAG, String.format( + "Cache stats: h: %d m: %d (%.2f%%) p: %d e: %d, size: %d of %d (%.2f%%)", + mMemoryCache.hitCount(), + mMemoryCache.missCount(), + ((float) mMemoryCache.hitCount() + / (mMemoryCache.hitCount() + mMemoryCache.missCount()) * 100), + mMemoryCache.putCount(), + mMemoryCache.evictionCount(), mMemoryCache.size(), mMemoryCache.maxSize(), + ((float) mMemoryCache.size() / mMemoryCache.maxSize()) * 100)); + */ + } + + return bitmap; + } + + /** + * Get from disk cache. + * + * @param data Unique identifier for which item to get + * + * @return The bitmap if found in cache, null otherwise + */ + @Nullable + public Bitmap getBitmapFromDiskCache(String data) { + final String key = hashKeyForDisk(data); + synchronized (mDiskCacheLock) { + while (mDiskCacheStarting) { + try { + mDiskCacheLock.wait(); + } catch (InterruptedException e) { + } + } + if (mDiskLruCache != null) { + InputStream inputStream = null; + try { + final DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); + if (snapshot != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache hit"); + } + inputStream = snapshot.getInputStream(DISK_CACHE_INDEX); + if (inputStream != null) { + return BitmapFactory.decodeStream(inputStream); + } + } + } catch (final IOException e) { + Log.e(TAG, "getBitmapFromDiskCache - " + e); + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + } + } + } + return null; + } + } + + /** + * Get byte[] from disk cache. + * + * @param data Unique identifier for which item to get + * + * @return The bytes at that entry in the cache, null otherwise + */ + @Nullable + public byte[] getBytesFromDiskCache(String data) { + final String key = hashKeyForDisk(data); + synchronized (mDiskCacheLock) { + while (mDiskCacheStarting) { + try { + mDiskCacheLock.wait(); + } catch (InterruptedException e) { + } + } + if (mDiskLruCache != null) { + InputStream inputStream = null; + try { + final DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key); + if (snapshot != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache hit"); + } + inputStream = snapshot.getInputStream(DISK_CACHE_INDEX); + if (inputStream != null) { + return ByteStreams.toByteArray(inputStream); + } + } + } catch (final IOException e) { + Log.e(TAG, "getBitmapFromDiskCache - " + e); + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + } + } + } + return null; + } + } + + /** + * Clears both the memory and disk cache associated with this ImageCache object. Note that this + * includes disk access so this should not be executed on the main/UI thread. + */ + public void clearCache() { + clearMemoryCache(); + + synchronized (mDiskCacheLock) { + mDiskCacheStarting = true; + if (mDiskLruCache != null && !mDiskLruCache.isClosed()) { + try { + mDiskLruCache.delete(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache cleared"); + } + } catch (IOException e) { + Log.e(TAG, "clearCache - " + e); + } + mDiskLruCache = null; + initDiskCache(); + } + } + } + + public void clearMemoryCache() { + if (mMemoryCache != null) { + mMemoryCache.evictAll(); + Log.d(TAG, "Memory cache cleared"); + } + } + + /** + * Flushes the disk cache associated with this ImageCache object. Note that this includes disk + * access so this should not be executed on the main/UI thread. + */ + public void flush() { + synchronized (mDiskCacheLock) { + if (mDiskLruCache != null) { + try { + mDiskLruCache.flush(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache flushed"); + } + } catch (IOException e) { + Log.e(TAG, "flush - " + e); + } + } + } + } + + /** + * Closes the disk cache associated with this ImageCache object. Note that this includes disk + * access so this should not be executed on the main/UI thread. + */ + public void close() { + synchronized (mDiskCacheLock) { + if (mDiskLruCache != null) { + try { + if (!mDiskLruCache.isClosed()) { + mDiskLruCache.close(); + mDiskLruCache = null; + if (BuildConfig.DEBUG) { + Log.d(TAG, "Disk cache closed"); + } + } + } catch (IOException e) { + Log.e(TAG, "close - " + e); + } + } + } + } + + /** + * A holder class that contains cache parameters. + */ + public static class ImageCacheParams { + + public int memCacheSize = DEFAULT_MEM_CACHE_SIZE; + + public float diskCacheSizePercent = DEFAULT_DISK_CACHE_SIZE_PERCENT; + + public final int maxDiskCacheSize = MAX_DISK_CACHE_SIZE; + + public File diskCacheDir; + + public final CompressFormat compressFormat = DEFAULT_COMPRESS_FORMAT; + + public final int compressQuality = DEFAULT_COMPRESS_QUALITY; + + public final boolean memoryCacheEnabled = DEFAULT_MEM_CACHE_ENABLED; + + public final boolean diskCacheEnabled = DEFAULT_DISK_CACHE_ENABLED; + + public boolean clearDiskCacheOnStart = DEFAULT_CLEAR_DISK_CACHE_ON_START; + + public final boolean initDiskCacheOnCreate = DEFAULT_INIT_DISK_CACHE_ON_CREATE; + + public ImageCacheParams(Context context, String uniqueName) { + diskCacheDir = getDiskCacheDir(context, uniqueName); + } + + public ImageCacheParams(File diskCacheDir) { + this.diskCacheDir = diskCacheDir; + } + + /** + * Sets the memory cache size based on a percentage of the device memory class. Eg. setting + * percent to 0.2 would set the memory cache to one fifth of the device memory class. Throws + * {@link IllegalArgumentException} if percent is < 0.05 or > .8. + *

+ * This value should be chosen carefully based on a number of factors Refer to the + * corresponding Android Training class for more discussion: http://developer.android.com/training/displaying-bitmaps/ + * + * @param context Context to use to fetch memory class + * @param percent Percent of memory class to use to size memory cache + */ + public void setMemCacheSizePercent(Context context, float percent) { + if (percent < 0.05f || percent > 0.8f) { + throw new IllegalArgumentException("setMemCacheSizePercent - percent must be " + + "between 0.05 and 0.8 (inclusive)"); + } + memCacheSize = Math.round(percent * getMemoryClass(context) * 1024 * 1024); + } + + private static int getMemoryClass(Context context) { + return ((ActivityManager) context.getSystemService( + Context.ACTIVITY_SERVICE)).getMemoryClass(); + } + } + + /** + * Get a usable cache directory (external if available, internal otherwise). + * + * @param context The context to use + * @param uniqueName A unique directory name to append to the cache dir + * + * @return The cache dir + */ + public static File getDiskCacheDir(Context context, String uniqueName) { + // Check if media is mounted or storage is built-in, if so, try and use external cache dir + // otherwise use internal cache dir + File externalCacheDir = getExternalCacheDir(context); + final String cachePath = ((Environment.MEDIA_MOUNTED.equals(Environment + .getExternalStorageState()) || !isExternalStorageRemovable()) + && externalCacheDir != null) + ? externalCacheDir.getPath() + : context.getCacheDir().getPath(); + + return new File(cachePath + File.separator + uniqueName); + } + + /** + * A hashing method that changes a string (like a URL) into a hash suitable for using as a disk + * filename. The hashing method is MD5. + */ + public static String hashKeyForDisk(String key) { + return mHashFunction.hashBytes(key.getBytes()).toString(); + } + + /** + * Get the size in bytes of a bitmap. + * + * @param bitmap Bitmap to examine. + * + * @return size in bytes + */ + public static int getBitmapSize(Bitmap bitmap) { + return bitmap.getByteCount(); + } + + /** + * Check if external storage is built-in or removable. + * + * @return True if external storage is removable (like an SD card), false otherwise. + */ + public static boolean isExternalStorageRemovable() { + return Environment.isExternalStorageRemovable(); + } + + /** + * Get the external app cache directory. + * + * @param context The context to use + * + * @return The external cache dir + */ + @Nullable + public static File getExternalCacheDir(Context context) { + return context.getExternalCacheDir(); + } + + /** + * Check how much usable space is available at a given path. + * + * @param path The path to check + * + * @return The space available in bytes + */ + public static long getUsableSpace(File path) { + return path.getUsableSpace(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageFetcher.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageFetcher.java new file mode 100644 index 000000000..9e94e69c8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageFetcher.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.content.Context; + +import androidx.annotation.NonNull; +import android.util.Log; + +import com.google.common.io.ByteStreams; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLDecoder; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.Nullable; + +import uk.org.ngo.squeezer.R; + +/** + * A subclass of {@link ImageWorker} that fetches images from a URL. + */ +public class ImageFetcher extends ImageWorker { + private static final String TAG = "ImageFetcher"; + + private volatile static ImageFetcher sImageFetcher; + + private ImageFetcher(Context context) { + super(context); + } + + /** + * @return an imagefetcher globally useful for the application, with a cache that + * is maintained across activities. + * + * @param context Anything that provides a context. + */ + @NonNull + public static ImageFetcher getInstance(Context context) { + ImageFetcher result = sImageFetcher; + if (result == null) { + synchronized (ImageFetcher.class) { + result = sImageFetcher; + if (result == null) { + sImageFetcher = new ImageFetcher(context); + sImageFetcher.setLoadingImage(R.drawable.icon_pending_artwork); + ImageCache.ImageCacheParams imageCacheParams = new ImageCache.ImageCacheParams(context, "artwork"); + imageCacheParams.setMemCacheSizePercent(context, 0.12f); + sImageFetcher.addImageCache(imageCacheParams); + } + } + } + return sImageFetcher; + } + + /** + * Call this in low memory situations. Clears the memory cache. + */ + public static void onLowMemory() { + if (sImageFetcher == null) { + return; + } + + sImageFetcher.clearMemoryCache(); + } + + /** + * The main process method, which will be called by the ImageWorker in the AsyncTask background + * thread. + * + * @param params The parameters for this request. + * + * @return Undecoded bytes for the requested bitmap, null if downloading failed. + */ + @Nullable + protected byte[] processBitmap(BitmapWorkerTaskParams params) { + String data = params.data.toString(); + Log.d(TAG, "processBitmap: " + data); + + HttpURLConnection urlConnection = null; + InputStream in = null; + byte[] bytes = null; + + try { + URL resourceUrl, base, next; + Map visited; + String location; + + visited = new HashMap<>(); + + while (true) + { + Integer times = visited.get(data); + if (times == null) times = 0; + visited.put(data, ++times); + + if (times > 3) + throw new IOException("Stuck in redirect loop"); + + resourceUrl = new URL(data); + urlConnection = (HttpURLConnection) resourceUrl.openConnection(); + + urlConnection.setConnectTimeout(15000); + urlConnection.setReadTimeout(15000); + urlConnection.setInstanceFollowRedirects(false); // Make the logic below easier to detect redirections + + switch (urlConnection.getResponseCode()) + { + case HttpURLConnection.HTTP_MOVED_PERM: + case HttpURLConnection.HTTP_MOVED_TEMP: + location = urlConnection.getHeaderField("Location"); + location = URLDecoder.decode(location, "UTF-8"); + base = new URL(data); + next = new URL(base, location); // Deal with relative URLs + data = next.toExternalForm(); + continue; + } + + break; + } + + in = urlConnection.getInputStream(); + bytes = ByteStreams.toByteArray(in); + } catch (final IOException e) { + Log.e(TAG, "Error in downloadUrlToStream - " + data + e); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + try { + if (in != null) { + in.close(); + } + } catch (final IOException e) { + Log.e(TAG, "Closing input stream failed"); + } + } + + return bytes; + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageWorker.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageWorker.java new file mode 100644 index 000000000..194ac3595 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ImageWorker.java @@ -0,0 +1,946 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.app.Activity; +import android.app.Notification; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.IdRes; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.core.app.NotificationManagerCompat; +import android.util.Log; +import android.view.ViewTreeObserver; +import android.widget.ImageView; +import android.widget.RemoteViews; + +import com.google.common.base.Joiner; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; + +import javax.annotation.Nullable; + +import uk.org.ngo.squeezer.BuildConfig; + +/** + * This class wraps up completing some arbitrary long running work when loading a bitmap to an + * ImageView. It handles things like using a memory and disk cache, running the work in a background + * thread and setting a placeholder image. + */ +public abstract class ImageWorker { + + private static final String TAG = "ImageWorker"; + + private static final int FADE_IN_TIME = 200; + + private ImageCache mImageCache; + + private Bitmap mLoadingBitmap; + + private boolean mFadeInBitmap = true; + + private boolean mExitTasksEarly = false; + + protected boolean mPauseWork = false; + + private final Object mPauseWorkLock = new Object(); + + protected final Resources mResources; + + @IntDef({MESSAGE_CLEAR, MESSAGE_INIT_DISK_CACHE, MESSAGE_FLUSH, MESSAGE_CLOSE, + MESSAGE_CLEAR_MEMORY_CACHE}) + @Retention(RetentionPolicy.SOURCE) + public @interface CacheMessages {} + private static final int MESSAGE_CLEAR = 0; + private static final int MESSAGE_INIT_DISK_CACHE = 1; + private static final int MESSAGE_FLUSH = 2; + private static final int MESSAGE_CLOSE = 3; + private static final int MESSAGE_CLEAR_MEMORY_CACHE = 4; + + /** Joiner for the components that make up a key in the memory cache. */ + protected static final Joiner mMemCacheKeyJoiner = Joiner.on(':'); + + /** Paint to use when colouring debug swatches on images. */ + private static final Paint mCacheDebugPaint = new Paint(); + + /** Colour of debug swatch for images loaded from memory cache. */ + private static final int mCacheDebugColorMemory = Color.GREEN; + + /** Colour of debug swatch for images loaded from disk cache. */ + private static final int mCacheDebugColorDisk = Color.BLUE; + + /** Colour of debug swatch for images loaded from network (no caching). */ + private static final int mCacheDebugColorNetwork = Color.RED; + + protected ImageWorker(Context context) { + mResources = context.getResources(); + } + + /** + * Load an image specified by the data parameter into an ImageView (override {@link + * ImageWorker#processBitmap(BitmapWorkerTaskParams)} to define the processing logic). A memory and disk cache + * will be used if an {@link ImageCache} has been set using {@link + * ImageWorker#setImageCache(ImageCache)}. If the image is found in the memory cache, it is set + * immediately, otherwise an {@link AsyncTask} will be created to asynchronously load the + * bitmap. + * + * @param data The URL of the image to download + * @param imageView The ImageView to bind the downloaded image to + */ + public void loadImage(final Object data, final ImageView imageView) { + if (data == null) { + return; + } + + int width = imageView.getWidth(); + int height = imageView.getHeight(); + + // If the dimensions aren't known yet then the view hasn't been measured. Get a + // ViewTreeObserver and listen for the PreDraw message. Using a GlobalLayoutListener + // does not work for views that are in the list but drawn off-screen, possibly due + // to the convertview. See http://stackoverflow.com/a/14325365 for some discussion. + // The solution there, of posting a runnable, does not appear to reliably work on + // devices running (at least) API 7. An OnPreDrawListener appears to work, and will + // be called after measurement is complete. + if (width == 0 || height == 0) { + // Store the URL in the imageView's tag, in case the URL assigned to is changed. + imageView.setTag(data); + + imageView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + imageView.getViewTreeObserver().removeOnPreDrawListener(this); + // If the imageView is still assigned to the URL then we can load in to it. + if (data.equals(imageView.getTag())) { + loadImage(data, imageView); + } + return true; + } + }); + return; + } + + loadImage(data, imageView, width, height); + } + + /** + * Like {@link #loadImage(Object, ImageView)} but with explicit width and height parameters. + * + * @param data The URL of the image to download + * @param imageView The ImageView to bind the downloaded image to + * @param width Resize the image to this width (and save it in the memory cache as such) + * @param height Resize the image to this height (and save it in the memory cache as such) + * @param callback Will be called once an image is set on the view. + */ + public void loadImage(final Object data, final ImageView imageView, int width, int height, LoadImageCallback callback) { + Bitmap bitmap = null; + String memCacheKey = hashKeyForMemory(String.valueOf(data), width, height); + + if (mImageCache != null) { + bitmap = mImageCache.getBitmapFromMemCache(memCacheKey); + } + + if (bitmap != null) { + // Bitmap found in memory cache + if (BuildConfig.DEBUG) { + addDebugSwatch(new Canvas(bitmap), mCacheDebugColorMemory); + } + imageView.setImageBitmap(bitmap); + if (callback != null) { + callback.onDone(); + } + } else if (cancelPotentialWork(data, imageView)) { + final ImageViewBitmapWorkerTask task = new ImageViewBitmapWorkerTask(imageView, callback); + final AsyncDrawable asyncDrawable = + new AsyncDrawable(mResources, mLoadingBitmap, task); + imageView.setImageDrawable(asyncDrawable); + + // NOTE: This uses a custom version of AsyncTask that has been pulled from the + // framework and slightly modified. Refer to the docs at the top of the class + // for more info on what was changed. + task.executeOnExecutor(AsyncTask.DUAL_THREAD_EXECUTOR, + new BitmapWorkerTaskParams(width, height, data, memCacheKey)); + } + } + + /** + * Like {@link #loadImage(Object, ImageView)} but with explicit width and height parameters. + * + * @param data The URL of the image to download + * @param imageView The ImageView to bind the downloaded image to + * @param width Resize the image to this width (and save it in the memory cache as such) + * @param height Resize the image to this height (and save it in the memory cache as such) + */ + public void loadImage(final Object data, final ImageView imageView, int width, int height) { + loadImage(data, imageView, width, height, null); + } + + /** + * Interface for callbacks passed to {@link #loadImage(Object, ImageView, int, int, LoadImageCallback)} + */ + public interface LoadImageCallback { + /** + * Called after the bitmap has been set on the view. + */ + void onDone(); + } + + /** + * Interface for callbacks passed to {@link #loadImage(Object, int, int, ImageWorkerCallback)} + */ + public interface ImageWorkerCallback { + /** + * Called after the bitmap has been loaded. + * + * @param data The same value passed as the data parameter to + * {@link #loadImage(Object, int, int, ImageWorkerCallback)} + * @param bitmap The bitmap, may be null if loading if it failed to load + */ + void process(Object data, @Nullable Bitmap bitmap); + } + + /** + * Like {@link #loadImage(Object, ImageView, int, int)} but calls the provided callback after + * the image has been loaded instead of saving it in to an imageview. + * + * @param data The URL of the image to download + * @param width Resize the image to this width (and save it in the memory cache as such) + * @param height Resize the image to this height (and save it in the memory cache as such) + * @param callback The callback + */ + public void loadImage(final Object data, int width, int height, ImageWorkerCallback callback) { + Bitmap bitmap = null; + String memCacheKey = hashKeyForMemory(String.valueOf(data), width, height); + if (mImageCache != null) { + bitmap = mImageCache.getBitmapFromMemCache(memCacheKey); + } + + if (bitmap != null) { + // Bitmap found in memory cache + if (BuildConfig.DEBUG) { + addDebugSwatch(new Canvas(bitmap), mCacheDebugColorMemory); + } + callback.process(data, bitmap); + } else { + final CallbackBitmapWorkerTask task = new CallbackBitmapWorkerTask(callback); + + task.executeOnExecutor(AsyncTask.DUAL_THREAD_EXECUTOR, + new BitmapWorkerTaskParams(width, height, data, memCacheKey)); + } + } + + /** + * Loads the requested image in to an {@link ImageView} in the given {@link RemoteViews} + * and updates the notification when done. + * + * @param context system context + * @param data The URL of the image to download + * @param remoteViews The {@link RemoteViews} that contains the {@link ImageView} + * @param viewId The identifier for the {@link ImageView} + * @param width Resize the image to this width (and save it in the memory cache as such) + * @param height Resize the image to this height (and save it in the memory cache as such) + * @param nm The notification manager + * @param notificationId Identifier of the notification to update + * @param notification The notification to post + */ + public void loadImage(Context context, final Object data, final RemoteViews remoteViews, + @IdRes int viewId, int width, int height, + NotificationManagerCompat nm, int notificationId, Notification notification) { + Bitmap bitmap = null; + String memCacheKey = hashKeyForMemory(String.valueOf(data), width, height); + if (mImageCache != null) { + bitmap = mImageCache.getBitmapFromMemCache(memCacheKey); + } + + if (bitmap != null) { + // Bitmap found in memory cache + if (BuildConfig.DEBUG) { + addDebugSwatch(new Canvas(bitmap), mCacheDebugColorMemory); + } + remoteViews.setImageViewBitmap(viewId, bitmap); + nm.notify(notificationId, notification); + } else { + final RemoteViewBitmapWorkerTask task = new RemoteViewBitmapWorkerTask( + remoteViews, viewId, nm, notificationId, notification); + final AsyncDrawable asyncDrawable = new AsyncDrawable(mResources, mLoadingBitmap, task); + remoteViews.setImageViewBitmap(viewId, asyncDrawable.getBitmap()); + + // NOTE: This uses a custom version of AsyncTask that has been pulled from the + // framework and slightly modified. Refer to the docs at the top of the class + // for more info on what was changed. + task.executeOnExecutor(AsyncTask.DUAL_THREAD_EXECUTOR, + new BitmapWorkerTaskParams(width, height, data, memCacheKey)); + } + } + + /** + * Generates a hash key for the memory cache. The key includes the target width and height, + * so that multiple copies of the image may exist in the cache at different sizes. + * + * @param data The identifier for the image (e.g., URL). + * @param width Target width for the bitmap. + * @param height Target height for the bitmap. + * @return Cache key to use. + */ + @NonNull + private static String hashKeyForMemory(@NonNull String data, int width, int height) { + return mMemCacheKeyJoiner.join(width, height, data); + } + + /** + * Set placeholder bitmap that shows when the the background thread is running. + * + * @param bitmap + */ + public void setLoadingImage(Bitmap bitmap) { + mLoadingBitmap = bitmap; + } + + /** + * Set placeholder bitmap that shows when the the background thread is running. + * + * @param resId + */ + public void setLoadingImage(int resId) { + mLoadingBitmap = BitmapFactory.decodeResource(mResources, resId); + } + + /** + * Adds a debug swatch to a canvas. The marker is a triangle pointing north-west + * on the top left corner, the edges are 25% of the canvas' width and height. + * + * @param canvas The canvas to draw on. + * @param color The colour to use for the swatch. + */ + public static void addDebugSwatch(Canvas canvas, int color) { + float width = canvas.getWidth(); + float height = canvas.getHeight(); + + Path path = new Path(); + path.lineTo(width / 4, 0); + path.lineTo(0, height / 4); + path.lineTo(0, 0); + + // Draw the swatch. + mCacheDebugPaint.setColor(color); + mCacheDebugPaint.setStyle(Paint.Style.FILL); + canvas.drawPath(path, mCacheDebugPaint); + + // Stroke the swatch with a white hairline. + mCacheDebugPaint.setColor(Color.WHITE); + mCacheDebugPaint.setStyle(Paint.Style.STROKE); + mCacheDebugPaint.setStrokeWidth(0); + canvas.drawPath(path, mCacheDebugPaint); + } + + /** + * Adds an {@link ImageCache} to this worker in the background, to prevent disk access on + * the UI thread. + * + * @param imageCacheParams A description of the cache. + */ + public void addImageCache(ImageCache.ImageCacheParams imageCacheParams) { + setImageCache(new ImageCache(imageCacheParams)); + + // AsyncTask instantiates an InternalHandler (which extends Handler but doesn't pass a + // Looper so it grabs the current one, sometimes it's not the main thread and an exception + // is thrown. This makes the Looper explicit. + + Handler handler = new Handler(Looper.getMainLooper()) { + public void handleMessage(@NonNull Message msg) { + new CacheAsyncTask().execute(msg.what); + } + }; + handler.obtainMessage(MESSAGE_INIT_DISK_CACHE).sendToTarget(); + } + + /** + * Sets the {@link ImageCache} object to use with this ImageWorker. Usually you will not need to + * call this directly, instead use {@link ImageWorker#addImageCache} which will create and add + * the {@link ImageCache} object in a background thread (to ensure no disk access on the main/UI + * thread). + * + * @param imageCache + */ + public void setImageCache(ImageCache imageCache) { + mImageCache = imageCache; + } + + /** + * If set to true, the image will fade-in once it has been loaded by the background thread. + */ + public void setImageFadeIn(boolean fadeIn) { + mFadeInBitmap = fadeIn; + } + + /** + * Set the flag that determines whether tasks should exit early. Set to true if any pending + * work should be abandoned (e.g., in {@link Activity#onPause()} ). + * + * @param exitTasksEarly + */ + public void setExitTasksEarly(boolean exitTasksEarly) { + mExitTasksEarly = exitTasksEarly; + } + + /** + * Subclasses should override this to define any processing or work that must happen to produce + * the final bitmap. This will be executed in a background thread and be long running. For + * example, you could resize a large bitmap here, or pull down an image from the network. + * + * @param params The parameters to identify which image to process, as provided by {@link + * ImageWorker#loadImage(Object, ImageView)} + * + * @return The processed bitmap, or null if processing failed. + */ + protected abstract byte[] processBitmap(BitmapWorkerTaskParams params); + + /** + * Cancels any pending work attached to the provided ImageView. + * + * @param imageView + */ + public static void cancelWork(ImageView imageView) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + if (bitmapWorkerTask != null) { + bitmapWorkerTask.cancel(true); + if (BuildConfig.DEBUG) { + final Object bitmapData = bitmapWorkerTask.data; + Log.d(TAG, "cancelWork - cancelled work for " + bitmapData); + } + } + } + + /** + * Returns true if the current work has been cancelled or if there was no work in progress on + * this image view. Returns false if the work in progress deals with the same data. The work is + * not stopped in that case. + */ + public static boolean cancelPotentialWork(Object data, ImageView imageView) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (bitmapWorkerTask != null) { + final Object bitmapData = bitmapWorkerTask.data; + if (bitmapData == null || !bitmapData.equals(data)) { + bitmapWorkerTask.cancel(true); + if (BuildConfig.DEBUG) { + Log.d(TAG, "cancelPotentialWork - cancelled work for " + data); + } + } else { + // The same work is already in progress. + return false; + } + } + return true; + } + + /** + * @param imageView Any imageView + * + * @return Retrieve the currently active work task (if any) associated with this imageView. null + * if there is no such task. + */ + private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } + + protected class BitmapWorkerTaskParams { + /** Desired bitmap width. */ + public final int width; + + /** Desired bitmap height. */ + public final int height; + + /** Identifier for the bitmap to fetch (e.g., a URL). */ + @NonNull + public final Object data; + + /** Cache key to use when saving the bitmap in the memory cache. */ + @NonNull + public final String memCacheKey; + + public BitmapWorkerTaskParams(int width, int height, + @NonNull Object data, @NonNull String memCacheKey) { + this.width = width; + this.height = height; + this.data = data; + this.memCacheKey = memCacheKey; + } + } + + protected class RemoteViewBitmapWorkerTaskParams extends BitmapWorkerTaskParams { + NotificationManagerCompat mNotificationManagerCompat; + int mNotificationId; + Notification mNotification; + + public RemoteViewBitmapWorkerTaskParams(int width, int height, + @NonNull Object data, @NonNull String memCacheKey, + @NonNull NotificationManagerCompat notificationManagerCompat, + int notificationId, + @NonNull Notification notification) { + super(width, height, data, memCacheKey); + mNotificationManagerCompat = notificationManagerCompat; + mNotificationId = notificationId; + mNotification = notification; + } + } + + /** + * The actual AsyncTask that will asynchronously process the image. + */ + private class BitmapWorkerTask extends AsyncTask { + protected static final String TAG = "BitmapWorkerTask"; + protected Object data; + + /** + * Background processing. + */ + @Override + protected Bitmap doInBackground(BitmapWorkerTaskParams... params) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "doInBackground - starting work"); + } + + boolean loadedFromNetwork = false; + + data = params[0].data; + final String dataString = String.valueOf(data); + byte[] bytes = null; + Bitmap scaledBitmap = null; + + // Wait here if work is paused and the task is not cancelled + synchronized (mPauseWorkLock) { + while (mPauseWork && !isCancelled()) { + try { + mPauseWorkLock.wait(); + } catch (InterruptedException e) { + } + } + } + + // If the image cache is available and this task has not been cancelled by another + // thread and there's nothing to indicate this task should cancel then try and fetch + // the bitmap bytes from the cache. + if (mImageCache != null && !isCancelled() && !shouldCancel()) { + bytes = mImageCache.getBytesFromDiskCache(dataString); + } + + // If the bitmap was not found in the cache and this task has not been cancelled by + // another thread and there's nothing to indicate that this task should cancel, then + // call the main process method (as implemented by a subclass) + if ((bytes == null || bytes.length == 0) && !isCancelled() && !shouldCancel()) { + bytes = processBitmap(params[0]); + loadedFromNetwork = true; + + // If the bitmap bytes were loaded then add them to the disk cache. + if (bytes != null && bytes.length != 0 && mImageCache != null) { + mImageCache.addBytesToDiskCache(dataString, bytes); + } + } + + // Create a bitmap from the bytes, scaled to the appropriate size. + if (bytes != null && bytes.length != 0 && params[0].width > 0 && params[0].height > 0) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + + options.inSampleSize = calculateInSampleSize( + options, params[0].width, params[0].height); + + options.inJustDecodeBounds = false; + + if (! BuildConfig.DEBUG) { + // Not a debug build, just need the scaled bitmap. + scaledBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } else { + // Debug build, need a mutable bitmap to add the debug swatch later. + options.inMutable = true; + scaledBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options); + } + } + + // If the bitmap was processed and the image cache is available, then add the processed + // bitmap to the memory cache for future use. Note we don't check if the task was + // cancelled here, if it was, and the thread is still running, we may as well add the + // processed bitmap to our cache as it might be used again in the future. + if (scaledBitmap != null && mImageCache != null) { + mImageCache.addBitmapToMemoryCache(params[0].memCacheKey, scaledBitmap); + } + + if (BuildConfig.DEBUG) { + Log.d(TAG, "doInBackground - finished work"); + } + + if (BuildConfig.DEBUG && scaledBitmap != null) { + if (loadedFromNetwork) { + addDebugSwatch(new Canvas(scaledBitmap), mCacheDebugColorNetwork); + } else { + addDebugSwatch(new Canvas(scaledBitmap), mCacheDebugColorDisk); + } + + } + return scaledBitmap; + } + + /** + * Calculate an inSampleSize for use in a {@link BitmapFactory.Options} object when decoding + * bitmaps using the decode* methods from {@link BitmapFactory}. This implementation calculates + * the closest inSampleSize that will result in the final decoded bitmap having a width and + * height equal to or larger than the requested width and height. This implementation does not + * ensure a power of 2 is returned for inSampleSize which can be faster when decoding but + * results in a larger bitmap which isn't as useful for caching purposes. + * + * @param options An options object with out* params already populated (run through a decode* + * method with inJustDecodeBounds==true + * @param reqWidth The requested width of the resulting bitmap + * @param reqHeight The requested height of the resulting bitmap + * + * @return The value to be used for inSampleSize + */ + public int calculateInSampleSize(BitmapFactory.Options options, + int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + if (width > height) { + inSampleSize = Math.round((float) height / (float) reqHeight); + } else { + inSampleSize = Math.round((float) width / (float) reqWidth); + } + + // This offers some additional logic in case the image has a strange + // aspect ratio. For example, a panorama may have a much larger + // width than height. In these cases the total pixels might still + // end up being too large to fit comfortably in memory, so we should + // be more aggressive with sample down the image (=larger + // inSampleSize). + + final float totalPixels = width * height; + + // Anything more than 2x the requested pixels we'll sample down + // further. + final float totalReqPixelsCap = reqWidth * reqHeight * 2; + + while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) { + inSampleSize++; + } + } + return inSampleSize; + } + + @Override + protected void onCancelled(Bitmap bitmap) { + super.onCancelled(bitmap); + synchronized (mPauseWorkLock) { + mPauseWorkLock.notifyAll(); + } + } + + /** + * Determines whether bitmap processing should abort early. + * + * @return The value of {@code mExitTasksEarly}. + */ + protected boolean shouldCancel() { + return mExitTasksEarly; + } + } + + /** + * A specialisation of {@link BitmapWorkerTask} that sets the loaded bitmap in to an + * {@link ImageView}. + */ + private class ImageViewBitmapWorkerTask extends BitmapWorkerTask { + protected final WeakReference imageViewReference; + private LoadImageCallback callback; + + public ImageViewBitmapWorkerTask(ImageView imageView, LoadImageCallback callback) { + super(); + imageViewReference = new WeakReference<>(imageView); + this.callback = callback; + } + + /** + * Once the image is processed, associates it to the imageView + */ + @Override + protected void onPostExecute(Bitmap bitmap) { + // if cancel was called on this task or the "exit early" flag is set then we're done + if (isCancelled() || mExitTasksEarly) { + bitmap = null; + } + + final ImageView imageView = getAttachedImageView(); + if (bitmap != null && imageView != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onPostExecute - setting bitmap"); + } + setImageBitmap(imageView, bitmap); + if (callback != null) { + callback.onDone(); + } + } + } + + protected boolean shouldCancel() { + return super.shouldCancel() && getAttachedImageView() == null; + } + + /** + * Returns the ImageView associated with this task as long as the ImageView's task still + * points to this task as well. Returns null otherwise. + */ + protected ImageView getAttachedImageView() { + final ImageView imageView = imageViewReference.get(); + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (this == bitmapWorkerTask) { + return imageView; + } + + return null; + } + + } + + /** + * A specialisation of {@link BitmapWorkerTask} that passed the loaded bitmap to a callback + * for further processing. + */ + private class CallbackBitmapWorkerTask extends BitmapWorkerTask { + protected static final String TAG = "CallbackBitmapWorkerTas"; + + private ImageWorkerCallback mCallback; + + public CallbackBitmapWorkerTask(ImageWorkerCallback callback) { + mCallback = callback; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + Log.d(TAG, "callback: onPostExecute()"); + if (isCancelled() || shouldCancel()) { + bitmap = null; + } + + Log.d(TAG, "onPostExecute - setting bitmap"); + mCallback.process(data, bitmap); + } + + /** + * @return Always returns false, the processing is not aborted before the callback is called. + */ + @Override + protected boolean shouldCancel() { + return false; + } + } + + /** + * A specialisation of {@link BitmapWorkerTask} that sets the loaded bitmap in to an + * {@link ImageView} in a {@link RemoteViews} and posts a {@link Notification}. + */ + private class RemoteViewBitmapWorkerTask extends BitmapWorkerTask { + private RemoteViews mRemoteViews; + private int mViewId; + NotificationManagerCompat mNotificationManagerCompat; + int mNotificationId; + Notification mNotification; + + public RemoteViewBitmapWorkerTask(RemoteViews remoteViews, int viewId, + NotificationManagerCompat notificationManagerCompat, + int notificationId, Notification notification) { + super(); + mRemoteViews = remoteViews; + mViewId = viewId; + mNotificationManagerCompat = notificationManagerCompat; + mNotificationId = notificationId; + mNotification = notification; + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (bitmap != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "onPostExecute - setting bitmap"); + } + Log.d(TAG, "Setting notification bitmap"); + mRemoteViews.setImageViewBitmap(mViewId, bitmap); + } + + // Always post the notification. + mNotificationManagerCompat.notify(mNotificationId, mNotification); + } + + /** + * @return Always returns false, the processing is not to ensure the notification is posted. + */ + @Override + protected boolean shouldCancel() { + return false; + } + } + + /** + * A custom Drawable that will be attached to the imageView while the work is in progress. + * Contains a reference to the actual worker task, so that it can be stopped if a new binding is + * required, and makes sure that only the last started worker process can bind its result, + * independently of the finish order. + */ + private static class AsyncDrawable extends BitmapDrawable { + + private final WeakReference bitmapWorkerTaskReference; + + public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { + super(res, bitmap); + bitmapWorkerTaskReference = + new WeakReference<>(bitmapWorkerTask); + } + + public BitmapWorkerTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } + + /** + * Called when the processing is complete and the final bitmap should be set on the ImageView. + * + * @param imageView + * @param bitmap + */ + private void setImageBitmap(ImageView imageView, Bitmap bitmap) { + if (mFadeInBitmap) { + // Transition drawable between the pending image and the final bitmap. + final TransitionDrawable td = + new TransitionDrawable(new Drawable[]{ + imageView.getDrawable(), + new BitmapDrawable(mResources, bitmap) + }); + + imageView.setImageDrawable(td); + td.setCrossFadeEnabled(true); + td.startTransition(FADE_IN_TIME); + } else { + imageView.setImageDrawable(new BitmapDrawable(mResources, bitmap)); + } + } + + public void setPauseWork(boolean pauseWork) { + synchronized (mPauseWorkLock) { + mPauseWork = pauseWork; + if (!mPauseWork) { + mPauseWorkLock.notifyAll(); + } + } + } + + protected class CacheAsyncTask extends AsyncTask { + + @Override + protected Void doInBackground(Object... params) { + @CacheMessages int message = (Integer) params[0]; + switch (message) { + case MESSAGE_CLEAR: + clearCacheInternal(); + break; + case MESSAGE_INIT_DISK_CACHE: + initDiskCacheInternal(); + break; + case MESSAGE_FLUSH: + flushCacheInternal(); + break; + case MESSAGE_CLOSE: + closeCacheInternal(); + break; + case MESSAGE_CLEAR_MEMORY_CACHE: + clearMemoryCacheInternal(); + break; + } + return null; + } + } + + protected void initDiskCacheInternal() { + if (mImageCache != null) { + mImageCache.initDiskCache(); + } + } + + protected void clearCacheInternal() { + if (mImageCache != null) { + mImageCache.clearCache(); + } + } + + protected void clearMemoryCacheInternal() { + if (mImageCache != null) { + mImageCache.clearMemoryCache(); + } + } + + protected void flushCacheInternal() { + if (mImageCache != null) { + mImageCache.flush(); + } + } + + protected void closeCacheInternal() { + if (mImageCache != null) { + mImageCache.close(); + mImageCache = null; + } + } + + public void clearCache() { + new CacheAsyncTask().execute(MESSAGE_CLEAR); + } + + public void clearMemoryCache() { new CacheAsyncTask().execute(MESSAGE_CLEAR_MEMORY_CACHE); } + + public void flushCache() { + new CacheAsyncTask().execute(MESSAGE_FLUSH); + } + + public void closeCache() { + new CacheAsyncTask().execute(MESSAGE_CLOSE); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Intents.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Intents.java new file mode 100644 index 000000000..6ae466c96 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Intents.java @@ -0,0 +1,50 @@ +package uk.org.ngo.squeezer.util; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; + +import java.util.List; + +public class Intents { + + /** + * Indicates whether the specified action can be used as an intent. This method queries the + * package manager for installed packages that can respond to an intent with the specified + * action. If no suitable package is found, this method returns false. + * + * @param context The application's environment. + * @param action The Intent action to check for availability. + * + * @return True if an Intent with the specified action can be sent and responded to, false + * otherwise. + */ + public static boolean isIntentAvailable(Context context, String action) { + final PackageManager packageManager = context.getPackageManager(); + final Intent intent = new Intent(action); + List list = + packageManager.queryIntentActivities(intent, + PackageManager.MATCH_DEFAULT_ONLY); + return !list.isEmpty(); + } + + /** + * Indicates whether the specified action can be broadcast as an intent. This method queries the + * package manager for installed packages that can respond to a broadcats intent with the + * specified action. If no suitable package is found, this method returns false. + * + * @param context The application's environment. + * @param action The Intent action to check for availability. + * + * @return True if an Intent with the specified action can be sent and responded to, false + * otherwise. + */ + public static boolean isBroadcastReceiverAvailable(Context context, String action) { + final PackageManager packageManager = context.getPackageManager(); + final Intent intent = new Intent(action); + List list = + packageManager.queryBroadcastReceivers(intent, 0); + return !list.isEmpty(); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/NotificationUtil.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/NotificationUtil.java new file mode 100644 index 000000000..ba0b18263 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/NotificationUtil.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.org.ngo.squeezer.util; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +/** + * Simplifies common {@link Notification} tasks. + */ +public class NotificationUtil { + + /** + * Create notification channel for O (API 26) and above. Does nothing for pre-O (26) devices. + * + * @param context global information about an application environment + * @param channelId The id of the channel + * @param channelName The user-visible name of the channel + * @param channelDescription The user-visible description of the channel + */ + public static void createNotificationChannel( + Context context, + String channelId, + String channelName, + String channelDescription, + int channelImportance, + boolean channelEnableVibrate, + int channelLockscreenVisibility + ) { + + // NotificationChannels are required for Notifications on O (API 26) and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Initializes NotificationChannel. + NotificationChannel notificationChannel = + new NotificationChannel(channelId, channelName, channelImportance); + notificationChannel.setDescription(channelDescription); + notificationChannel.enableVibration(channelEnableVibrate); + notificationChannel.setLockscreenVisibility(channelLockscreenVisibility); + + // Adds NotificationChannel to system. Attempting to create an existing notification + // channel with its original values performs no operation, so it's safe to perform the + // below sequence. + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.createNotificationChannel(notificationChannel); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Reflection.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Reflection.java new file mode 100644 index 000000000..e4c22b681 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Reflection.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2011 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; + +/** + * Reflection utility methods + * + * @author kaa + */ +public class Reflection { + + /** + *

Return the actual type parameter of the supplied class for the type variable at the + * supplied position in the supplied base class or interface.

The method returns null if the + * class can't be resolved. See {@link #genericTypeResolver(Class, Class)} for details on what + * can't be resolved, and how to work around it. + * + * @param currentClass The current class which must extend or implement base + * @param base Generic base class or interface which type variable we wish to resolve. + * @param genericArgumentNumber The position of the type variable we are interested in. + * + * @return The actual type parameter at the supplied position in base as a class or + * null. + * + * @see #genericTypeResolver(Class, Class) + */ + public static Class getGenericClass(Class currentClass, + Class base, int genericArgumentNumber) { + Type[] genericTypes = genericTypeResolver(currentClass, base); + Type type = genericArgumentNumber < genericTypes.length + ? genericTypes[genericArgumentNumber] : null; + + if (type instanceof Class) { + return (Class) type; + } + return null; + } + + /** + *

Resolve actual type arguments of the supplied class for the type variables in the supplied + * generic base class or interface.

If the types can't be resolved an empty array is + * returned.

NOTE
This will only resolve generic parameters when they are + * declared in the class, it wont resolve instances of generic types. So for example: + *

+     * new LinkedList() cant'be resolved but
+     * new LinkedList(){} can. (Notice the subclassing of the generic
+     * collection)
+     * 
+ * + * @param currentClass The current class which must extend or implement base + * @param base Generic base class or interface which type variables we wish to resolve. + * + * @return Actual type arguments for base used in currentClass. + * + * @see #getGenericClass(Class, Class, int) + */ + public static Type[] genericTypeResolver(Class currentClass, + Class base) { + Type[] actualTypeArguments = null; + + while (currentClass != Object.class) { + if (currentClass.isAssignableFrom(base)) { + return (actualTypeArguments == null ? currentClass.getTypeParameters() + : actualTypeArguments); + } + + if (base.isInterface()) { + Type[] actualTypes = genericInterfaceResolver(currentClass, base, + actualTypeArguments); + if (actualTypes != null) { + return actualTypes; + } + } + + actualTypeArguments = mapTypeArguments(currentClass, + currentClass.getGenericSuperclass(), actualTypeArguments); + currentClass = currentClass.getSuperclass(); + } + + return new Type[0]; + } + + + /** + * Resolve actual type arguments of the supplied class for the type variables in the supplied + * generic interface. + * + * @param currentClass The current class which may implement base + * @param baseInterface Generic interface which type variables we wish to resolve. + * @param actualTypeArguments Resolved type arguments from parent + * + * @return Actual type arguments for baseInterface used in + * currentClass or null. + * + * @see #getGenericClass(Class, Class, int) + */ + private static Type[] genericInterfaceResolver(Class currentClass, + Class baseInterface, Type[] pActualTypeArguments) { + Class[] interfaces = currentClass.getInterfaces(); + Type[] genericInterfaces = currentClass.getGenericInterfaces(); + for (int ifno = 0; ifno < genericInterfaces.length; ifno++) { + Type[] actualTypeArguments = mapTypeArguments(currentClass, genericInterfaces[ifno], + pActualTypeArguments); + + if (genericInterfaces[ifno] instanceof ParameterizedType) { + if (baseInterface + .equals(((ParameterizedType) genericInterfaces[ifno]).getRawType())) { + return actualTypeArguments; + } + } + + Type[] resolvedTypes = genericInterfaceResolver(interfaces[ifno], baseInterface, + actualTypeArguments); + if (resolvedTypes != null) { + return resolvedTypes; + } + } + + return null; + } + + /** + * Map the resolved type arguments of the given class to the type parameters of the supplied + * superclass or direct interface. + * + * @param currentClass The class with the supplied resolved type arguments + * @param type Superclass or direct interface of currentClass + * @param actualTypeArguments Resolved type arguments of of currentClass + * + * @return The resolved type arguments mapped to the given superclass or direct interface + */ + private static Type[] mapTypeArguments(Class currentClass, Type type, + Type[] actualTypeArguments) { + if (type instanceof ParameterizedType) { + ParameterizedType pType = (ParameterizedType) type; + + if (actualTypeArguments == null) { + return pType.getActualTypeArguments(); + } + + TypeVariable[] typeParameters = currentClass.getTypeParameters(); + Type[] actualTypes = pType.getActualTypeArguments(); + Type[] newActualTypeArguments = new Type[actualTypes.length]; + for (int i = 0; i < actualTypes.length; i++) { + newActualTypeArguments[i] = actualTypes[i]; + for (int j = 0; j < typeParameters.length; j++) { + if (actualTypes[i].equals(typeParameters[j])) { + newActualTypeArguments[i] = actualTypeArguments[j]; + break; + } + } + } + return newActualTypeArguments; + + } else { + return null; + } + } + +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/RetainFragment.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/RetainFragment.java new file mode 100644 index 000000000..3978c7ef6 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/RetainFragment.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + +import android.os.Bundle; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import android.util.Log; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Provides a fragment that will be retained across the lifecycle of the activity that hosts it. + *

+ * Get an instance of this class by calling {@link #getInstance(String, FragmentManager)}, and place + * objects that should be persisted across the activity lifecycle using {@link #put(String, + * Object)}. Retrieve persisted objects with {@link #get(String)}. + */ +public class RetainFragment extends Fragment { + + private static final String TAG = RetainFragment.class.getName(); + + private final Map mHash = Collections.synchronizedMap(new HashMap()); + + /** + * Empty constructor as per the Fragment documentation + */ + public RetainFragment() { + } + + public static RetainFragment getInstance(String tag, FragmentManager fm) { + Log.d(TAG, "getInstance() for " + tag); + RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(tag); + if (fragment == null) { + Log.d(TAG, " Creating new instance"); + fragment = new RetainFragment(); + fm.beginTransaction().add(fragment, tag).commitAllowingStateLoss(); + } + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Make sure this Fragment is retained over a configuration change + setRetainInstance(true); + } + + public Object put(String key, Object value) { + mHash.put(key, value); + return value; + } + + public Object get(String key) { + return mHash.get(key); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ScanNetworkTask.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ScanNetworkTask.java new file mode 100644 index 000000000..676e34ac8 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ScanNetworkTask.java @@ -0,0 +1,219 @@ +package uk.org.ngo.squeezer.util; + +import android.content.Context; +import android.net.wifi.WifiManager; + +import androidx.annotation.NonNull; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import uk.org.ngo.squeezer.R; + +/** + * Scans the local network for servers. + */ +public class ScanNetworkTask implements Runnable { + private static final String TAG = ScanNetworkTask.class.getSimpleName(); + + private final ScanNetworkCallback callback; + private final WifiManager wm; + private final int defaultHttpPort; + private final Handler uiThreadHandler = new Handler(Looper.getMainLooper()); + private volatile boolean cancelled; + + /** + * Map server names to IP addresses. + */ + private final TreeMap mServerMap = new TreeMap<>(); + + /** + * UDP port to broadcast discovery requests to. + */ + private static final int DISCOVERY_PORT = 3483; + + /** + * Maximum time to wait between discovery attempts (ms). + */ + private static final int DISCOVERY_ATTEMPT_TIMEOUT = 1400; + + public ScanNetworkTask(Context context, ScanNetworkCallback callback) { + this.callback = callback; + wm = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + defaultHttpPort = context.getResources().getInteger(R.integer.DefaultHttpPort); + } + + /** + * Discover Squeeze servers on the local network. + *

+ * Do this by sending an UDP broadcasts to port 3483 and wait approximately + * DISCOVERY_ATTEMPT_TIMEOUT for responses. Squeeze servers are supposed to listen for this, + * and respond with a packet that starts 'E' and some information about the server in type, + * value pairs + *

+ * The server name is the section with the type "NAME". + * The http port is the section with the type "JSON". + *

+ * Map the name to an IP address:port and store in mServerMap for later use. + *

+ * See the Slim::Networking::Discovery module in Squeeze server for more details. + */ + @Override + public void run() { + WifiManager.WifiLock wifiLock; + DatagramSocket socket = null; + + // UDP broadcast data that causes Squeeze servers to reply. The + // format is 'e', followed by null-terminated tags that indicate the + // data to return. + // + // The Squeeze server uses the size of the request packet to + // determine the size of the response packet. + + byte[] request = { + 'e', // 'existence' ? + 'I', 'P', 'A', 'D', 0, // Include IP address + 'N', 'A', 'M', 'E', 0, // Include server name + 'J', 'S', 'O', 'N', 0, // Include server port + }; + byte[] data = new byte[512]; + System.arraycopy(request, 0, data, 0, request.length); + + wifiLock = wm.createWifiLock(TAG); + + // mServerMap.put("Dummy", "127.0.0.1"); + + Log.v(TAG, "Locking WiFi while scanning"); + wifiLock.acquire(); + + try { + InetAddress broadcastAddress = InetAddress.getByName("255.255.255.255"); + boolean timedOut; + socket = new DatagramSocket(); + DatagramPacket discoveryPacket = new DatagramPacket(data, data.length, + broadcastAddress, DISCOVERY_PORT); + + byte[] buf = new byte[512]; + DatagramPacket responsePacket = new DatagramPacket(buf, buf.length); + + socket.setSoTimeout(DISCOVERY_ATTEMPT_TIMEOUT); + socket.send(discoveryPacket); + timedOut = false; + while (!timedOut) { + if (cancelled) { + break; + } + try { + socket.receive(responsePacket); + if (buf[0] == (byte) 'E') { + Map discover = parseDiscover(responsePacket.getLength(), responsePacket.getData()); + + String name = discover.get("NAME"); + if (name != null) { + String host = responsePacket.getAddress().getHostAddress(); + String port = discover.containsKey("JSON") ? discover.get("JSON") : String.valueOf(defaultHttpPort); + mServerMap.put(name, host + ':' + port); + } + } + } catch (IOException e) { + timedOut = true; + } + } + + } catch (SocketException e) { + // new DatagramSocket(3483) + } catch (UnknownHostException e) { + // InetAddress.getByName() + Log.e(TAG, "UnknownHostException", e); + // TODO remote logging Util.crashlyticsLogException(e); + } catch (IOException e) { + // socket.send() + Log.e(TAG, "IOException", e); + // TODO remote logging Util.crashlyticsLogException(e); + } finally { + if (socket != null) { + socket.close(); + } + + Log.v(TAG, "Scanning complete, unlocking WiFi"); + wifiLock.release(); + } + + // For testing that multiple servers are handled correctly. + // mServerMap.put("Dummy", "127.0.0.1"); + uiThreadHandler.post(new Runnable() { + @Override + public void run() { + callback.onScanFinished(mServerMap); + } + }); + } + + /** + * Parse a Squeezeserver broadcast response. + *

+ * The response buffer consists of a literal 'E' followed by 1 or more packed tuples that + * follow the format {4-byte-type}{1-byte-length}{[length]-bytes-values}. + *

+ * See the code in the server's Slim/Networking/Discovery::gotTLVRequest() method for more + * details on how the response is constructed. + * + * @return A map with type, value from the response packet. May be empty if the response is + * truncated + */ + @VisibleForTesting + @NonNull + static Map parseDiscover(int packetLength, byte[] buffer) { + Map result = new HashMap<>(); + + int i = 1; + while (i < packetLength) { + // Check if the buffer is truncated by the server, and bail out if it is. + if (i + 5 > packetLength) { + break; + } + + // Extract type and skip over it + String type = new String(buffer, i, 4); + i += 4; + + // Read the length, and skip over it.& 0xff to it is an unsigned byte + int length = buffer[i++] & 0xFF; + + // Check if the buffer is truncated by the server, and bail out if it is. + if (i + length > packetLength) { + break; + } + + // Extract the value and skip over it. + String value = new String(buffer, i, length); + i += length; + + result.put(type, value); + } + + return result; + } + + public void cancel() { + cancelled = true; + callback.onScanFinished(mServerMap); + } + + public interface ScanNetworkCallback { + void onScanFinished(TreeMap mServerMap); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Scrobble.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Scrobble.java new file mode 100644 index 000000000..3581dc216 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/Scrobble.java @@ -0,0 +1,74 @@ +package uk.org.ngo.squeezer.util; + +import android.content.Context; +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.util.Log; + +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.Squeezer; +import uk.org.ngo.squeezer.model.CurrentPlaylistItem; +import uk.org.ngo.squeezer.model.PlayerState; + +public class Scrobble { + + public static boolean haveScrobbleDroid() { + return Intents.isBroadcastReceiverAvailable( + Squeezer.getContext(), + "net.jjc1138.android.scrobbler.action.MUSIC_STATUS"); + } + + public static boolean haveSls() { + return Intents.isBroadcastReceiverAvailable( + Squeezer.getContext(), + "com.adam.aslfms.notify.playstatechanged"); + } + + public static boolean canScrobble() { + return haveScrobbleDroid() || haveSls(); + } + + /** + * Conditionally broadcasts a scrobbling intent populated from a player state. + * + * @param context the context to use to fetch resources. + * @param playerState the player state to scrobble from. + */ + public static void scrobbleFromPlayerState(@NonNull Context context, @Nullable PlayerState playerState) { + if (playerState == null || !Scrobble.canScrobble()) + return; + + @PlayerState.PlayState String playStatus = playerState.getPlayStatus(); + CurrentPlaylistItem currentSong = playerState.getCurrentSong(); + + if (playStatus == null || currentSong == null) + return; + + Log.d("Scrobble", "Scrobbling, playing is: " + (PlayerState.PLAY_STATE_PLAY.equals(playStatus))); + Intent i = new Intent(); + + if (Scrobble.haveSls()) { + // http://code.google.com/p/a-simple-lastfm-scrobbler/wiki/Developers + i.setAction("com.adam.aslfms.notify.playstatechanged"); + i.putExtra("state", PlayerState.PLAY_STATE_PLAY.equals(playStatus) ? 0 : 2); + i.putExtra("app-name", context.getText(R.string.app_name)); + i.putExtra("app-package", "uk.org.ngo.squeezer"); + i.putExtra("track", currentSong.getName()); + i.putExtra("album", currentSong.getAlbum()); + i.putExtra("artist", currentSong.getArtist()); + i.putExtra("duration", playerState.getCurrentSongDuration()); + i.putExtra("source", "P"); + } else if (Scrobble.haveScrobbleDroid()) { + // http://code.google.com/p/scrobbledroid/wiki/DeveloperAPI + i.setAction("net.jjc1138.android.scrobbler.action.MUSIC_STATUS"); + i.putExtra("playing", PlayerState.PLAY_STATE_PLAY.equals(playStatus)); + i.putExtra("track", currentSong.getName()); + i.putExtra("album", currentSong.getAlbum()); + i.putExtra("artist", currentSong.getArtist()); + i.putExtra("secs", playerState.getCurrentSongDuration()); + i.putExtra("source", "P"); + } + context.sendBroadcast(i); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/SqueezePlayer.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/SqueezePlayer.java new file mode 100644 index 000000000..85276cb52 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/SqueezePlayer.java @@ -0,0 +1,108 @@ +package uk.org.ngo.squeezer.util; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import androidx.appcompat.app.AppCompatActivity; +import android.util.Log; + +import uk.org.ngo.squeezer.Preferences; + +public class SqueezePlayer extends Handler { + private static final int MSG_STOP = 0; + private static final int MSG_TIMEOUT = 1; + private static final long TIMEOUT_DELAY = 10 * 60 * 1000; // 10 minutes in milliseconds + + private static final String SQUEEZEPLAYER_PACKAGE = "de.bluegaspode.squeezeplayer"; + private static final String SQUEEZEPLAYER_SERVICE = "de.bluegaspode.squeezeplayer.playback.service.PlaybackService"; + private static final String HAS_SERVER_SETTINGS_EXTRA = "intentHasServerSettings"; + private static final String FORCE_SERVER_SETTINGS_EXTRA = "forceSettingsFromIntent"; + private static final String SERVER_URL_EXTRA = "serverURL"; + private static final String SERVER_NAME_EXTRA = "serverName"; + private static final String USER_NAME_EXTRA = "username"; + private static final String PASSWORD_EXTRA = "password"; + + private static final String TAG = "SqueezePlayer"; + + private final String serverUrl; + private final String serverName; + private final String username; + private final String password; + private final AppCompatActivity context; + + private SqueezePlayer(AppCompatActivity context, Preferences.ServerAddress serverAddress) { + this.context = context; + + Preferences preferences = new Preferences(context); + serverUrl = serverAddress.address(); + serverName = preferences.getServerName(serverAddress); + username = preferences.getUsername(serverAddress); + password = preferences.getPassword(serverAddress); + + Log.d(TAG, "startControllingSqueezePlayer"); + startControllingSqueezePlayer(); + } + + public static SqueezePlayer maybeStartControllingSqueezePlayer(AppCompatActivity context) { + Preferences preferences = new Preferences(context); + Preferences.ServerAddress serverAddress = preferences.getServerAddress(); + + if (hasSqueezePlayer(context) && preferences.controlSqueezePlayer(serverAddress)) { + return new SqueezePlayer(context, serverAddress); + } + + return null; + } + + private static boolean hasSqueezePlayer(Context context) { + final PackageManager packageManager = context.getPackageManager(); + Intent intent = packageManager.getLaunchIntentForPackage(SQUEEZEPLAYER_PACKAGE); + return (intent != null); + } + + private void startControllingSqueezePlayer() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(getSqueezePlayerIntent()); + } else { + context.startService(getSqueezePlayerIntent()); + } + removeMessages(MSG_TIMEOUT); + sendMessageDelayed(obtainMessage(MSG_TIMEOUT), TIMEOUT_DELAY); + } + + private Intent getSqueezePlayerIntent() { + final ComponentName component = new ComponentName(SQUEEZEPLAYER_PACKAGE, SQUEEZEPLAYER_SERVICE); + Intent intent = new Intent().setComponent(component); + if (serverUrl != null) { + intent.putExtra(FORCE_SERVER_SETTINGS_EXTRA, true); + intent.putExtra(HAS_SERVER_SETTINGS_EXTRA, true); + intent.putExtra(SERVER_URL_EXTRA, serverUrl); + intent.putExtra(SERVER_NAME_EXTRA, serverName); + if (username != null) + intent.putExtra(USER_NAME_EXTRA, username); + if (password != null) + intent.putExtra(PASSWORD_EXTRA, password); + } + return intent; + } + + public void stopControllingSqueezePlayer() { + sendEmptyMessage(MSG_STOP); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_STOP : + removeMessages(MSG_TIMEOUT); + break; + case MSG_TIMEOUT: + startControllingSqueezePlayer(); + break; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ThemeManager.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ThemeManager.java new file mode 100644 index 000000000..fd5390fcd --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/util/ThemeManager.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2014 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.util; + + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; + +import uk.org.ngo.squeezer.Preferences; +import uk.org.ngo.squeezer.R; +import uk.org.ngo.squeezer.framework.EnumWithText; + +/** + * Manage the user's choice of theme and ensure that activities respect it. + *

+ * Call {@link #onCreate(Activity)} from the activity's own {@code onCreate()} method to + * apply the theme when the activity starts. + *

+ * Call {@link #onResume(Activity)} from the activity's own {@code onResume()} method to + * ensure that any changes to the user's theme preference are handled. + */ +public class ThemeManager { + /** The current theme applied to the app. */ + @StyleRes + private int mCurrentTheme; + + /** Available themes. */ + public enum Theme implements EnumWithText { + LIGHT_DARKACTIONBAR(R.string.settings_theme_light_dark, R.style.AppTheme_Light_DarkActionBar), + DARK(R.string.settings_theme_dark, R.style.AppTheme); + + @StringRes private final int mLabelId; + @StyleRes public final int mThemeId; + + Theme(@StringRes int labelId, @StyleRes int themeId) { + mLabelId = labelId; + mThemeId = themeId; + } + + @Override + public String getText(Context context) { + return context.getString(mLabelId); + } + } + + /** + * Call this from each activity's onCreate() method before setContentView() or similar + * is called. + *

+ * Generally, this means immediately after calling {@code super.onCreate()}. + * + * @param activity The activity to be themed. + */ + public void onCreate(Activity activity) { + // Ensure the activity uses the correct theme. + mCurrentTheme = getThemePreference(activity); + activity.setTheme(mCurrentTheme); + } + + /** + * Call this from each activity's onResume() method before doing any other work to + * resume the activity. If the theme has changed since the activity was paused this + * method will restart the activity. + * + * @param activity The activity being themed. + */ + public void onResume(Activity activity) { + // Themes can only be applied before views are instantiated. If the current theme + // changed while this activity was paused (e.g., because the user went to the + // SettingsActivity and changed it) then restart this activity with the new theme. + if (mCurrentTheme != getThemePreference(activity)) { + Intent intent = activity.getIntent(); + activity.finish(); + activity.overridePendingTransition(0, 0); + activity.startActivity(intent); + activity.overridePendingTransition(0, 0); + } + } + + /** + * @return The application's default theme if the user did not choose one. + */ + public static Theme getDefaultTheme() { + return Theme.DARK; + } + + /** + * Retrieve the user's theme preference. + * + * @return A resource identifier for the user's chosen theme. + */ + private int getThemePreference(Activity activity) { + try { + Theme theme = Theme + .valueOf(new Preferences(activity).getTheme()); + return theme.mThemeId; + } catch (Exception e) { + return getDefaultTheme().mThemeId; + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/AnimationEndListener.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/AnimationEndListener.java new file mode 100644 index 000000000..5d26f24cf --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/AnimationEndListener.java @@ -0,0 +1,20 @@ +package uk.org.ngo.squeezer.widget; + +import android.view.animation.Animation; + +/** + * {@link Animation.AnimationListener} with default empty implementations of + * {@link Animation.AnimationListener#onAnimationRepeat(Animation)} and + * {@link Animation.AnimationListener#onAnimationStart(Animation)}. + *

+ * This is just for a more convenient syntax if you only need to override the end action. + */ +public abstract class AnimationEndListener implements Animation.AnimationListener { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/Croller.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/Croller.java new file mode 100644 index 000000000..ac647be9b --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/Croller.java @@ -0,0 +1,26 @@ +package uk.org.ngo.squeezer.widget; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; + +public class Croller extends com.sdsmdg.harjot.crollerTest.Croller { + + public Croller(Context context) { + super(context); + } + + public Croller(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public Croller(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onDraw(Canvas canvas) { + setIndicatorWidth((float) ((float) getWidth() / 64.0)); + super.onDraw(canvas); + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/OnSwipeListener.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/OnSwipeListener.java new file mode 100644 index 000000000..859b6f146 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/OnSwipeListener.java @@ -0,0 +1,38 @@ +package uk.org.ngo.squeezer.widget; + +import android.view.GestureDetector; +import android.view.MotionEvent; + +public class OnSwipeListener extends GestureDetector.SimpleOnGestureListener { + + public boolean onSwipeUp(){ + return false; + } + + public boolean onSwipeDown(){ + return false; + } + + public boolean onSwipeLeft(){ + return false; + } + + public boolean onSwipeRight(){ + return false; + } + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (Math.abs(velocityX) > Math.abs(velocityY)) { + return velocityX < 0 ? onSwipeLeft() : onSwipeRight(); + } else { + return velocityY < 0 ? onSwipeUp() : onSwipeDown(); + } + } + +} \ No newline at end of file diff --git a/src/com/danga/squeezer/RepeatingImageButton.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/RepeatingImageButton.java similarity index 69% rename from src/com/danga/squeezer/RepeatingImageButton.java rename to Squeezer/src/main/java/uk/org/ngo/squeezer/widget/RepeatingImageButton.java index a7dafc93f..e7f5fb744 100644 --- a/src/com/danga/squeezer/RepeatingImageButton.java +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/RepeatingImageButton.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008 The Android Open Source Project + * Copyright (c) 2008 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,27 +14,31 @@ * limitations under the License. */ -package com.danga.squeezer; +package uk.org.ngo.squeezer.widget; +import android.annotation.SuppressLint; import android.content.Context; import android.os.SystemClock; +import androidx.annotation.NonNull; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; -import android.widget.ImageButton; /** - * A button that will repeatedly call a 'listener' method - * as long as the button is pressed. + * A button that will repeatedly call a 'listener' method as long as the button is pressed. */ -public class RepeatingImageButton extends ImageButton { +@SuppressLint("NewApi") +public class RepeatingImageButton extends androidx.appcompat.widget.AppCompatImageButton { private long mStartTime; + private int mRepeatCount; + private RepeatListener mListener; + private long mInterval = 500; - + public RepeatingImageButton(Context context) { this(context, null); } @@ -43,23 +47,25 @@ public RepeatingImageButton(Context context, AttributeSet attrs) { this(context, attrs, android.R.attr.imageButtonStyle); } + @SuppressLint("NewApi") public RepeatingImageButton(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); setFocusable(true); setLongClickable(true); } - + /** - * Sets the listener to be called while the button is pressed and - * the interval in milliseconds with which it will be called. + * Sets the listener to be called while the button is pressed and the interval in milliseconds + * with which it will be called. + * * @param l The listener that will be called - * @param interval The interval in milliseconds for calls + * @param interval The interval in milliseconds for calls */ public void setRepeatListener(RepeatListener l, long interval) { mListener = l; mInterval = interval; } - + @Override public boolean performLongClick() { mStartTime = SystemClock.elapsedRealtime(); @@ -67,9 +73,9 @@ public boolean performLongClick() { post(mRepeater); return true; } - + @Override - public boolean onTouchEvent(MotionEvent event) { + public boolean onTouchEvent(@NonNull MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_UP) { // remove the repeater, but call the hook one more time removeCallbacks(mRepeater); @@ -84,19 +90,20 @@ public boolean onTouchEvent(MotionEvent event) { @Override public boolean onKeyUp(int keyCode, KeyEvent event) { switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_CENTER: - case KeyEvent.KEYCODE_ENTER: - // remove the repeater, but call the hook one more time - removeCallbacks(mRepeater); - if (mStartTime != 0) { - doRepeat(true); - mStartTime = 0; - } + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + // remove the repeater, but call the hook one more time + removeCallbacks(mRepeater); + if (mStartTime != 0) { + doRepeat(true); + mStartTime = 0; + } } return super.onKeyUp(keyCode, event); } - - private Runnable mRepeater = new Runnable() { + + private final Runnable mRepeater = new Runnable() { + @Override public void run() { doRepeat(false); if (isPressed()) { @@ -105,23 +112,24 @@ public void run() { } }; - private void doRepeat(boolean last) { + private void doRepeat(boolean last) { long now = SystemClock.elapsedRealtime(); if (mListener != null) { mListener.onRepeat(this, now - mStartTime, last ? -1 : mRepeatCount++); } } - + public interface RepeatListener { + /** - * This method will be called repeatedly at roughly the interval - * specified in setRepeatListener(), for as long as the button - * is pressed. + * This method will be called repeatedly at roughly the interval specified in + * setRepeatListener(), for as long as the button is pressed. + * * @param v The button as a View. * @param duration The number of milliseconds the button has been pressed so far. - * @param repeatcount The number of previous calls in this sequence. - * If this is going to be the last call in this sequence (i.e. the user - * just stopped pressing the button), the value will be -1. + * @param repeatcount The number of previous calls in this sequence. If this is going to be + * the last call in this sequence (i.e. the user just stopped pressing the button), the + * value will be -1. */ void onRepeat(View v, long duration, int repeatcount); } diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/SquareImageView.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/SquareImageView.java new file mode 100644 index 000000000..63537fe78 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/SquareImageView.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.widget; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; + +/** + * Sets both view dimensions to whichever of height and width are measured as being smaller, + * resulting in a square image. + */ +@SuppressLint("NewApi") +public class SquareImageView extends androidx.appcompat.widget.AppCompatImageView { + + private boolean mBlockLayout; + + @SuppressLint("NewApi") + public SquareImageView(Context context) { + super(context); + } + + @SuppressLint("NewApi") + public SquareImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @SuppressLint("NewApi") + public SquareImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + int width = getMeasuredWidth(); + int height = getMeasuredHeight(); + int widthWithoutPadding = width - getPaddingLeft() - getPaddingRight(); + int heightWithoutPadding = height - getPaddingTop() - getPaddingBottom(); + + int size = Math.min(heightWithoutPadding, widthWithoutPadding); + + setMeasuredDimension(size + getPaddingLeft() + getPaddingRight(), + size + getPaddingTop() + getPaddingBottom()); + } + + @Override + public void setImageDrawable(Drawable drawable) { + mBlockLayout = true; + super.setImageDrawable(drawable); + mBlockLayout = false; + } + + @Override + public void requestLayout() { + if (!mBlockLayout) { + super.requestLayout(); + } + } +} diff --git a/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/UndoBarController.java b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/UndoBarController.java new file mode 100644 index 000000000..826009664 --- /dev/null +++ b/Squeezer/src/main/java/uk/org/ngo/squeezer/widget/UndoBarController.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2014 Kurt Aaholst + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.org.ngo.squeezer.widget; + +import android.app.Activity; +import android.content.Context; +import android.os.Handler; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; + +import android.widget.LinearLayout; +import android.widget.TextView; + +import uk.org.ngo.squeezer.R; + +/** + * + * Controls a view which is a toast with an undo button. + *

+ * Use this for actions which which shall be undoable. The undo bar is hosted by an activity. When + * the undo bar is first requested (by calling + * {@link #show(Activity, int, UndoBarController.UndoListener)}) it inflates its view, and + * add this view to the activity. The visibility of this view is maintained by this class. You + * supply an {@link UndoBarController.UndoListener} when you request the undo bar, which is called + * when the undo button is pressed, or the undo bar goes away. The undo bar instance is shared + * between requests, when using the same activity. When a new request comes in, any existing + * listener is called, and the listener is replaced with the one in the new request. + *

+ * Activities which uses the undo bar, should call {@link #hide(Activity)} in their + * {@link Activity#onPause()} method. + */ +public class UndoBarController extends LinearLayout { + public static final int FADE_DURATION = 300; + public static final int UNDO_DURATION = 5000; + + private final View mUndoBar; + private final TextView mMessageView; + private final Handler mHideHandler = new Handler(); + private UndoListener mUndoListener; + + private final Runnable mHideRunnable = new Runnable() { + @Override + public void run() { + hideUndoBar(false, false); + } + }; + + private UndoBarController(final Context context, final AttributeSet attrs) { + super(context, attrs); + LayoutInflater.from(context).inflate(R.layout.undo_bar, this, true); + mMessageView = (TextView) findViewById(R.id.undobar_message); + mUndoBar = (View) mMessageView.getParent(); + TextView button = (TextView) findViewById(R.id.undobar_button); + button.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + hideUndoBar(false, true); + } + }); + + hideUndoBar(true, false); + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + if (event.getX() < mUndoBar.getLeft() || event.getY() < mUndoBar.getTop() + || event.getX() > mUndoBar.getRight() || event.getY() < mUndoBar.getBottom()) { + hideUndoBar(false, false); + } + } + return false; + } + + private void hideUndoBar(final boolean immediate, final boolean undoSelected) { + mHideHandler.removeCallbacks(mHideRunnable); + + if (mUndoListener != null && getVisibility() == View.VISIBLE) { + if (undoSelected) + mUndoListener.onUndo(); + else + mUndoListener.onDone(); + mUndoListener = null; + } + + clearAnimation(); + startAnimation(outAnimation(immediate)); + } + + /** + * Insert an ActionableToastBar onto an Activity + * + * @param activity Activity to hold this view + * @param message The message will be shown in left side in toast + * @param listener Callback + */ + private static void showUndoBar(final Activity activity, final CharSequence message, + final UndoListener listener) { + UndoBarController undo = UndoBarController.getView(activity); + if (undo == null) { + undo = new UndoBarController(activity, null); + ((ViewGroup) activity.findViewById(android.R.id.content)).addView(undo); + } else { + undo.hideUndoBar(true, false); + } + undo.mUndoListener = listener; + + undo.mMessageView.setText(message); + + undo.resetTimeout(); + undo.clearAnimation(); + undo.startAnimation(undo.inAnimation()); + } + + private static UndoBarController getView(final Activity activity) { + final View view = activity.findViewById(R.id.undobar); + UndoBarController undo = null; + if (view != null) { + undo = (UndoBarController) view.getParent(); + } + return undo; + } + + private Animation outAnimation(boolean immediate) { + final Animation animation = new AlphaAnimation(1F, 0F); + animation.setDuration(immediate ? 0 : FADE_DURATION); + animation.setAnimationListener(new AnimationEndListener() { + @Override + public void onAnimationEnd(Animation animation) { + mUndoBar.setVisibility(View.GONE); + } + }); + return animation; + } + + private Animation inAnimation() { + final Animation animation = new AlphaAnimation(0F, 1F); + animation.setDuration(FADE_DURATION); + animation.setAnimationListener(new AnimationEndListener() { + @Override + public void onAnimationEnd(Animation animation) { + mUndoBar.setVisibility(View.VISIBLE); + } + }); + return animation; + } + + + /** + * Show an undo bar + * + * @param activity Activity to hold this view + * @param message The message will be shown in left side in toast + * @param listener Callback + */ + public static void show(final Activity activity, final CharSequence message, + final UndoListener listener) { + showUndoBar(activity, message, listener); + } + + /** + * Show an undo bar + * + * @param activity Activity to hold this view + * @param message The message will be shown in left side in toast + * @param listener Callback + */ + public static void show(final Activity activity, final @StringRes int message, + final UndoListener listener) { + showUndoBar(activity, activity.getString(message), listener); + } + + /** + * Hide the {@link UndoBarController} + * + * @param activity Activity which hosts the {@link UndoBarController} + */ + public static void hide(final Activity activity) { + final UndoBarController v = UndoBarController.getView(activity); + if (v != null && v.getVisibility() == View.VISIBLE) v.hideUndoBar(false, false); + } + + /** Make the undo bar stay for the full duration from now */ + public void resetTimeout() { + mHideHandler.removeCallbacks(mHideRunnable); + mHideHandler.postDelayed(mHideRunnable, UNDO_DURATION); + } + + /** Callback interface for the undo bar */ + public interface UndoListener { + /** Called when the undo button is pressed */ + void onUndo(); + + /** Called when the undo bar goes away and undo has not been pressed */ + void onDone(); + } + +} \ No newline at end of file diff --git a/Squeezer/src/main/play/contact-email.txt b/Squeezer/src/main/play/contact-email.txt new file mode 100644 index 000000000..5e3263d0a --- /dev/null +++ b/Squeezer/src/main/play/contact-email.txt @@ -0,0 +1 @@ +android.squeezer@gmail.com diff --git a/Squeezer/src/main/play/contact-website.txt b/Squeezer/src/main/play/contact-website.txt new file mode 100644 index 000000000..261b19044 --- /dev/null +++ b/Squeezer/src/main/play/contact-website.txt @@ -0,0 +1 @@ +https://nikclayton.github.io/android-squeezer/ diff --git a/Squeezer/src/main/play/default-language.txt b/Squeezer/src/main/play/default-language.txt new file mode 100644 index 000000000..beb9970be --- /dev/null +++ b/Squeezer/src/main/play/default-language.txt @@ -0,0 +1 @@ +en-US diff --git a/Squeezer/src/main/play/listings/en-US/full-description.txt b/Squeezer/src/main/play/listings/en-US/full-description.txt new file mode 100644 index 000000000..93e568246 --- /dev/null +++ b/Squeezer/src/main/play/listings/en-US/full-description.txt @@ -0,0 +1,23 @@ +Remote control for your Logitech Media Server ("Squeezeserver" etc) and players. + +Features include: + +• Browse the library by album artist, all artists, composers, album, genre, year, playlist, favorites, or new music +• Playlist control (now playing, shuffle, repeat, save/clear/modify playlists) +• Manage players, and groups of players +• Full library search +• Internet radio support (browse, staff picks, search) +• Browse Squeezeserver music folders +• Supports Plugins/Apps +• Library Views and Remote Music Libraries +• Download of local music; track, album, artist, genre, year, playlist and music folder +• Automatic discovery of local servers +• Support for password protected servers +• Connect to mysqueezebox.com +• Control playback from your Android Wear device +• Home Screen Widgets +• Alarm Clock management +• Add/remove music from favorites +• Display lyrics + +Squeezer is free, and open source. diff --git a/Squeezer/src/main/play/listings/en-US/short-description.txt b/Squeezer/src/main/play/listings/en-US/short-description.txt new file mode 100644 index 000000000..276e59b7f --- /dev/null +++ b/Squeezer/src/main/play/listings/en-US/short-description.txt @@ -0,0 +1 @@ +Remote control for Logitech Media Server ("Squeezeserver" etc) and players. diff --git a/Squeezer/src/main/play/listings/en-US/title.txt b/Squeezer/src/main/play/listings/en-US/title.txt new file mode 100644 index 000000000..035ed35f5 --- /dev/null +++ b/Squeezer/src/main/play/listings/en-US/title.txt @@ -0,0 +1 @@ +Squeezer diff --git a/Squeezer/src/main/play/listings/en-US/video-url.txt b/Squeezer/src/main/play/listings/en-US/video-url.txt new file mode 100644 index 000000000..ae83b72c7 --- /dev/null +++ b/Squeezer/src/main/play/listings/en-US/video-url.txt @@ -0,0 +1 @@ +http://www.youtube.com/watch?v=NI9t8qnX5bo diff --git a/Squeezer/src/main/play/release-notes/en-US/beta.txt b/Squeezer/src/main/play/release-notes/en-US/beta.txt new file mode 100644 index 000000000..f0ecc5bb4 --- /dev/null +++ b/Squeezer/src/main/play/release-notes/en-US/beta.txt @@ -0,0 +1,11 @@ +• Current playlist control; Possibility to undo a clear current playlist. Remove a track by swiping left or right. Touch and hold to move a track + +• Download confirmation and option to disable downloads + +• Fix an issue with where Android 10 would falsely adds .mp3 to filenames + +• Work around an issue with recent spotty versions, where only one search result is displayed + +• Switch radio buttons properly + +• Bug and stability fixes \ No newline at end of file diff --git a/Squeezer/src/main/play/release-notes/en-US/production.txt b/Squeezer/src/main/play/release-notes/en-US/production.txt new file mode 100644 index 000000000..3584a24f9 --- /dev/null +++ b/Squeezer/src/main/play/release-notes/en-US/production.txt @@ -0,0 +1,11 @@ +• Current playlist control; Possibility to undo a clear current playlist. Remove a track by swiping left or right. Touch and hold to move a track + +• Download confirmation and option to disable downloads + +• Fix an issue with where Android 10 would falsely adds .mp3 to filenames + +• Work around an issue with recent Spotty versions, where only one search result is displayed + +• Switch radio buttons properly + +• Bug and stability fixes \ No newline at end of file diff --git a/Squeezer/src/main/res/anim/slide_in_up.xml b/Squeezer/src/main/res/anim/slide_in_up.xml new file mode 100644 index 000000000..0cef18922 --- /dev/null +++ b/Squeezer/src/main/res/anim/slide_in_up.xml @@ -0,0 +1,23 @@ + + + + diff --git a/Squeezer/src/main/res/anim/slide_out_down.xml b/Squeezer/src/main/res/anim/slide_out_down.xml new file mode 100644 index 000000000..8be793b95 --- /dev/null +++ b/Squeezer/src/main/res/anim/slide_out_down.xml @@ -0,0 +1,23 @@ + + + + diff --git a/Squeezer/src/main/res/drawable-hdpi/btn_repeat_all.png b/Squeezer/src/main/res/drawable-hdpi/btn_repeat_all.png new file mode 100644 index 000000000..8c241dae8 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/btn_repeat_all.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/btn_repeat_one.png b/Squeezer/src/main/res/drawable-hdpi/btn_repeat_one.png new file mode 100644 index 000000000..c67830413 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/btn_repeat_one.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_album.png b/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_album.png new file mode 100644 index 000000000..8d9cb8dc0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_album.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_song.png b/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_song.png new file mode 100644 index 000000000..194911d4d Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/btn_shuffle_song.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_add.png b/Squeezer/src/main/res/drawable-hdpi/ic_add.png new file mode 100644 index 000000000..751dd8669 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_add.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings.png b/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings.png new file mode 100644 index 000000000..d1acba16d Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings_light.png b/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings_light.png new file mode 100644 index 000000000..80d48b243 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_alarm_settings_light.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_baby.png b/Squeezer/src/main/res/drawable-hdpi/ic_baby.png new file mode 100644 index 000000000..028c1ff16 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_baby.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_blank.png b/Squeezer/src/main/res/drawable-hdpi/ic_blank.png new file mode 100644 index 000000000..e63713ed4 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_blank.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_boom.png b/Squeezer/src/main/res/drawable-hdpi/ic_boom.png new file mode 100644 index 000000000..b399baea6 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_boom.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_controller.png b/Squeezer/src/main/res/drawable-hdpi/ic_controller.png new file mode 100644 index 000000000..7aa0a94d5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_controller.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_delete.png b/Squeezer/src/main/res/drawable-hdpi/ic_delete.png new file mode 100644 index 000000000..8262aa452 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_delete.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_delete_light.png b/Squeezer/src/main/res/drawable-hdpi/ic_delete_light.png new file mode 100644 index 000000000..5c2d7707f Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_delete_light.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_fab4.png b/Squeezer/src/main/res/drawable-hdpi/ic_fab4.png new file mode 100644 index 000000000..b2e0f747a Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_fab4.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_launcher.png b/Squeezer/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 000000000..1ae86fbc0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_launcher_lastfm.png b/Squeezer/src/main/res/drawable-hdpi/ic_launcher_lastfm.png new file mode 100644 index 000000000..6acda3e12 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_launcher_lastfm.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_menu_back.png b/Squeezer/src/main/res/drawable-hdpi/ic_menu_back.png new file mode 100644 index 000000000..661a4ae92 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_menu_back.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_receiver.png b/Squeezer/src/main/res/drawable-hdpi/ic_receiver.png new file mode 100644 index 000000000..b40bb41c0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_receiver.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_sb1n2.png b/Squeezer/src/main/res/drawable-hdpi/ic_sb1n2.png new file mode 100644 index 000000000..080b506ee Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_sb1n2.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_sb3.png b/Squeezer/src/main/res/drawable-hdpi/ic_sb3.png new file mode 100644 index 000000000..38f22a28c Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_sb3.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_slimp3.png b/Squeezer/src/main/res/drawable-hdpi/ic_slimp3.png new file mode 100644 index 000000000..45fdd741b Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_slimp3.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_softsqueeze.png b/Squeezer/src/main/res/drawable-hdpi/ic_softsqueeze.png new file mode 100644 index 000000000..e17120151 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_softsqueeze.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplay.png b/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplay.png new file mode 100644 index 000000000..9ed4e5f4b Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplay.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplayer.png b/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplayer.png new file mode 100644 index 000000000..5765b89c4 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_squeezeplayer.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/ic_transporter.png b/Squeezer/src/main/res/drawable-hdpi/ic_transporter.png new file mode 100644 index 000000000..23df29ad9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/ic_transporter.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_alarm.png b/Squeezer/src/main/res/drawable-hdpi/icon_alarm.png new file mode 100644 index 000000000..f3d4927e7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_alarm.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_linein.png b/Squeezer/src/main/res/drawable-hdpi/icon_linein.png new file mode 100644 index 000000000..4b21ee897 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_linein.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_ml_artists.png b/Squeezer/src/main/res/drawable-hdpi/icon_ml_artists.png new file mode 100644 index 000000000..6b802654a Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_ml_artists.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_ml_genres.png b/Squeezer/src/main/res/drawable-hdpi/icon_ml_genres.png new file mode 100644 index 000000000..1f30aaef7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_ml_genres.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_ml_new_music.png b/Squeezer/src/main/res/drawable-hdpi/icon_ml_new_music.png new file mode 100644 index 000000000..dc4d762c4 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_ml_new_music.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_ml_years.png b/Squeezer/src/main/res/drawable-hdpi/icon_ml_years.png new file mode 100644 index 000000000..83b8f16ac Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_ml_years.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_mymusic.png b/Squeezer/src/main/res/drawable-hdpi/icon_mymusic.png new file mode 100644 index 000000000..e4e1edb31 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_mymusic.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_fwd.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_fwd.png new file mode 100644 index 000000000..2caacca1a Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_fwd.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_pause.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_pause.png new file mode 100644 index 000000000..ad7d59a08 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_pause.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_play.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_play.png new file mode 100644 index 000000000..95eda53be Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_play.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat.png new file mode 100644 index 000000000..03986b561 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_off.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_off.png new file mode 100644 index 000000000..1f5af83a3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_off.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_song.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_song.png new file mode 100644 index 000000000..7d0984186 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_repeat_song.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_rew.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_rew.png new file mode 100644 index 000000000..c4167cf81 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_rew.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle.png new file mode 100644 index 000000000..40ead5897 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_album.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_album.png new file mode 100644 index 000000000..bbe84a868 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_album.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_off.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_off.png new file mode 100644 index 000000000..1808ff536 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_shuffle_off.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_15.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_15.png new file mode 100644 index 000000000..37b6abcf0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_15.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_30.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_30.png new file mode 100644 index 000000000..463fd47ed Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_30.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_45.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_45.png new file mode 100644 index 000000000..56b9c8b45 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_45.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_60.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_60.png new file mode 100644 index 000000000..a403327f5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_60.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_90.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_90.png new file mode 100644 index 000000000..a6895559e Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_90.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_off.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_off.png new file mode 100644 index 000000000..0e8b90bd8 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_sleep_off.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_stop.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_stop.png new file mode 100644 index 000000000..84288d3b9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_stop.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_bar.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_bar.png new file mode 100644 index 000000000..850776b0d Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_bar.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_mute.png b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_mute.png new file mode 100644 index 000000000..93ad8580f Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_popup_box_volume_mute.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_settings.png b/Squeezer/src/main/res/drawable-hdpi/icon_settings.png new file mode 100644 index 000000000..25447896b Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_settings.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_settings_adv.png b/Squeezer/src/main/res/drawable-hdpi/icon_settings_adv.png new file mode 100644 index 000000000..0d4f8ea9a Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_settings_adv.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_settings_home.png b/Squeezer/src/main/res/drawable-hdpi/icon_settings_home.png new file mode 100644 index 000000000..777398750 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_settings_home.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/icon_settings_name.png b/Squeezer/src/main/res/drawable-hdpi/icon_settings_name.png new file mode 100644 index 000000000..d4b800e39 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/icon_settings_name.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/list_focused_holo.9.png b/Squeezer/src/main/res/drawable-hdpi/list_focused_holo.9.png new file mode 100644 index 000000000..555270842 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/list_focused_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/list_longpressed_holo.9.png b/Squeezer/src/main/res/drawable-hdpi/list_longpressed_holo.9.png new file mode 100644 index 000000000..4ea7afa00 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/list_longpressed_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/list_pressed_holo_dark.9.png b/Squeezer/src/main/res/drawable-hdpi/list_pressed_holo_dark.9.png new file mode 100644 index 000000000..5654cd694 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/list_pressed_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/list_selector_disabled_holo_dark.9.png b/Squeezer/src/main/res/drawable-hdpi/list_selector_disabled_holo_dark.9.png new file mode 100644 index 000000000..f6fd30dcd Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/list_selector_disabled_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-hdpi/squeezer_notification.png b/Squeezer/src/main/res/drawable-hdpi/squeezer_notification.png new file mode 100644 index 000000000..8f1ff8c17 Binary files /dev/null and b/Squeezer/src/main/res/drawable-hdpi/squeezer_notification.png differ diff --git a/Squeezer/src/main/res/drawable-ldpi/ic_launcher.png b/Squeezer/src/main/res/drawable-ldpi/ic_launcher.png new file mode 100644 index 000000000..db4901166 Binary files /dev/null and b/Squeezer/src/main/res/drawable-ldpi/ic_launcher.png differ diff --git a/Squeezer/src/main/res/drawable-ldpi/ic_launcher_lastfm.png b/Squeezer/src/main/res/drawable-ldpi/ic_launcher_lastfm.png new file mode 100644 index 000000000..c1b9fdb31 Binary files /dev/null and b/Squeezer/src/main/res/drawable-ldpi/ic_launcher_lastfm.png differ diff --git a/Squeezer/src/main/res/drawable-ldpi/ic_menu_back.png b/Squeezer/src/main/res/drawable-ldpi/ic_menu_back.png new file mode 100644 index 000000000..71eb533a7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-ldpi/ic_menu_back.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/btn_repeat_all.png b/Squeezer/src/main/res/drawable-mdpi/btn_repeat_all.png new file mode 100644 index 000000000..b528e54ae Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/btn_repeat_all.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/btn_repeat_one.png b/Squeezer/src/main/res/drawable-mdpi/btn_repeat_one.png new file mode 100644 index 000000000..c5d16591c Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/btn_repeat_one.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_album.png b/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_album.png new file mode 100644 index 000000000..cfd6eb21c Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_album.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_song.png b/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_song.png new file mode 100644 index 000000000..a0d6e6936 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/btn_shuffle_song.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_add.png b/Squeezer/src/main/res/drawable-mdpi/ic_add.png new file mode 100644 index 000000000..b7c194dbe Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_add.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings.png b/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings.png new file mode 100644 index 000000000..4fbe9114d Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings_light.png b/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings_light.png new file mode 100644 index 000000000..ac787d048 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_alarm_settings_light.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_baby.png b/Squeezer/src/main/res/drawable-mdpi/ic_baby.png new file mode 100644 index 000000000..e0bf166e1 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_baby.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_blank.png b/Squeezer/src/main/res/drawable-mdpi/ic_blank.png new file mode 100644 index 000000000..1c4b6a0dd Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_blank.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_boom.png b/Squeezer/src/main/res/drawable-mdpi/ic_boom.png new file mode 100644 index 000000000..7b8cb6ef5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_boom.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_controller.png b/Squeezer/src/main/res/drawable-mdpi/ic_controller.png new file mode 100644 index 000000000..030bcb801 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_controller.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_delete.png b/Squeezer/src/main/res/drawable-mdpi/ic_delete.png new file mode 100644 index 000000000..2fea20deb Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_delete.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_delete_light.png b/Squeezer/src/main/res/drawable-mdpi/ic_delete_light.png new file mode 100644 index 000000000..c4077e260 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_delete_light.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_fab4.png b/Squeezer/src/main/res/drawable-mdpi/ic_fab4.png new file mode 100644 index 000000000..f919f9990 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_fab4.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_launcher.png b/Squeezer/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 000000000..1407150f9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_launcher_lastfm.png b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_lastfm.png new file mode 100644 index 000000000..03871c8f6 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_lastfm.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_launcher_scrobbledroid.png b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_scrobbledroid.png new file mode 100644 index 000000000..18bf663d9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_scrobbledroid.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_launcher_sls.png b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_sls.png new file mode 100644 index 000000000..881a76452 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_launcher_sls.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_menu_back.png b/Squeezer/src/main/res/drawable-mdpi/ic_menu_back.png new file mode 100644 index 000000000..bb6924552 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_menu_back.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_receiver.png b/Squeezer/src/main/res/drawable-mdpi/ic_receiver.png new file mode 100644 index 000000000..04ed672b1 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_receiver.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_sb1n2.png b/Squeezer/src/main/res/drawable-mdpi/ic_sb1n2.png new file mode 100644 index 000000000..1590600d6 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_sb1n2.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_sb3.png b/Squeezer/src/main/res/drawable-mdpi/ic_sb3.png new file mode 100644 index 000000000..7d048c51c Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_sb3.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_slimp3.png b/Squeezer/src/main/res/drawable-mdpi/ic_slimp3.png new file mode 100644 index 000000000..1f2397b2b Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_slimp3.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_softsqueeze.png b/Squeezer/src/main/res/drawable-mdpi/ic_softsqueeze.png new file mode 100644 index 000000000..b1c962dd2 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_softsqueeze.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplay.png b/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplay.png new file mode 100644 index 000000000..f1bb3d1ed Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplay.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplayer.png b/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplayer.png new file mode 100644 index 000000000..d594023b0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_squeezeplayer.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/ic_transporter.png b/Squeezer/src/main/res/drawable-mdpi/ic_transporter.png new file mode 100644 index 000000000..74dbdd00f Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/ic_transporter.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_alarm.png b/Squeezer/src/main/res/drawable-mdpi/icon_alarm.png new file mode 100644 index 000000000..35a388772 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_alarm.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_album_noart_fullscreen.png b/Squeezer/src/main/res/drawable-mdpi/icon_album_noart_fullscreen.png new file mode 100644 index 000000000..cff90ea75 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_album_noart_fullscreen.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_ml_artists.png b/Squeezer/src/main/res/drawable-mdpi/icon_ml_artists.png new file mode 100644 index 000000000..47b78c0c3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_ml_artists.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_ml_genres.png b/Squeezer/src/main/res/drawable-mdpi/icon_ml_genres.png new file mode 100644 index 000000000..618dc7b4e Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_ml_genres.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_ml_new_music.png b/Squeezer/src/main/res/drawable-mdpi/icon_ml_new_music.png new file mode 100644 index 000000000..038e72287 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_ml_new_music.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_ml_years.png b/Squeezer/src/main/res/drawable-mdpi/icon_ml_years.png new file mode 100644 index 000000000..b1c325e56 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_ml_years.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_mymusic.png b/Squeezer/src/main/res/drawable-mdpi/icon_mymusic.png new file mode 100644 index 000000000..3cdbf0a59 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_mymusic.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_settings.png b/Squeezer/src/main/res/drawable-mdpi/icon_settings.png new file mode 100644 index 000000000..a183fc68a Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_settings.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_settings_adv.png b/Squeezer/src/main/res/drawable-mdpi/icon_settings_adv.png new file mode 100644 index 000000000..581c9967e Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_settings_adv.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_settings_home.png b/Squeezer/src/main/res/drawable-mdpi/icon_settings_home.png new file mode 100644 index 000000000..5b9bc629e Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_settings_home.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/icon_settings_name.png b/Squeezer/src/main/res/drawable-mdpi/icon_settings_name.png new file mode 100644 index 000000000..c15e17417 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/icon_settings_name.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/list_focused_holo.9.png b/Squeezer/src/main/res/drawable-mdpi/list_focused_holo.9.png new file mode 100644 index 000000000..00f05d8c9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/list_focused_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/list_longpressed_holo.9.png b/Squeezer/src/main/res/drawable-mdpi/list_longpressed_holo.9.png new file mode 100644 index 000000000..3bf8e0362 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/list_longpressed_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/list_pressed_holo_dark.9.png b/Squeezer/src/main/res/drawable-mdpi/list_pressed_holo_dark.9.png new file mode 100644 index 000000000..6e77525d2 Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/list_pressed_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/list_selector_disabled_holo_dark.9.png b/Squeezer/src/main/res/drawable-mdpi/list_selector_disabled_holo_dark.9.png new file mode 100644 index 000000000..92da2f0dd Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/list_selector_disabled_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-mdpi/squeezer_notification.png b/Squeezer/src/main/res/drawable-mdpi/squeezer_notification.png new file mode 100644 index 000000000..10fc26adc Binary files /dev/null and b/Squeezer/src/main/res/drawable-mdpi/squeezer_notification.png differ diff --git a/Squeezer/src/main/res/drawable-nodpi/squeezerremote_preview.png b/Squeezer/src/main/res/drawable-nodpi/squeezerremote_preview.png new file mode 100644 index 000000000..31b79e6f5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-nodpi/squeezerremote_preview.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_all.png b/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_all.png new file mode 100644 index 000000000..d42de7392 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_all.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_one.png b/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_one.png new file mode 100644 index 000000000..d6a0d164a Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/btn_repeat_one.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_album.png b/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_album.png new file mode 100644 index 000000000..90cff5728 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_album.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_song.png b/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_song.png new file mode 100644 index 000000000..ea944694e Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/btn_shuffle_song.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_add.png b/Squeezer/src/main/res/drawable-xhdpi/ic_add.png new file mode 100644 index 000000000..3d6845ac3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_add.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings.png b/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings.png new file mode 100644 index 000000000..a9682f1d3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings_light.png b/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings_light.png new file mode 100644 index 000000000..c2bd73721 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_alarm_settings_light.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_baby.png b/Squeezer/src/main/res/drawable-xhdpi/ic_baby.png new file mode 100644 index 000000000..3ef0f5de9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_baby.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_blank.png b/Squeezer/src/main/res/drawable-xhdpi/ic_blank.png new file mode 100644 index 000000000..3314707a0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_blank.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_boom.png b/Squeezer/src/main/res/drawable-xhdpi/ic_boom.png new file mode 100644 index 000000000..45126836f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_boom.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_controller.png b/Squeezer/src/main/res/drawable-xhdpi/ic_controller.png new file mode 100644 index 000000000..f7ddbe4f5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_controller.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_delete.png b/Squeezer/src/main/res/drawable-xhdpi/ic_delete.png new file mode 100644 index 000000000..63838869b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_delete.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_delete_light.png b/Squeezer/src/main/res/drawable-xhdpi/ic_delete_light.png new file mode 100644 index 000000000..3beaa3296 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_delete_light.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_fab4.png b/Squeezer/src/main/res/drawable-xhdpi/ic_fab4.png new file mode 100644 index 000000000..2fcd9900a Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_fab4.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_launcher.png b/Squeezer/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 000000000..faf7427ea Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_menu_back.png b/Squeezer/src/main/res/drawable-xhdpi/ic_menu_back.png new file mode 100644 index 000000000..8ac4f6498 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_menu_back.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_receiver.png b/Squeezer/src/main/res/drawable-xhdpi/ic_receiver.png new file mode 100644 index 000000000..652051ea6 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_receiver.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_sb1n2.png b/Squeezer/src/main/res/drawable-xhdpi/ic_sb1n2.png new file mode 100644 index 000000000..2bf8ba141 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_sb1n2.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_sb3.png b/Squeezer/src/main/res/drawable-xhdpi/ic_sb3.png new file mode 100644 index 000000000..90c6174cb Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_sb3.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_slimp3.png b/Squeezer/src/main/res/drawable-xhdpi/ic_slimp3.png new file mode 100644 index 000000000..fc64f0094 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_slimp3.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_softsqueeze.png b/Squeezer/src/main/res/drawable-xhdpi/ic_softsqueeze.png new file mode 100644 index 000000000..3846758c0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_softsqueeze.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplay.png b/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplay.png new file mode 100644 index 000000000..6e2f7eb8b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplay.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplayer.png b/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplayer.png new file mode 100644 index 000000000..752050d59 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_squeezeplayer.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/ic_transporter.png b/Squeezer/src/main/res/drawable-xhdpi/ic_transporter.png new file mode 100644 index 000000000..099b3e2be Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/ic_transporter.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_alarm.png b/Squeezer/src/main/res/drawable-xhdpi/icon_alarm.png new file mode 100644 index 000000000..8df5c7294 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_alarm.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_ml_artists.png b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_artists.png new file mode 100644 index 000000000..f7dd28039 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_artists.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_ml_genres.png b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_genres.png new file mode 100644 index 000000000..dc94f744b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_genres.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_ml_new_music.png b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_new_music.png new file mode 100644 index 000000000..3e0f39108 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_new_music.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_ml_years.png b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_years.png new file mode 100644 index 000000000..207cf8435 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_ml_years.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_mymusic.png b/Squeezer/src/main/res/drawable-xhdpi/icon_mymusic.png new file mode 100644 index 000000000..908df1e85 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_mymusic.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_settings.png b/Squeezer/src/main/res/drawable-xhdpi/icon_settings.png new file mode 100644 index 000000000..5264594df Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_settings.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_settings_adv.png b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_adv.png new file mode 100644 index 000000000..0d49998e7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_adv.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_settings_home.png b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_home.png new file mode 100644 index 000000000..fc7031af7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_home.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/icon_settings_name.png b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_name.png new file mode 100644 index 000000000..00df049e7 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/icon_settings_name.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/list_focused_holo.9.png b/Squeezer/src/main/res/drawable-xhdpi/list_focused_holo.9.png new file mode 100644 index 000000000..b545f8e57 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/list_focused_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/list_longpressed_holo.9.png b/Squeezer/src/main/res/drawable-xhdpi/list_longpressed_holo.9.png new file mode 100644 index 000000000..eda10e612 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/list_longpressed_holo.9.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/list_pressed_holo_dark.9.png b/Squeezer/src/main/res/drawable-xhdpi/list_pressed_holo_dark.9.png new file mode 100644 index 000000000..e4b33935a Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/list_pressed_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/list_selector_disabled_holo_dark.9.png b/Squeezer/src/main/res/drawable-xhdpi/list_selector_disabled_holo_dark.9.png new file mode 100644 index 000000000..88726b691 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/list_selector_disabled_holo_dark.9.png differ diff --git a/Squeezer/src/main/res/drawable-xhdpi/squeezer_notification.png b/Squeezer/src/main/res/drawable-xhdpi/squeezer_notification.png new file mode 100644 index 000000000..b15f269e4 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xhdpi/squeezer_notification.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_add.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_add.png new file mode 100644 index 000000000..1d880a722 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_add.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings.png new file mode 100644 index 000000000..0ab70bd33 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings_light.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings_light.png new file mode 100644 index 000000000..4d7007ea3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_alarm_settings_light.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_baby.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_baby.png new file mode 100644 index 000000000..695426db0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_baby.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_blank.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_blank.png new file mode 100644 index 000000000..4ff9e8b19 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_blank.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_boom.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_boom.png new file mode 100644 index 000000000..a8c7614b3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_boom.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_controller.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_controller.png new file mode 100644 index 000000000..f78d01d5a Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_controller.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_delete.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 000000000..89b0fed1f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_delete.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_delete_light.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_delete_light.png new file mode 100644 index 000000000..507c55222 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_delete_light.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_fab4.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_fab4.png new file mode 100644 index 000000000..03ab71229 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_fab4.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_launcher.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..3e285307f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_menu_back.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_menu_back.png new file mode 100644 index 000000000..d3191caff Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_menu_back.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_receiver.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_receiver.png new file mode 100644 index 000000000..e342bf60c Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_receiver.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_sb1n2.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_sb1n2.png new file mode 100644 index 000000000..3519c5dad Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_sb1n2.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_sb3.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_sb3.png new file mode 100644 index 000000000..c31cc2ec0 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_sb3.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_slimp3.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_slimp3.png new file mode 100644 index 000000000..52629c130 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_slimp3.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_softsqueeze.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_softsqueeze.png new file mode 100644 index 000000000..059bf6c7d Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_softsqueeze.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplay.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplay.png new file mode 100644 index 000000000..cd6dda3d8 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplay.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplayer.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplayer.png new file mode 100644 index 000000000..3a01da6b1 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_squeezeplayer.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/ic_transporter.png b/Squeezer/src/main/res/drawable-xxhdpi/ic_transporter.png new file mode 100644 index 000000000..d25a8c997 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/ic_transporter.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_alarm.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_alarm.png new file mode 100644 index 000000000..b5a48de29 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_alarm.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_artists.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_artists.png new file mode 100644 index 000000000..78c65e52b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_artists.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_genres.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_genres.png new file mode 100644 index 000000000..d6d2b25dc Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_genres.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_new_music.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_new_music.png new file mode 100644 index 000000000..40f4cf6a1 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_new_music.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_years.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_years.png new file mode 100644 index 000000000..b7d9aa886 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_ml_years.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_mymusic.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_mymusic.png new file mode 100644 index 000000000..18750ce7f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_mymusic.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_settings.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings.png new file mode 100644 index 000000000..a29181cdd Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_adv.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_adv.png new file mode 100644 index 000000000..f76f91c91 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_adv.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_home.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_home.png new file mode 100644 index 000000000..4645d71e1 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_home.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_name.png b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_name.png new file mode 100644 index 000000000..bc530348f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/icon_settings_name.png differ diff --git a/Squeezer/src/main/res/drawable-xxhdpi/squeezer_notification.png b/Squeezer/src/main/res/drawable-xxhdpi/squeezer_notification.png new file mode 100644 index 000000000..958e550b5 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxhdpi/squeezer_notification.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_alarm.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_alarm.png new file mode 100644 index 000000000..e5787d01b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_alarm.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_artists.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_artists.png new file mode 100644 index 000000000..931aeec5f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_artists.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_genres.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_genres.png new file mode 100644 index 000000000..69ebdbe3f Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_genres.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_new_music.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_new_music.png new file mode 100644 index 000000000..f563f29f3 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_new_music.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_years.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_years.png new file mode 100644 index 000000000..3f06232f4 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_ml_years.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_mymusic.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_mymusic.png new file mode 100644 index 000000000..f5991f7c9 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_mymusic.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings.png new file mode 100644 index 000000000..41a207d9c Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_adv.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_adv.png new file mode 100644 index 000000000..f71bd334b Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_adv.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_home.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_home.png new file mode 100644 index 000000000..9591a5232 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_home.png differ diff --git a/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_name.png b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_name.png new file mode 100644 index 000000000..f8f8f6554 Binary files /dev/null and b/Squeezer/src/main/res/drawable-xxxhdpi/icon_settings_name.png differ diff --git a/Squeezer/src/main/res/drawable/add.xml b/Squeezer/src/main/res/drawable/add.xml new file mode 100644 index 000000000..d05260850 --- /dev/null +++ b/Squeezer/src/main/res/drawable/add.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/btn_repeat.xml b/Squeezer/src/main/res/drawable/btn_repeat.xml new file mode 100644 index 000000000..0aa5c2af4 --- /dev/null +++ b/Squeezer/src/main/res/drawable/btn_repeat.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/btn_shuffle.xml b/Squeezer/src/main/res/drawable/btn_shuffle.xml new file mode 100644 index 000000000..11b3104ee --- /dev/null +++ b/Squeezer/src/main/res/drawable/btn_shuffle.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/favorites.xml b/Squeezer/src/main/res/drawable/favorites.xml new file mode 100644 index 000000000..de330ee1b --- /dev/null +++ b/Squeezer/src/main/res/drawable/favorites.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_close.xml b/Squeezer/src/main/res/drawable/ic_action_close.xml new file mode 100644 index 000000000..ce1bc3cf4 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_close.xml @@ -0,0 +1,5 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_disconnect.xml b/Squeezer/src/main/res/drawable/ic_action_disconnect.xml new file mode 100644 index 000000000..e200e41b6 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_disconnect.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_down.xml b/Squeezer/src/main/res/drawable/ic_action_down.xml new file mode 100644 index 000000000..203eeb364 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_down.xml @@ -0,0 +1,5 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_home.xml b/Squeezer/src/main/res/drawable/ic_action_home.xml new file mode 100644 index 000000000..83b756a4c --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_home.xml @@ -0,0 +1,5 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_next.xml b/Squeezer/src/main/res/drawable/ic_action_next.xml new file mode 100644 index 000000000..383f8589a --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_nowplaying.xml b/Squeezer/src/main/res/drawable/ic_action_nowplaying.xml new file mode 100644 index 000000000..d12e312c3 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_nowplaying.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_pause.xml b/Squeezer/src/main/res/drawable/ic_action_pause.xml new file mode 100644 index 000000000..ef74c0aed --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_play.xml b/Squeezer/src/main/res/drawable/ic_action_play.xml new file mode 100644 index 000000000..926e4914b --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_playlist.xml b/Squeezer/src/main/res/drawable/ic_action_playlist.xml new file mode 100644 index 000000000..78aeb3c30 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_power_settings_new.xml b/Squeezer/src/main/res/drawable/ic_action_power_settings_new.xml new file mode 100644 index 000000000..d0902d8d6 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_power_settings_new.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/ic_action_previous.xml b/Squeezer/src/main/res/drawable/ic_action_previous.xml new file mode 100644 index 000000000..8c4815b83 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_view_as_grid.xml b/Squeezer/src/main/res/drawable/ic_action_view_as_grid.xml new file mode 100644 index 000000000..a3f4b1dbb --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_view_as_grid.xml @@ -0,0 +1,10 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_action_view_as_list.xml b/Squeezer/src/main/res/drawable/ic_action_view_as_list.xml new file mode 100644 index 000000000..3848ae537 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_action_view_as_list.xml @@ -0,0 +1,10 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_home.xml b/Squeezer/src/main/res/drawable/ic_home.xml new file mode 100644 index 000000000..839b7b829 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_home.xml @@ -0,0 +1,5 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_menu_alarm.xml b/Squeezer/src/main/res/drawable/ic_menu_alarm.xml new file mode 100644 index 000000000..672626402 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_alarm.xml @@ -0,0 +1,4 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_menu_choose_player.xml b/Squeezer/src/main/res/drawable/ic_menu_choose_player.xml new file mode 100644 index 000000000..438fb06dd --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_choose_player.xml @@ -0,0 +1,4 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_menu_overflow.xml b/Squeezer/src/main/res/drawable/ic_menu_overflow.xml new file mode 100644 index 000000000..463e48265 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_overflow.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/ic_menu_playlist_clear.xml b/Squeezer/src/main/res/drawable/ic_menu_playlist_clear.xml new file mode 100644 index 000000000..8628755f0 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_playlist_clear.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ic_menu_playlist_save.xml b/Squeezer/src/main/res/drawable/ic_menu_playlist_save.xml new file mode 100644 index 000000000..d9f9b871f --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_playlist_save.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ic_menu_search.xml b/Squeezer/src/main/res/drawable/ic_menu_search.xml new file mode 100644 index 000000000..61a520c3e --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_menu_search.xml @@ -0,0 +1,8 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_refresh.xml b/Squeezer/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..e50ae58c5 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,8 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_undobar_undo.xml b/Squeezer/src/main/res/drawable/ic_undobar_undo.xml new file mode 100644 index 000000000..d8dec46a3 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_undobar_undo.xml @@ -0,0 +1,9 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_volume_down.xml b/Squeezer/src/main/res/drawable/ic_volume_down.xml new file mode 100644 index 000000000..020383c5f --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_volume_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/Squeezer/src/main/res/drawable/ic_volume_up.xml b/Squeezer/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000..c511edf4a --- /dev/null +++ b/Squeezer/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/Squeezer/src/main/res/drawable/icon_background.xml b/Squeezer/src/main/res/drawable/icon_background.xml new file mode 100644 index 000000000..337b9c225 --- /dev/null +++ b/Squeezer/src/main/res/drawable/icon_background.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/icon_pending_artwork.9.png b/Squeezer/src/main/res/drawable/icon_pending_artwork.9.png new file mode 100644 index 000000000..a6542770d Binary files /dev/null and b/Squeezer/src/main/res/drawable/icon_pending_artwork.9.png differ diff --git a/Squeezer/src/main/res/drawable/icon_popup_favorite.xml b/Squeezer/src/main/res/drawable/icon_popup_favorite.xml new file mode 100644 index 000000000..4c11ffd12 --- /dev/null +++ b/Squeezer/src/main/res/drawable/icon_popup_favorite.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/internet_radio.xml b/Squeezer/src/main/res/drawable/internet_radio.xml new file mode 100644 index 000000000..83087bf35 --- /dev/null +++ b/Squeezer/src/main/res/drawable/internet_radio.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/item_background.xml b/Squeezer/src/main/res/drawable/item_background.xml new file mode 100644 index 000000000..488e6d715 --- /dev/null +++ b/Squeezer/src/main/res/drawable/item_background.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/item_background_light.xml b/Squeezer/src/main/res/drawable/item_background_light.xml new file mode 100644 index 000000000..f39e26aa1 --- /dev/null +++ b/Squeezer/src/main/res/drawable/item_background_light.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/keyboard_return.xml b/Squeezer/src/main/res/drawable/keyboard_return.xml new file mode 100644 index 000000000..ce2692c71 --- /dev/null +++ b/Squeezer/src/main/res/drawable/keyboard_return.xml @@ -0,0 +1,8 @@ + + + diff --git a/Squeezer/src/main/res/drawable/list_selector_background_transition_holo_dark.xml b/Squeezer/src/main/res/drawable/list_selector_background_transition_holo_dark.xml new file mode 100644 index 000000000..d5c765af6 --- /dev/null +++ b/Squeezer/src/main/res/drawable/list_selector_background_transition_holo_dark.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/Squeezer/src/main/res/drawable/list_selector_holo_dark.xml b/Squeezer/src/main/res/drawable/list_selector_holo_dark.xml new file mode 100644 index 000000000..a4de566f7 --- /dev/null +++ b/Squeezer/src/main/res/drawable/list_selector_holo_dark.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/main_button_light.xml b/Squeezer/src/main/res/drawable/main_button_light.xml new file mode 100644 index 000000000..6b30c9d42 --- /dev/null +++ b/Squeezer/src/main/res/drawable/main_button_light.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/main_button_normal.xml b/Squeezer/src/main/res/drawable/main_button_normal.xml new file mode 100644 index 000000000..6eb3a0e85 --- /dev/null +++ b/Squeezer/src/main/res/drawable/main_button_normal.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ml_albums.xml b/Squeezer/src/main/res/drawable/ml_albums.xml new file mode 100644 index 000000000..c96d0da48 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ml_albums.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ml_folder.xml b/Squeezer/src/main/res/drawable/ml_folder.xml new file mode 100644 index 000000000..ec060a88c --- /dev/null +++ b/Squeezer/src/main/res/drawable/ml_folder.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ml_playlist.xml b/Squeezer/src/main/res/drawable/ml_playlist.xml new file mode 100644 index 000000000..c66811e70 --- /dev/null +++ b/Squeezer/src/main/res/drawable/ml_playlist.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/ml_random.xml b/Squeezer/src/main/res/drawable/ml_random.xml new file mode 100644 index 000000000..f0ba2111d --- /dev/null +++ b/Squeezer/src/main/res/drawable/ml_random.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/notification_button.xml b/Squeezer/src/main/res/drawable/notification_button.xml new file mode 100644 index 000000000..aa41cf42b --- /dev/null +++ b/Squeezer/src/main/res/drawable/notification_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/panel_background.xml b/Squeezer/src/main/res/drawable/panel_background.xml new file mode 100644 index 000000000..76d14962d --- /dev/null +++ b/Squeezer/src/main/res/drawable/panel_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/Squeezer/src/main/res/drawable/power.xml b/Squeezer/src/main/res/drawable/power.xml new file mode 100644 index 000000000..b0fe98e91 --- /dev/null +++ b/Squeezer/src/main/res/drawable/power.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/search.xml b/Squeezer/src/main/res/drawable/search.xml new file mode 100644 index 000000000..e71c6d081 --- /dev/null +++ b/Squeezer/src/main/res/drawable/search.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/settings_audio.xml b/Squeezer/src/main/res/drawable/settings_audio.xml new file mode 100644 index 000000000..f3d251d0c --- /dev/null +++ b/Squeezer/src/main/res/drawable/settings_audio.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/settings_repeat.xml b/Squeezer/src/main/res/drawable/settings_repeat.xml new file mode 100644 index 000000000..cf84592bd --- /dev/null +++ b/Squeezer/src/main/res/drawable/settings_repeat.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/settings_sleep.xml b/Squeezer/src/main/res/drawable/settings_sleep.xml new file mode 100644 index 000000000..4aee46125 --- /dev/null +++ b/Squeezer/src/main/res/drawable/settings_sleep.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/settings_sync.xml b/Squeezer/src/main/res/drawable/settings_sync.xml new file mode 100644 index 000000000..6272a7a67 --- /dev/null +++ b/Squeezer/src/main/res/drawable/settings_sync.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/shuffle.xml b/Squeezer/src/main/res/drawable/shuffle.xml new file mode 100644 index 000000000..eee327a7f --- /dev/null +++ b/Squeezer/src/main/res/drawable/shuffle.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/thin_progress_bar.xml b/Squeezer/src/main/res/drawable/thin_progress_bar.xml new file mode 100644 index 000000000..fe5f5c426 --- /dev/null +++ b/Squeezer/src/main/res/drawable/thin_progress_bar.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Squeezer/src/main/res/drawable/toast_background.xml b/Squeezer/src/main/res/drawable/toast_background.xml new file mode 100644 index 000000000..e63038972 --- /dev/null +++ b/Squeezer/src/main/res/drawable/toast_background.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/Squeezer/src/main/res/drawable/underline.xml b/Squeezer/src/main/res/drawable/underline.xml new file mode 100644 index 000000000..e97898815 --- /dev/null +++ b/Squeezer/src/main/res/drawable/underline.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Squeezer/src/main/res/layout-land/now_playing_fragment_full.xml b/Squeezer/src/main/res/layout-land/now_playing_fragment_full.xml new file mode 100644 index 000000000..cecc02550 --- /dev/null +++ b/Squeezer/src/main/res/layout-land/now_playing_fragment_full.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +