├── .gitignore
├── LICENSE
├── README.md
├── build.gradle
├── fastlane
└── metadata
│ └── android
│ └── en-US
│ ├── changelogs
│ ├── 100.txt
│ ├── 101.txt
│ ├── 102.txt
│ ├── 103.txt
│ ├── 104.txt
│ ├── 105.txt
│ ├── 2.txt
│ ├── 200.txt
│ ├── 201.txt
│ ├── 3.txt
│ ├── 300.txt
│ ├── 301.txt
│ ├── 4.txt
│ ├── 400.txt
│ ├── 401.txt
│ ├── 402.txt
│ ├── 5.txt
│ ├── 500.txt
│ ├── 6.txt
│ ├── 600.txt
│ ├── 601.txt
│ ├── 602.txt
│ ├── 603.txt
│ ├── 7.txt
│ ├── 700.txt
│ ├── 8.txt
│ ├── 800.txt
│ ├── 801.txt
│ ├── 802.txt
│ ├── 803.txt
│ ├── 804.txt
│ └── 805.txt
│ ├── full_description.txt
│ ├── images
│ ├── icon.png
│ └── phoneScreenshots
│ │ ├── browse.png
│ │ ├── now-playing.png
│ │ └── queue.png
│ └── short_description.txt
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── lms-material
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── craigd
│ │ └── lmsmaterial
│ │ └── app
│ │ └── ExampleInstrumentedTest.java
│ ├── main
│ ├── AndroidManifest.xml
│ ├── ic_launcher-playstore.png
│ ├── java
│ │ └── com
│ │ │ └── craigd
│ │ │ └── lmsmaterial
│ │ │ └── app
│ │ │ ├── ControlService.java
│ │ │ ├── DownloadService.java
│ │ │ ├── DownloadStatusReceiver.java
│ │ │ ├── JsonRpc.java
│ │ │ ├── LocalPlayer.java
│ │ │ ├── MainActivity.java
│ │ │ ├── PhoneStateHandler.java
│ │ │ ├── ServerDiscovery.java
│ │ │ ├── SettingsActivity.java
│ │ │ ├── TermuxResultsService.java
│ │ │ ├── UrlHandler.java
│ │ │ ├── Utils.java
│ │ │ └── cometd
│ │ │ ├── BayeuxExtension.java
│ │ │ ├── CometClient.java
│ │ │ ├── ConnectionState.java
│ │ │ ├── HttpStreamingTransport.java
│ │ │ ├── PlayerStatus.java
│ │ │ └── SlimClient.java
│ └── res
│ │ ├── color
│ │ ├── switch_thumb_color.xml
│ │ ├── switch_thumb_color_light.xml
│ │ ├── switch_track_color.xml
│ │ ├── switch_track_decoration_color.xml
│ │ └── switch_track_decoration_color_light.xml
│ │ ├── drawable
│ │ ├── ic_action_quit.xml
│ │ ├── ic_action_quit_light.xml
│ │ ├── ic_download.xml
│ │ ├── ic_launcher_background.xml
│ │ ├── ic_launcher_foreground.xml
│ │ ├── ic_mono_icon.xml
│ │ ├── ic_next.xml
│ │ ├── ic_pause.xml
│ │ ├── ic_play.xml
│ │ ├── ic_prev.xml
│ │ └── notification_image.png
│ │ ├── layout
│ │ ├── activity_main.xml
│ │ ├── auth_prompt.xml
│ │ ├── preference_switch.xml
│ │ ├── preference_switch_light.xml
│ │ ├── settings_activity.xml
│ │ └── url_handler.xml
│ │ ├── menu
│ │ ├── right_menu.xml
│ │ └── right_menu_light.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.webp
│ │ └── ic_launcher_round.webp
│ │ ├── values-fr
│ │ ├── arrays.xml
│ │ └── strings.xml
│ │ ├── values-ru
│ │ ├── arrays.xml
│ │ └── strings.xml
│ │ ├── values-uk
│ │ ├── arrays.xml
│ │ └── strings.xml
│ │ ├── values-zh
│ │ ├── arrays.xml
│ │ └── strings.xml
│ │ ├── values
│ │ ├── arrays.xml
│ │ ├── colors.xml
│ │ ├── dimens.xml
│ │ ├── strings.xml
│ │ └── styles.xml
│ │ └── xml
│ │ ├── data_extraction_rules.xml
│ │ └── root_preferences.xml
│ └── test
│ └── java
│ └── com
│ └── craigd
│ └── lmsmaterial
│ └── app
│ └── ExampleUnitTest.java
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | *.jks
3 | .gradle
4 | /local.properties
5 | .idea
6 | .DS_Store
7 | /build
8 | /captures
9 | .externalNativeBuild
10 | .cxx
11 | lms-material/debug/
12 | lms-material/release/
13 | keystore.properties
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 | This application *requires* that you run LMS and have the Material-Skin plugin
3 | installed. However, it is not required for Material to be set as the default
4 | skin.
5 |
6 | This app is based upon https://github.com/andreasbehnke/lms-material-app
7 |
8 | [
](https://f-droid.org/packages/com.craigd.lmsmaterial.app/)
11 |
12 | # Building and signing the app
13 |
14 | You can build the app using your own signing key. Only signed apk files can be
15 | installed by downloading, so the signing process is required.
16 |
17 | Read this documentation for details: https://developer.android.com/studio/publish/app-signing
18 |
19 | * create a jsk keystore and a key for signing app:
20 | ```
21 | keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
22 | ```
23 | * create file keystore.properties:
24 | ```
25 | storePassword=myStorePassword
26 | keyPassword=mykeyPassword
27 | keyAlias=my-alias
28 | storeFile=my-release-key.jks
29 | ```
30 | * secure this file:
31 | ```
32 | chmod 600 keystore.properties
33 | ```
34 | * build release apk:
35 | ```
36 | ./gradlew assembleRelease
37 | ```
38 | * move release artifact to your phone:
39 | ```
40 | /lms-material-app/lms-material/build/outputs/apk/release/lms-material-release.apk
41 | ```
42 |
43 | # Donations
44 |
45 | I develop this skin purely for fun, so no donations are required. However, seeing as I have been asked about this a few times, here is a link...
46 |
47 | [](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=2X2CTDUH27V9L&source=url)
48 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | repositories {
5 | google()
6 | mavenCentral()
7 | }
8 | dependencies {
9 | classpath 'com.android.tools.build:gradle:8.4.2'
10 |
11 | // NOTE: Do not place your application dependencies here; they belong
12 | // in the individual module build.gradle files
13 | }
14 | }
15 |
16 | allprojects {
17 | repositories {
18 | google()
19 | mavenCentral()
20 | }
21 | }
22 |
23 | task clean(type: Delete) {
24 | delete rootProject.buildDir
25 | }
26 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/100.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added 'Orientation' setting.
4 | • Added 'Enable WiFi' setting.
5 | • Added 'During phone call' setting to pause, or mute, active player, or all players.
6 | • Added 'Show notification' setting to show current player name in nofification, and display basic media controls (these do not reflect state).
7 | • Register to receive links via 'Share Link'
8 |
9 | Fixed
10 |
11 | • Update correct 'summary' field when auto-discover server.
12 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/101.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Send correct command when 'play' is pressed on notification.
4 | • Fix re-opening 'Application settings' if these were previously open when app put into background.
5 | • Open '/updateinfo.html' in user's browser.
6 | • Fix notification, and action, icon colors on older Android.
7 | • Register for 'share' events from YouTube app.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/102.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added support for servers requiring authentication.
4 |
5 | Fixed
6 |
7 | • Only open '/material' links in this app, open others in user's browser.
8 | • When set to pause or mute players during calls, get list of powered on and playing players and only control those.
9 | • When set to pause or mute players during calls, only perform action if webview was acive within 5 seconds or notification is shown.
10 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/103.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Pass all URLs (apart from settings, quit, and launch player) onto user's browser.
4 |
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/104.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Add ability to decrease zoom, not just increase.
4 | • Add option to control whether application is shown over lock screen or not.
5 |
6 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/105.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Incrementally change volume when press-and-hold hardware buttons.
4 | • Add 'Default player' setting
5 | • Add 'Only control default' setting
6 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/2.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Wait for 5 seconds for URL to load. If an invalid server address is entered then this will timeout.
4 | • Auto-detect LMS server.
5 | • Add button in settings dialog to detect other servers. First one found that is not the current server is used.
6 | • Show server name and address in settings dialog.
7 |
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/200.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added download support (requires Material Skin 2.6.0, or newer).
4 |
5 | Fixed
6 |
7 | • Show default player name / ID in preferences if set.
8 | • Use Uri.Builder to construct connection URL.
9 | • Fix stopping of notification service.
10 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/201.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Don't use WebView's app cache - deprecated.
4 | • Disable webview scrollbars.
5 | • Tell MaterialSkin to not embed PDF files, and use intent to open these with phone's PDF viewer. Requires skin version 2.10.0, or later.
6 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/3.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • If page fails to load, try to discover LMS server first, then show settings page if this fails.
4 | • Add ability to zoom webview.
5 | • Show spinner if launched with no network connection.
6 | • If device has a cutout then don't hide status bar.
7 | • Use JSON port number from service discovery.
8 |
9 | Fixed
10 |
11 | • Use correct tag value for log messages.
12 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/300.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • When adding a URL via Android's share system, show a dialog asking which player to share to and add buttons for play, add, or insert.
4 | • Add option to auto-discover servers when connection is lost (enabled by default).
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/301.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Improve look of selection menus in WebView.
4 | • Set navigation and status bar colors for settings activty.
5 | • Set initial navigation and status bar colors for main activity.
6 | • Use custom toast class so that colors can be configured.
7 | • Remove margin from application settings.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/4.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Don't recreate webiew when orientation changes.
4 | • Add option to control status bar visibility; normal, blend with toolbar, or hidden.
5 | • Add option to control navigation bar visibility; normal, blend with toolbar, or hidden.
6 | • Opt-out of WebView metrics.
7 |
8 | Fixed
9 |
10 | • No need to fade out progress animation.
11 | • Use device's display density to properly zoom webview.
12 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/400.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Add options to start local player; SB Player, SqueezePlayer, or SqueezeLite (via Termux)
4 | • Use light theme for 'Application' settings if skin is set to a light theme
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/401.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added ability to specify Squeezelite options.
4 |
5 | Fixed
6 |
7 | • Catch exceptions when launching PDF viewer.
8 | • If receive 404 error when loading show settings page.
9 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/402.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • When deleting cache clear app's cache too.
4 | • If fail to connect and auto-discovery is disabled then show settings.
5 | • Make dialogs rounded.
6 | • Disable caps case of buttons.
7 |
8 | Fixed
9 |
10 | • Revert usage of light theme for settings, always use dark.
11 | • Replace forward-slashes with underscores for download filenames.
12 | • Set font scale to 100%.
13 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/5.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Round corners of square PNG icons.
4 |
5 | Fixed
6 |
7 | • Crash when switching from hidden status (or navigatation) bar to visible.
8 | • Set Android 7.1 as minimum supported version.
9 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/500.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Modified icon to match new Lyrion icon.
4 | • Changed name to 'Lyrion'.
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/6.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Allow manual entry of server address and port.
4 |
5 | Fixed
6 |
7 | • Correctly handle visibility of navbar when statubar is hidden.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/600.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Set target SDK to 34.
4 | • Use CallStateListener / PhoneStateListener to handle calls.
5 | • MediaSession for Android 13+
6 | • Material 3.
7 | • Ukrainian and Russian translations added.
8 | • Remove option to enable WiFi as this is no longer possible.
9 | • Hide 'Web page not available' and just show blank view.
10 | • Use MaterialSkin 5.2.0's top and bottom padding option to fill screen.
11 | • Remove navigation and status bar settings and replace with single fullscreen option.
12 |
13 | Fixed
14 |
15 | • Prevent backups to work-around issues with duplicated MACs, etc.
16 | • Less detailed notification icon.
17 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/601.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Handle screen rotation.
4 | • Fix edge-to-edge view for pre Android 14 devices.
5 | • Set Android 6.0 as minimum level.
6 | • Only draw behind status and navigation bars for Android 8.0 and above.
7 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/602.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • If zoom factor is less than default then adjust top/bottom padding.
4 |
5 | Fixed
6 |
7 | • Notifications on pre-Android 13 devices.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/603.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Use WindowInsets to calculate top and bottom padding required.
4 |
5 | Fixed
6 |
7 | • Fix starting Squeezelite (via Termux)
8 | • Don't allow fullscreen option on devices with top-left camera cutout.
9 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/7.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Set Android 6.0 as minimum supported version.
4 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/700.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Add option to allow full media-session notifications, with cover art and status updates.
4 | • Light settings dialog with light themes.
5 |
6 | Fixed
7 |
8 | • Resize view when keyboard shown.
9 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/8.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added 'Quit' action to main menu.
4 | • Added 'Quit' action to settings actionbar.
5 | • Added option to keep screen on.
6 | • Back button closes app if not connected to network.
7 | • Removed dividers in setings view.
8 |
9 | Fixed
10 |
11 | • Set Android 5.0 as minimum supported version.
12 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/800.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Added option to control if player state should be resumed after call.
4 |
5 | Fixed
6 |
7 | • Reduce network usage by setting timeout value to 0 for player status updates.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/801.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Update download code for newer SDK target.
4 | • Ask for MANAGE_EXTERNAL_STORAGE permission on Android 13+ when downloading files so that cover-art can also be downloaded. If this permission is not granted music is still downloaded, but cover-art will not be.
5 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/802.txt:
--------------------------------------------------------------------------------
1 | Fixed
2 |
3 | • Fix starting of SqueezePlayer.
4 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/803.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • When opening PDFs check for an installed handler and show appropriate error if none found.
4 | • Remove 'Quit' menu option.
5 |
6 | Fixed
7 |
8 | • Save downloaded cover-art as 'albumart.jpg'
9 | • Fix bottom inset height calculation.
10 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/804.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Add 'Quit' action to pre-Android 13 notifications.
4 | • Re-add option to show 'Quit' in main menu.
5 | • If skin indicates its lost connection then after 5 seconds start discovery (if enabled) or show settings.
6 |
7 | Fixed
8 |
9 | • Stop local player when quit via settings dialog or notification, if option enabled.
10 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/805.txt:
--------------------------------------------------------------------------------
1 | Improved
2 |
3 | • Only show 'Discovering server' toast if not due to connection issues.
4 |
5 | Fixed
6 |
7 | • When server discovered, only load if different to current.
8 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Provides a WebView wrapper for accessing a Lyrion Music Server instance using MaterialSkin. Requires the MaterialSkin plugin to be installed on your LMS instance.
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/icon.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/browse.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/browse.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/now-playing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/now-playing.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/queue.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/queue.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Simple webview wrapper for MaterialSkin on an LMS server
2 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | android.defaults.buildfeatures.buildconfig=true
21 | android.nonTransitiveRClass=false
22 | android.nonFinalResIds=false
23 |
24 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jan 23 08:34:15 GMT 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/lms-material/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/lms-material/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 |
3 | android {
4 | defaultConfig {
5 | applicationId "com.craigd.lmsmaterial.app"
6 | compileSdk 34
7 | minSdkVersion 23
8 | targetSdkVersion 34
9 | buildToolsVersion = "34.0.0"
10 | versionCode 805
11 | versionName "0.8.5"
12 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13 | }
14 | buildTypes {
15 | release {
16 | minifyEnabled true
17 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
18 | }
19 | }
20 | namespace 'com.craigd.lmsmaterial.app'
21 | lint {
22 | checkReleaseBuilds false
23 | }
24 | }
25 |
26 | dependencies {
27 | implementation fileTree(dir: 'libs', include: ['*.jar'])
28 | implementation 'androidx.appcompat:appcompat:1.7.0'
29 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
30 | implementation 'androidx.preference:preference:1.2.1'
31 | implementation 'androidx.media:media:1.7.0'
32 | implementation 'com.android.volley:volley:1.2.1'
33 | implementation 'com.google.android.material:material:1.12.0'
34 | implementation 'io.github.muddz:styleabletoast:2.4.0'
35 | implementation 'org.cometd.java:cometd-java-client:3.1.11'
36 | implementation 'org.slf4j:slf4j-nop:1.7.30'
37 | implementation platform("org.jetbrains.kotlin:kotlin-bom:1.9.20")
38 | testImplementation 'junit:junit:4.13.2'
39 | androidTestImplementation 'androidx.test.ext:junit:1.2.1'
40 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
41 | }
42 |
--------------------------------------------------------------------------------
/lms-material/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/lms-material/src/androidTest/java/com/craigd/lmsmaterial/app/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.craigd.lmsmaterial.app;
2 |
3 | import android.content.Context;
4 | import androidx.test.platform.app.InstrumentationRegistry;
5 | import androidx.test.ext.junit.runners.AndroidJUnit4;
6 |
7 | import org.junit.Test;
8 | import org.junit.runner.RunWith;
9 |
10 | import static org.junit.Assert.*;
11 |
12 | /**
13 | * Instrumented test, which will execute on an Android device.
14 | *
15 | * @see Testing documentation
16 | */
17 | @RunWith(AndroidJUnit4.class)
18 | public class ExampleInstrumentedTest {
19 | @Test
20 | public void useAppContext() {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
23 |
24 | assertEquals("com.craigd.lmsmaterial.app", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/lms-material/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
38 |
42 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
80 |
81 |
82 |
83 |
84 |
85 |
89 |
90 |
93 |
94 |
97 |
98 |
99 |
100 |
101 |
104 |
105 |
106 |
107 |
108 |
111 |
112 |
113 |
114 |
115 |
116 |
119 |
120 |
121 |
122 |
123 |
124 |
127 |
128 |
129 |
130 |
--------------------------------------------------------------------------------
/lms-material/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/lms-material/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/DownloadService.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.annotation.SuppressLint;
11 | import android.app.DownloadManager;
12 | import android.app.Notification;
13 | import android.app.NotificationChannel;
14 | import android.app.NotificationManager;
15 | import android.app.PendingIntent;
16 | import android.app.Service;
17 | import android.content.Context;
18 | import android.content.Intent;
19 | import android.content.SharedPreferences;
20 | import android.content.pm.ServiceInfo;
21 | import android.net.Uri;
22 | import android.os.Build;
23 | import android.os.Environment;
24 | import android.os.Handler;
25 | import android.os.IBinder;
26 | import android.os.Looper;
27 | import android.os.Message;
28 | import android.os.Messenger;
29 |
30 | import androidx.annotation.NonNull;
31 | import androidx.annotation.RequiresApi;
32 | import androidx.core.app.NotificationCompat;
33 | import androidx.core.app.NotificationManagerCompat;
34 | import androidx.core.app.ServiceCompat;
35 | import androidx.preference.PreferenceManager;
36 |
37 | import org.json.JSONArray;
38 | import org.json.JSONException;
39 | import org.json.JSONObject;
40 |
41 | import java.io.File;
42 | import java.io.FileInputStream;
43 | import java.io.FileOutputStream;
44 | import java.io.InputStream;
45 | import java.io.OutputStream;
46 | import java.lang.ref.WeakReference;
47 | import java.util.HashSet;
48 | import java.util.LinkedList;
49 | import java.util.List;
50 | import java.util.Set;
51 |
52 | public class DownloadService extends Service {
53 | private static final String COVER_ART_SRC = "cover.jpg";
54 | private static final String COVER_ART_DEST = "albumart.jpg";
55 | public static final String STATUS = DownloadService.class.getCanonicalName() + ".STATUS";
56 | public static final String STATUS_BODY = "body";
57 | public static final String STATUS_LEN = "len";
58 | public static final int DOWNLOAD_LIST = 1;
59 | public static final int CANCEL_LIST = 2;
60 | public static final int STATUS_REQ = 3;
61 | private static final int MSG_ID = 2;
62 | private static final int MAX_QUEUED_ITEMS = 4;
63 | public static final String NOTIFICATION_CHANNEL_ID = "lms_download_service";
64 |
65 | private DownloadManager downloadManager;
66 | private SharedPreferences sharedPreferences;
67 | private NotificationCompat.Builder notificationBuilder;
68 | private NotificationManagerCompat notificationManager;
69 | private final Messenger messenger = new Messenger(new IncomingHandler(this));
70 |
71 | static String getString(JSONObject obj, String key) {
72 | try {
73 | return obj.getString(key);
74 | } catch (JSONException e) {
75 | return null;
76 | }
77 | }
78 |
79 | static int getInt(JSONObject obj, String key) {
80 | try {
81 | return obj.getInt(key);
82 | } catch (JSONException e) {
83 | return 0;
84 | }
85 | }
86 |
87 | static String fatSafe(String str) {
88 | return str.replaceAll("[?<>\\\\:*|\"/]", "_");
89 | }
90 |
91 | static String fixEmpty(String str) {
92 | return Utils.isEmpty(str) ? "Unknown" : str;
93 | }
94 |
95 | static class DownloadItem {
96 | public DownloadItem(JSONObject obj, boolean transcode) {
97 | id = getInt(obj, "id");
98 | filename = getString(obj, "filename");
99 | title = getString(obj, "title");
100 | ext = getString(obj, "ext");
101 | artist = getString(obj, "artist");
102 | album = getString(obj, "album");
103 | tracknum = getInt(obj, "tracknum");
104 | disc = getInt(obj, "disc");
105 | albumId = getInt(obj, "album_id");
106 | isTrack = true;
107 |
108 | if (Utils.isEmpty(filename)) {
109 | filename = "";
110 | if (disc > 0) {
111 | filename += disc;
112 | }
113 | if (tracknum > 0) {
114 | filename += (!filename.isEmpty() ? "." : "") + (tracknum < 10 ? "0" : "") + tracknum + " ";
115 | } else if (!filename.isEmpty()) {
116 | filename += " ";
117 | }
118 | filename += fixEmpty(title) + "." + (transcode ? "mp3" : ext);
119 | } else if (transcode) {
120 | int pos = filename.lastIndexOf('.');
121 | filename = (pos > 0 ? filename.substring(0, pos) : filename) + ".mp3";
122 | }
123 | }
124 |
125 | public DownloadItem(int id, int albumId, String artist, String album) {
126 | isTrack = false;
127 | this.id = id * -1; // Ensure we do not overlap with track id
128 | this.albumId = albumId;
129 | this.artist = artist;
130 | this.album = album;
131 | this.filename = COVER_ART_DEST;
132 | }
133 |
134 | JSONObject toObject(boolean downloading) throws JSONException {
135 | JSONObject obj = new JSONObject();
136 | obj.put("id", id);
137 | obj.put("downloading", downloading);
138 | obj.put("title", filename);
139 | obj.put("subtitle", artist + " - " + album);
140 | return obj;
141 | }
142 |
143 | public String getFolder() {
144 | return fatSafe(!artist.isEmpty() && !album.isEmpty()
145 | ? artist + " - " + album
146 | : !artist.isEmpty()
147 | ? artist
148 | : !album.isEmpty()
149 | ? album
150 | : "Unknown");
151 | }
152 |
153 | public String getDownloadFileName() {
154 | return fatSafe(fixEmpty(artist) + " - " + fixEmpty(album) + " - " + filename);
155 | }
156 |
157 | public int id;
158 | public String filename;
159 | public String title;
160 | public String ext;
161 | public String artist;
162 | public String album;
163 | public int tracknum;
164 | public int disc;
165 | public int albumId;
166 | public boolean isTrack;
167 | public long downloadId = 0;
168 | }
169 |
170 | final List items = new LinkedList<>();
171 | List queuedItems = new LinkedList<>();
172 | Set trackIds = new HashSet<>();
173 | Set albumIds = new HashSet<>();
174 |
175 | private static class IncomingHandler extends Handler {
176 | private final WeakReference serviceRef;
177 | public IncomingHandler(DownloadService service) {
178 | super(Looper.getMainLooper());
179 | serviceRef = new WeakReference<>(service);
180 | }
181 | @Override
182 | public void handleMessage(@NonNull Message msg) {
183 | DownloadService srv = serviceRef.get();
184 | if (null==srv) {
185 | super.handleMessage(msg);
186 | return;
187 | }
188 | switch (msg.what) {
189 | case DOWNLOAD_LIST:
190 | srv.addTracks((JSONArray) msg.obj);
191 | break;
192 | case CANCEL_LIST:
193 | srv.cancel((JSONArray) msg.obj);
194 | break;
195 | case STATUS_REQ:
196 | srv.sendStatusUpdate();
197 | break;
198 | default:
199 | super.handleMessage(msg);
200 | }
201 | }
202 | }
203 |
204 | public DownloadService() {
205 | }
206 |
207 | @Override
208 | public IBinder onBind(Intent intent) {
209 | return messenger.getBinder();
210 | }
211 |
212 | @Override
213 | public void onCreate() {
214 | super.onCreate();
215 | Utils.debug("");
216 | DownloadStatusReceiver.init(this);
217 | startForegroundService();
218 | downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
219 | sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
220 | }
221 |
222 | private void startForegroundService() {
223 | Utils.debug("Start download service.");
224 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
225 | createNotificationChannel();
226 | } else {
227 | notificationBuilder = new NotificationCompat.Builder(this);
228 | }
229 | createNotification();
230 | }
231 |
232 | @RequiresApi(Build.VERSION_CODES.O)
233 | private void createNotificationChannel() {
234 | notificationManager = NotificationManagerCompat.from(this);
235 | NotificationChannel chan = new NotificationChannel(NOTIFICATION_CHANNEL_ID, getApplicationContext().getResources().getString(R.string.download_notification), NotificationManager.IMPORTANCE_LOW);
236 | chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE);
237 | chan.setShowBadge(false);
238 | chan.enableLights(false);
239 | chan.enableVibration(false);
240 | chan.setSound(null, null);
241 | notificationManager.createNotificationChannel(chan);
242 | notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID);
243 | }
244 |
245 | @SuppressLint("MissingPermission")
246 | private void createNotification() {
247 | if (!Utils.notificationAllowed(this, NOTIFICATION_CHANNEL_ID)) {
248 | Utils.error("Permission not granted");
249 | return;
250 | }
251 | Intent intent = new Intent(this, MainActivity.class);
252 | PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : PendingIntent.FLAG_UPDATE_CURRENT);
253 | Notification notification = notificationBuilder.setOngoing(true)
254 | .setOnlyAlertOnce(true)
255 | .setSmallIcon(R.drawable.ic_download)
256 | .setContentTitle(getResources().getString(R.string.downloading))
257 | .setCategory(Notification.CATEGORY_SERVICE)
258 | .setContentIntent(pendingIntent)
259 | .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
260 | .setVibrate(null)
261 | .setSound(null)
262 | .setShowWhen(false)
263 | .setChannelId(NOTIFICATION_CHANNEL_ID)
264 | .build();
265 |
266 | notificationManager = NotificationManagerCompat.from(this);
267 | notificationManager.notify(MSG_ID, notificationBuilder.build());
268 | // startForeground(MSG_ID, notification);
269 |
270 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
271 | startForegroundService(new Intent(this, DownloadService.class));
272 | } else {
273 | startService(new Intent(this, DownloadService.class));
274 | }
275 |
276 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
277 | ServiceCompat.startForeground(this, MSG_ID, notification, Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ? ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE : 0);
278 | }
279 | }
280 |
281 | void addTracks(JSONArray tracks) {
282 | Utils.debug("");
283 | boolean transcode = sharedPreferences.getBoolean("transcode", false);
284 |
285 | synchronized (items) {
286 | try {
287 | int before = items.size();
288 | List newAlbumCovers = new LinkedList<>();
289 | for (int i = 0; i < tracks.length(); ++i) {
290 | DownloadItem track = new DownloadItem((JSONObject) tracks.get(i), transcode);
291 | if (!trackIds.contains(track.id)) {
292 | trackIds.add(track.id);
293 | items.add(track);
294 | if (!albumIds.contains(track.albumId)) {
295 | albumIds.add(track.albumId);
296 | newAlbumCovers.add(new DownloadItem(track.id, track.albumId, track.artist, track.album));
297 | }
298 | }
299 | }
300 | items.addAll(newAlbumCovers);
301 | Utils.debug("Before: " + before + " now:"+ items.size());
302 | if (before!=items.size()) {
303 | if (0==before) {
304 | downloadItems();
305 | } else {
306 | sendStatusUpdate();
307 | }
308 | }
309 | } catch (JSONException e) {
310 | Utils.error("Failed to add tracks", e);
311 | }
312 | }
313 | }
314 |
315 | void cancel(JSONArray ids) {
316 | Utils.debug("");
317 | List toRemove = new LinkedList<>();
318 | List toRemoveQueued = new LinkedList<>();
319 | Set idSet = new HashSet<>();
320 | for (int i = 0; i < ids.length(); ++i) {
321 | try {
322 | idSet.add(ids.getInt(i));
323 | } catch (JSONException e) {
324 | Utils.error("Failed to decode cancel array", e);
325 | }
326 | }
327 | if (!idSet.isEmpty()) {
328 | synchronized (items) {
329 | for (int i = 0; i < items.size(); ++i) {
330 | if (idSet.contains(items.get(i).id)) {
331 | toRemove.add(items.get(i));
332 | }
333 | }
334 | for (int i = 0; i < queuedItems.size(); ++i) {
335 | if (idSet.contains(queuedItems.get(i).id)) {
336 | toRemoveQueued.add(queuedItems.get(i));
337 | }
338 | }
339 | Utils.debug("Remove " + toRemove.size() + " item(s)");
340 | if (!toRemove.isEmpty()) {
341 | for (DownloadItem item : toRemove) {
342 | items.remove(item);
343 | }
344 | }
345 | if (!toRemoveQueued.isEmpty()) {
346 | for (DownloadItem item : toRemoveQueued) {
347 | downloadManager.remove(item.downloadId);
348 | queuedItems.remove(item);
349 | }
350 | }
351 |
352 | if (!toRemove.isEmpty() || !toRemoveQueued.isEmpty()) {
353 | sendStatusUpdate();
354 | if (items.isEmpty()) {
355 | Utils.debug("Empty, so stop");
356 | stop();
357 | } else if (!toRemoveQueued.isEmpty()) {
358 | downloadItems();
359 | }
360 | }
361 | }
362 | }
363 | }
364 |
365 | void sendStatusUpdate() {
366 | JSONArray update = new JSONArray();
367 | int count = 0;
368 | Utils.debug("Items:" + items.size()+" Queued:"+queuedItems.size());
369 | synchronized (items) {
370 | for (DownloadItem item: queuedItems) {
371 | try {
372 | update.put(item.toObject(true));
373 | count++;
374 | } catch (JSONException e) {
375 | Utils.error("Failed to create item string", e);
376 | }
377 | }
378 | for (DownloadItem item: items) {
379 | try {
380 | update.put(item.toObject(false));
381 | count++;
382 | } catch (JSONException e) {
383 | Utils.error("Failed to create item string", e);
384 | }
385 | }
386 | }
387 | try {
388 | Utils.debug("Send status update to webview, count:" + count);
389 | Intent intent = new Intent();
390 | intent.setAction(STATUS);
391 | intent.putExtra(STATUS_BODY, update.toString(0));
392 | intent.putExtra(STATUS_LEN, count);
393 | sendBroadcast(intent);
394 | } catch (JSONException e) {
395 | Utils.error("Failed to create update string", e);
396 | }
397 | }
398 |
399 | void downloadItems() {
400 | Utils.debug("");
401 | synchronized (items) {
402 | while(!items.isEmpty() && queuedItems.size() 0) {
482 | outputStream.write(b, 0, bytesRead);
483 | }
484 | } catch (Exception e) {
485 | Utils.error("Failed to copy " + sourceFile.getAbsolutePath() + " to " + destFile.getAbsolutePath(), e);
486 | }
487 | try {
488 | if (!sourceFile.delete()) {
489 | Utils.error("Failed to delete " + sourceFile.getAbsolutePath());
490 | }
491 | } catch (Exception e) {
492 | Utils.error("Failed to delete " + sourceFile.getAbsolutePath(), e);
493 | }
494 | }
495 |
496 | void stop() {
497 | Utils.debug("");
498 | stopForeground(true);
499 | stopSelf();
500 | }
501 | }
502 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/DownloadStatusReceiver.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.app.DownloadManager;
11 | import android.content.BroadcastReceiver;
12 | import android.content.Context;
13 | import android.content.Intent;
14 |
15 | public class DownloadStatusReceiver extends BroadcastReceiver {
16 | public static final String SHOW_DOWNLOADS_ACT = "showdownloads";
17 |
18 | private static DownloadService service = null;
19 |
20 | public static void init(DownloadService srv) {
21 | service = srv;
22 | }
23 | @Override
24 | public void onReceive(Context context, Intent intent) {
25 | if (DownloadManager.ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) {
26 | Intent startIntent = new Intent(context, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
27 | startIntent.setAction(SHOW_DOWNLOADS_ACT);
28 | // TODO: MainActivity needs rto look at act and call relevant javascript
29 | context.startActivity(startIntent);
30 | } else if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
31 | service.downloadComplete(intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0));
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/JsonRpc.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_PASSWORD_KEY;
11 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_USERNAME_KEY;
12 |
13 | import android.content.Context;
14 | import android.content.SharedPreferences;
15 |
16 | import androidx.annotation.Nullable;
17 | import androidx.preference.PreferenceManager;
18 |
19 | import com.android.volley.AuthFailureError;
20 | import com.android.volley.RequestQueue;
21 | import com.android.volley.Response;
22 | import com.android.volley.toolbox.JsonObjectRequest;
23 | import com.android.volley.toolbox.Volley;
24 |
25 | import org.eclipse.jetty.util.B64Code;
26 | import org.json.JSONArray;
27 | import org.json.JSONObject;
28 |
29 | import java.util.HashMap;
30 | import java.util.Map;
31 |
32 | public class JsonRpc {
33 | private final RequestQueue requestQueue;
34 | private final SharedPreferences prefs ;
35 |
36 | private class Request extends JsonObjectRequest {
37 | public Request(String url, @Nullable JSONObject request, Response.Listener responseListener) {
38 | super(Request.Method.POST, url, request, responseListener, null);
39 | }
40 |
41 | @Override
42 | public Map getHeaders() throws AuthFailureError {
43 | String user = prefs.getString(LMS_USERNAME_KEY, "");
44 | String pass = prefs.getString(LMS_PASSWORD_KEY, "");
45 |
46 | if (user.isEmpty() || pass.isEmpty()) {
47 | return super.getHeaders();
48 | }
49 |
50 | Map headers = super.getHeaders();
51 | if (null==headers) {
52 | headers = new HashMap<>();
53 | headers.put("Authorization", "Basic " + B64Code.encode(user + ":" + pass));
54 | }
55 | return headers;
56 | }
57 | };
58 |
59 | public JsonRpc(Context context) {
60 | prefs = PreferenceManager.getDefaultSharedPreferences(context);
61 | requestQueue = Volley.newRequestQueue(context);
62 | }
63 |
64 | public void sendMessage(String id, String[] command) {
65 | sendMessage(id, command, null);
66 | }
67 |
68 | public void sendMessage(String id, String[] command, Response.Listener responseListener) {
69 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY,null));
70 | if (null!=server.ip) {
71 | try {
72 | JSONObject request = new JSONObject();
73 | JSONArray params = new JSONArray();
74 | JSONArray cmd = new JSONArray();
75 | params.put(0, id);
76 | for (String c : command) {
77 | cmd.put(cmd.length(), c);
78 | }
79 | params.put(1, cmd);
80 | request.put("id", 1);
81 | request.put("method", "slim.request");
82 | request.put("params", params);
83 |
84 | Utils.info("MSG:" + request);
85 | requestQueue.add(new Request("http://" + server.ip + ":" + server.port + "/jsonrpc.js", request, responseListener));
86 | } catch (Exception e) {
87 | Utils.error("Failed to send control message", e);
88 | }
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/LocalPlayer.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.annotation.SuppressLint;
11 | import android.app.PendingIntent;
12 | import android.content.Context;
13 | import android.content.Intent;
14 | import android.content.SharedPreferences;
15 | import android.content.pm.PackageManager;
16 | import android.os.Build;
17 | import android.provider.Settings;
18 | import android.widget.Toast;
19 |
20 | import androidx.core.content.ContextCompat;
21 |
22 | import java.util.HashMap;
23 | import java.util.LinkedList;
24 | import java.util.List;
25 | import java.util.Map;
26 | import java.util.Random;
27 |
28 | import io.github.muddz.styleabletoast.StyleableToast;
29 |
30 | public class LocalPlayer {
31 | public static final String NO_PLAYER = "none";
32 | public static final String SB_PLAYER = "sbplayer";
33 | public static final String SQUEEZE_PLAYER = "squeezeplayer";
34 | public static final String TERMUX_PLAYER = "termux";
35 | public static final String TERMUX_MAC_PREF = "termux_mac";
36 |
37 | private final SharedPreferences sharedPreferences;
38 | private final Context context;
39 | private JsonRpc rpc = null;
40 |
41 | private enum State {
42 | INITIAL,
43 | STARTED,
44 | STOPPED
45 | }
46 | private static State state = State.INITIAL;
47 |
48 | public LocalPlayer(SharedPreferences sharedPreferences, Context context) {
49 | this.sharedPreferences = sharedPreferences;
50 | this.context = context;
51 | }
52 |
53 | public void autoStart(boolean fromResume) {
54 | // If resuming and user had stopped this player, then don't auto-restart
55 | if (fromResume && State.STOPPED.equals(state)) {
56 | return;
57 | }
58 | if (sharedPreferences.getBoolean(SettingsActivity.AUTO_START_PLAYER_APP_PREF_KEY, false)) {
59 | // Only SqueezePlayer needs re-starting from resume???
60 | if (!fromResume || SQUEEZE_PLAYER.equals(sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null))) {
61 | start();
62 | }
63 | }
64 | }
65 |
66 | public void autoStop() {
67 | if (sharedPreferences.getBoolean(SettingsActivity.STOP_APP_ON_QUIT_PREF_KEY, false)) {
68 | stop();
69 | }
70 | }
71 |
72 | @SuppressLint("SdCardPath")
73 | public void start() {
74 | String playerApp = sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null);
75 | Utils.debug("Start player: " + playerApp);
76 | if (SB_PLAYER.equals(playerApp)) {
77 | if (sendSbPlayerIntent(true)) {
78 | state = State.STARTED;
79 | }
80 | } else if (SQUEEZE_PLAYER.equals(playerApp)) {
81 | if (controlSqueezePlayer(true)) {
82 | state = State.STARTED;
83 | }
84 | } else if (TERMUX_PLAYER.equals(playerApp)) {
85 | // First check Squeezelite is not already running...
86 | runTermuxCommand("/data/data/com.termux/files/usr/bin/ps", new String[]{"-eaf"}, true);
87 | }
88 | }
89 |
90 | @SuppressLint("SdCardPath")
91 | public void startTermuxSqueezeLite() {
92 | ServerDiscovery.Server current = new ServerDiscovery.Server(sharedPreferences.getString(SettingsActivity.SERVER_PREF_KEY, null));
93 | state = State.INITIAL;
94 | String opts = sharedPreferences.getString(SettingsActivity.SQUEEZELITE_OPTIONS_KEY, "");
95 | Map params = new HashMap<>();
96 | params.put("-M", "SqueezeLiteAndroid");
97 | params.put("-C", "5");
98 | params.put("-s", current.ip);
99 | params.put("-m", getTermuxMac());
100 | params.put("-n", Settings.Global.getString(context.getContentResolver(), "device_name"));
101 | String[] parts = opts.split(" ");
102 | if (parts.length>1 && parts.length%2 == 0) {
103 | for (int i=0; i entry: params.entrySet()) {
116 | args[i]=entry.getKey();
117 | i++;
118 | args[i]=entry.getValue();
119 | i++;
120 | }
121 | if (runTermuxCommand("/data/data/com.termux/files/usr/bin/squeezelite", args, false)) {
122 | state = State.STARTED;
123 | }
124 | }
125 |
126 | public void stopPlayer(String playerId) {
127 | // If stopping player via skin's 'power' button, then we need to ask LMS to forget
128 | // the client first, and then do the actual stop.
129 | if (null==rpc) {
130 | rpc = new JsonRpc(context);
131 | }
132 | rpc.sendMessage(playerId, new String[]{"client", "forget"}, response -> stop());
133 | }
134 |
135 | @SuppressLint("SdCardPath")
136 | public void stop() {
137 | String playerApp = sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null);
138 | Utils.debug("Stop player: " + playerApp);
139 | if (SB_PLAYER.equals(playerApp)) {
140 | if (sendSbPlayerIntent(false)) {
141 | state = State.STOPPED;
142 | }
143 | } else if (SQUEEZE_PLAYER.equals(playerApp)) {
144 | if (controlSqueezePlayer(false)) {
145 | state = State.STOPPED;
146 | }
147 | } else if (TERMUX_PLAYER.equals(playerApp)) {
148 | if (runTermuxCommand("/data/data/com.termux/files/usr/bin/killall", new String[]{"-9", "squeezelite"}, false)) {
149 | state = State.STOPPED;
150 | }
151 | }
152 | }
153 |
154 | private String getTermuxMac() {
155 | String mac = sharedPreferences.getString(TERMUX_MAC_PREF, null);
156 | if (null!=mac) {
157 | return mac;
158 | }
159 | List parts = new LinkedList<>();
160 | Random rand = new Random();
161 | parts.add("ab");
162 | parts.add("cd");
163 | while (parts.size()<6) {
164 | parts.add(String.format("%02x", rand.nextInt(255)));
165 | }
166 | String newMac = String.join(":", parts);
167 | SharedPreferences.Editor editor = sharedPreferences.edit();
168 | editor.putString(TERMUX_MAC_PREF, newMac);
169 | editor.apply();
170 | return newMac;
171 | }
172 |
173 | private boolean sendSbPlayerIntent(boolean start) {
174 | Intent intent = new Intent();
175 | intent.setClassName("com.angrygoat.android.sbplayer", "com.angrygoat.android.sbplayer.SBPlayerReceiver");
176 | intent.setAction("com.angrygoat.android.sbplayer." + (start ? "LAUNCH" : "EXIT"));
177 | intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES);
178 | try {
179 | context.sendBroadcast(intent);
180 | return true;
181 | } catch (Exception e) {
182 | Utils.error("Failed to control SB Player - " + e.getMessage());
183 | return false;
184 | }
185 | }
186 |
187 | private boolean controlSqueezePlayer(boolean start) {
188 | Intent intent = new Intent();
189 | intent.setClassName("de.bluegaspode.squeezeplayer", "de.bluegaspode.squeezeplayer.playback.service.PlaybackService");
190 |
191 | ServerDiscovery.Server current = new ServerDiscovery.Server(sharedPreferences.getString(SettingsActivity.SERVER_PREF_KEY, null));
192 | intent.putExtra("forceSettingsFromIntent", true);
193 | intent.putExtra("intentHasServerSettings", true);
194 | intent.putExtra("serverURL", current.ip + ":" + current.port);
195 | intent.putExtra("serverName", current.name);
196 | String user = sharedPreferences.getString(MainActivity.LMS_USERNAME_KEY, null);
197 | String pass = sharedPreferences.getString(MainActivity.LMS_PASSWORD_KEY, null);
198 | if (user != null && pass!=null) {
199 | intent.putExtra("username", user);
200 | intent.putExtra("password", pass);
201 | }
202 | try {
203 | if (start) {
204 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
205 | context.startForegroundService(intent);
206 | } else {
207 | context.startService(intent);
208 | }
209 | } else {
210 | context.stopService(intent);
211 | }
212 | return true;
213 | } catch (Exception e) {
214 | Utils.error("Failed to control SqueezePlayer - " + e.getMessage());
215 | return false;
216 | }
217 | }
218 |
219 | private boolean runTermuxCommand(String app, String[] args, boolean handleResp) {
220 | if (ContextCompat.checkSelfPermission(context, SettingsActivity.TERMUX_PERMISSION) != PackageManager.PERMISSION_GRANTED) {
221 | StyleableToast.makeText(context, context.getResources().getString(R.string.no_termux_run_perms), Toast.LENGTH_SHORT, R.style.toast).show();
222 | return false;
223 | }
224 | Intent intent = new Intent();
225 | intent.setClassName("com.termux", "com.termux.app.RunCommandService");
226 | intent.setAction("com.termux.RUN_COMMAND");
227 | intent.putExtra("com.termux.RUN_COMMAND_PATH", app);
228 | intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", args);
229 | intent.putExtra("com.termux.RUN_COMMAND_BACKGROUND", true);
230 | intent.putExtra("com.termux.RUN_COMMAND_SESSION_ACTION", "0");
231 | if (handleResp) {
232 | Utils.debug("HANDLE RESP");
233 | int executionId = TermuxResultsService.getNextExecutionId();
234 | Intent serviceIntent = new Intent(context, TermuxResultsService.class);
235 | serviceIntent.putExtra(TermuxResultsService.EXTRA_EXECUTION_ID, executionId);
236 | PendingIntent pendingIntent = PendingIntent.getService(context, executionId, serviceIntent,
237 | PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0));
238 | intent.putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", pendingIntent);
239 | }
240 | Utils.debug("Send Termux command:"+app+" args:"+String.join(", ", args));
241 | try {
242 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
243 | context.startForegroundService(intent);
244 | } else {
245 | context.startService(intent);
246 | }
247 | return true;
248 | } catch (Exception e) {
249 | Utils.error("Failed to send Termux command - " + e.getMessage());
250 | return false;
251 | }
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/PhoneStateHandler.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.content.Context;
11 | import android.content.SharedPreferences;
12 | import android.telephony.TelephonyManager;
13 |
14 | import androidx.preference.PreferenceManager;
15 |
16 | import com.android.volley.Response;
17 |
18 | import org.json.JSONArray;
19 | import org.json.JSONException;
20 | import org.json.JSONObject;
21 |
22 | import java.util.LinkedList;
23 | import java.util.List;
24 |
25 | public class PhoneStateHandler {
26 | public static final String DO_NOTHING = "nothing";
27 | public static final String MUTE_ALL = "muteall";
28 | public static final String MUTE_CURRENT = "mutecurrent";
29 | public static final String PAUSE_ALL = "pauseall";
30 | public static final String PAUSE_CURRENT = "pausecurrent";
31 |
32 | private SharedPreferences prefs = null;
33 | private JsonRpc rpc = null;
34 | private final List activePlayers = new LinkedList<>();
35 | private boolean inCall = false;
36 |
37 | private final Response.Listener rpcResponse = response -> {
38 | activePlayers.clear();
39 | if (inCall) {
40 | try {
41 | Utils.debug("RESP" + response.toString(4));
42 | JSONObject result = response.getJSONObject("result");
43 | if (result.has("players")) {
44 | JSONArray players = result.getJSONArray("players");
45 | if (players.length() > 0) {
46 | for (int i = 0; i < players.length(); ++i) {
47 | activePlayers.add(players.getJSONObject(i).getString("id"));
48 | }
49 | Utils.debug("RPC response, activePlayers:" + activePlayers);
50 | controlPlayers();
51 | }
52 | }
53 | } catch (JSONException e) {
54 | Utils.error("Failed to parse response", e);
55 | }
56 | }
57 | };
58 |
59 | public void handle(Context context, int state) {
60 | if (null==prefs) {
61 | prefs = PreferenceManager.getDefaultSharedPreferences(context);
62 | }
63 | String action = prefs.getString(SettingsActivity.ON_CALL_PREF_KEY, DO_NOTHING);
64 | Utils.debug("Call state:" + state + ", action: " + action);
65 |
66 | if (DO_NOTHING.equals(action)) {
67 | return;
68 | }
69 | if (null==rpc) {
70 | rpc = new JsonRpc(context);
71 | }
72 | if (state == TelephonyManager.CALL_STATE_RINGING || state == TelephonyManager.CALL_STATE_OFFHOOK) {
73 | callStarted(action);
74 | } else {
75 | callEnded();
76 | }
77 | }
78 |
79 | private void callStarted(String action) {
80 | Utils.debug("Call started, activePlayers:"+activePlayers);
81 | if (MainActivity.isActive() || ControlService.isActive()) {
82 | inCall = true;
83 | if (MUTE_CURRENT.equals(action) || PAUSE_CURRENT.equals(action)) {
84 | activePlayers.add(MainActivity.activePlayer);
85 | controlPlayers();
86 | } else {
87 | getActivePlayers();
88 | }
89 | } else {
90 | Utils.debug("App is not currently active");
91 | }
92 | }
93 |
94 | private void callEnded() {
95 | boolean resume = prefs.getBoolean(SettingsActivity.AFTER_CALL_PREF_KEY, true);
96 | Utils.debug("Call ended, activePlayers:"+activePlayers+", inCall:"+inCall+", resume:"+resume);
97 | if (inCall) {
98 | inCall = false;
99 | if (prefs.getBoolean(SettingsActivity.AFTER_CALL_PREF_KEY, true)) {
100 | controlPlayers();
101 | }
102 | }
103 | activePlayers.clear();
104 | }
105 |
106 | private void getActivePlayers() {
107 | rpc.sendMessage("", new String[]{"material-skin", "activeplayers"}, rpcResponse);
108 | }
109 |
110 | private void controlPlayers() {
111 | if (activePlayers.isEmpty()) {
112 | Utils.debug("Control players NO ACTIVE PLAYERS");
113 | return;
114 | }
115 | String action = prefs.getString(SettingsActivity.ON_CALL_PREF_KEY, DO_NOTHING);
116 | Utils.debug("Control players, action:" + action + ", active:" + activePlayers + ", current:"+MainActivity.activePlayer);
117 | if (MUTE_ALL.equals(action) || PAUSE_ALL.equals(action)) {
118 | for (String id: activePlayers) {
119 | controlPlayer(action, id);
120 | }
121 | } else if (MUTE_CURRENT.equals(action) || PAUSE_CURRENT.equals(action)) {
122 | for (String id: activePlayers) {
123 | Utils.debug(id+"=="+MainActivity.activePlayer+" ? " + id.equals(MainActivity.activePlayer));
124 |
125 | if (id.equals(MainActivity.activePlayer)) {
126 | Utils.debug("Matched ID");
127 | controlPlayer(action, id);
128 | break;
129 | }
130 | }
131 | }
132 | }
133 |
134 | private void controlPlayer(String action, String player) {
135 | Utils.debug(action+" on "+player);
136 | if (MUTE_ALL.equals(action) || MUTE_CURRENT.equals(action)) {
137 | rpc.sendMessage(player, new String[]{"mixer", "muting", inCall ? "1" : "0"});
138 | } else if (PAUSE_ALL.equals(action) || PAUSE_CURRENT.equals(action)) {
139 | rpc.sendMessage(player, new String[]{"pause", inCall ? "1" : "0"});
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/ServerDiscovery.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.content.Context;
11 | import android.net.wifi.WifiManager;
12 | import android.os.Handler;
13 | import android.os.Looper;
14 | import android.os.Message;
15 |
16 | import androidx.annotation.NonNull;
17 |
18 | import org.json.JSONException;
19 | import org.json.JSONObject;
20 |
21 | import java.io.IOException;
22 | import java.net.DatagramPacket;
23 | import java.net.DatagramSocket;
24 | import java.net.InetAddress;
25 | import java.util.LinkedList;
26 | import java.util.List;
27 | import java.util.Objects;
28 |
29 | public abstract class ServerDiscovery {
30 | private static final int SERVER_DISCOVERY_TIMEOUT = 1500;
31 |
32 | public static class Server implements Comparable {
33 | public static final int DEFAULT_PORT = 9000;
34 | public String ip = "";
35 | public String name = "";
36 | public int port = DEFAULT_PORT;
37 |
38 | private static String getString(JSONObject json, String key) {
39 | try {
40 | return json.getString(key);
41 | } catch (JSONException e) {
42 | return "";
43 | }
44 | }
45 |
46 | private static int getPort(JSONObject json) {
47 | try {
48 | return json.getInt("port");
49 | } catch (JSONException e) {
50 | return Server.DEFAULT_PORT;
51 | }
52 | }
53 |
54 | public Server(String str) {
55 | Utils.debug("DECODE:"+str);
56 | if (str != null) {
57 | try {
58 | JSONObject json = new JSONObject(str);
59 | ip = getString(json, "ip");
60 | name = getString(json, "name");
61 | port = getPort(json);
62 | } catch (JSONException ignored) {
63 | }
64 | }
65 | }
66 |
67 | public Server(String ip, int port, String name) {
68 | this.ip=ip;
69 | this.port=port;
70 | this.name=name;
71 | }
72 |
73 | public Server(DatagramPacket pkt) {
74 | ip = pkt.getAddress().getHostAddress();
75 |
76 | // Try to get name of server for packet
77 | int pktLen = pkt.getLength();
78 | byte[] bytes = pkt.getData();
79 |
80 | // Look for NAME: in list of key:value pairs
81 | for(int i=1; i < pktLen; ) {
82 | if (i + 5 > pktLen) {
83 | break;
84 | }
85 |
86 | // Extract 4 bytes
87 | String key = new String(bytes, i, 4);
88 | i += 4;
89 |
90 | int valueLen = bytes[i++] & 0xFF;
91 | if (i + valueLen > pktLen) {
92 | break;
93 | }
94 |
95 | if (key.equals("NAME")) {
96 | name = new String(bytes, i, valueLen);
97 | Utils.debug("Name:"+name);
98 | } else if (key.equals("JSON")) {
99 | try {
100 | port = Integer.parseInt(new String(bytes, i, valueLen));
101 | Utils.debug("Port:"+port);
102 | } catch (NumberFormatException ignored) {
103 | }
104 | }
105 | i += valueLen;
106 | }
107 | }
108 |
109 | public boolean isEmpty() {
110 | return null==ip || ip.isEmpty();
111 | }
112 |
113 | @Override
114 | public int compareTo(@NonNull Server o) {
115 | return null==ip ? (o.ip==null ? 0 : -1) : ip.compareTo(o.ip);
116 | }
117 |
118 | public boolean equals(Server o) {
119 | return Objects.equals(ip, o.ip);
120 | }
121 |
122 | public String describe() {
123 | if (null==name || name.isEmpty()) {
124 | return address();
125 | }
126 | return name+" ("+address()+")";
127 | }
128 |
129 | public String address() {
130 | return ip + (DEFAULT_PORT==port ? "" : (":"+port));
131 | }
132 |
133 | public String encode() {
134 | try {
135 | JSONObject json = new JSONObject();
136 | json.put("ip", ip);
137 | json.put("name", name);
138 | json.put("port", port);
139 | return json.toString(0);
140 | } catch (JSONException e) {
141 | return ip;
142 | }
143 | }
144 | }
145 |
146 | class DiscoveryRunnable implements Runnable {
147 | private volatile boolean active = false;
148 | private final WifiManager wifiManager;
149 | private final List servers = new LinkedList<>();
150 |
151 | DiscoveryRunnable(WifiManager wifiManager) {
152 | this.wifiManager = wifiManager;
153 | }
154 |
155 | @Override
156 | public void run() {
157 | Utils.debug("Discover LMS servers");
158 |
159 | active = true;
160 | WifiManager.WifiLock wifiLock;
161 | DatagramSocket socket = null;
162 | wifiLock = wifiManager.createWifiLock(Utils.LOG_TAG);
163 | wifiLock.acquire();
164 |
165 | try {
166 | InetAddress broadcastAddress = InetAddress.getByName("255.255.255.255");
167 | socket = new DatagramSocket();
168 | byte[] req = { 'e', 'I', 'P', 'A', 'D', 0, 'N', 'A', 'M', 'E', 0, 'J', 'S', 'O', 'N', 0 };
169 | DatagramPacket reqPkt = new DatagramPacket(req, req.length, broadcastAddress, 3483);
170 | byte[] resp = new byte[256];
171 | DatagramPacket respPkt = new DatagramPacket(resp, resp.length);
172 |
173 | socket.setSoTimeout(SERVER_DISCOVERY_TIMEOUT);
174 | socket.send(reqPkt);
175 | for (;;) {
176 | try {
177 | socket.receive(respPkt);
178 | if (resp[0]==(byte)'E') {
179 | Server server = new Server(respPkt);
180 | if (!servers.contains(server)) {
181 | servers.add(server);
182 | if (!discoverAll) {
183 | break; // Stop at first for now...
184 | }
185 | }
186 | }
187 | } catch (IOException e) {
188 | break;
189 | }
190 | }
191 |
192 | } catch (Exception ignored) {
193 | } finally {
194 | if (socket != null) {
195 | socket.close();
196 | }
197 |
198 | Utils.verbose("Scanning complete, unlocking WiFi");
199 | wifiLock.release();
200 | }
201 |
202 | handler.sendMessage(new Message());
203 | active = false;
204 | }
205 |
206 | public boolean isActive() {
207 | return active;
208 | }
209 | }
210 |
211 | final Context context;
212 | private final boolean discoverAll;
213 | private final Handler handler;
214 | private DiscoveryRunnable runnable;
215 |
216 | ServerDiscovery(Context context, boolean discoverAll) {
217 | this.context = context;
218 | this.discoverAll = discoverAll;
219 | handler = new Handler(Looper.getMainLooper()) {
220 | @Override
221 | public void handleMessage(@NonNull Message unused) {
222 | discoveryFinished(runnable.servers);
223 | }
224 | };
225 | }
226 |
227 | public void discover() {
228 | if (runnable!=null && runnable.isActive()) {
229 | return;
230 | }
231 | runnable = new DiscoveryRunnable((WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE));
232 | Thread thread = new Thread(runnable);
233 | thread.start();
234 | }
235 |
236 | protected abstract void discoveryFinished(List servers);
237 | }
238 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/TermuxResultsService.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.annotation.SuppressLint;
11 | import android.app.IntentService;
12 | import android.content.Intent;
13 | import android.os.Bundle;
14 |
15 | import androidx.preference.PreferenceManager;
16 |
17 | public class TermuxResultsService extends IntentService {
18 | public static final String EXTRA_EXECUTION_ID = "execution_id";
19 | private static int EXECUTION_ID = 1000;
20 |
21 | public static synchronized int getNextExecutionId() {
22 | EXECUTION_ID++;
23 | return EXECUTION_ID;
24 | }
25 |
26 | public TermuxResultsService() {
27 | super("TermuxResultsService");
28 | }
29 |
30 | @SuppressLint("SdCardPath")
31 | @Override
32 | protected void onHandleIntent(Intent intent) {
33 | if (intent == null) {
34 | return;
35 | }
36 |
37 | Bundle resultBundle = intent.getBundleExtra("result");
38 | if (resultBundle == null) {
39 | return;
40 | }
41 |
42 | if (intent.getIntExtra(EXTRA_EXECUTION_ID, 0)!=EXECUTION_ID) {
43 | return;
44 | }
45 |
46 | String stdout = resultBundle.getString("stdout", "");
47 | if (stdout.contains("/data/data/com.termux/files/usr/bin/squeezelite")) {
48 | Utils.debug("Squeezelite is already running");
49 | } else {
50 | new LocalPlayer(PreferenceManager.getDefaultSharedPreferences(getApplicationContext()), getApplicationContext()).startTermuxSqueezeLite();
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/UrlHandler.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2023 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 |
10 | import android.app.Dialog;
11 | import android.view.LayoutInflater;
12 | import android.view.View;
13 | import android.widget.AdapterView;
14 | import android.widget.ArrayAdapter;
15 | import android.widget.Button;
16 | import android.widget.Spinner;
17 |
18 | import androidx.appcompat.app.AlertDialog;
19 |
20 | import com.android.volley.Response;
21 |
22 | import org.json.JSONArray;
23 | import org.json.JSONException;
24 | import org.json.JSONObject;
25 |
26 | import java.util.ArrayList;
27 | import java.util.Collections;
28 | import java.util.LinkedList;
29 | import java.util.List;
30 |
31 | public class UrlHandler {
32 | private final MainActivity mainActivity;
33 | private JsonRpc rpc;
34 | private String handlingUrl;
35 | private Dialog dialog;
36 | private Spinner playerName;
37 | private final List playerList = new LinkedList<>();
38 | private int chosenPlayer = 0;
39 |
40 | private static class Player implements Comparable {
41 | public Player(String name, String id) {
42 | this.name = name;
43 | this.id = id;
44 | }
45 | public String name;
46 | public String id;
47 |
48 | @Override
49 | public int compareTo(Object o) {
50 | return name.compareToIgnoreCase(((Player)o).name);
51 | }
52 | }
53 |
54 | private final Response.Listener serverStatusResponse = new Response.Listener () {
55 | @Override
56 | public void onResponse(JSONObject response) {
57 | playerList.clear();
58 | try {
59 | Utils.debug("RESP" + response.toString(4));
60 | JSONObject result = response.getJSONObject("result");
61 | if (result.has("players_loop")) {
62 | JSONArray players = result.getJSONArray("players_loop");
63 | if (players.length() > 0) {
64 | for (int i = 0; i < players.length(); ++i) {
65 | JSONObject obj = players.getJSONObject(i);
66 | playerList.add(new Player(obj.getString("name"), obj.getString("playerid")));
67 | }
68 | Utils.debug("RPC response, numPlayers:" + playerList.size());
69 | }
70 | }
71 | } catch (JSONException e) {
72 | Utils.error( "Failed to parse response", e);
73 | }
74 | if (playerList.isEmpty()) {
75 | return;
76 | }
77 | Collections.sort(playerList);
78 |
79 | // Create dialog
80 | if (null==dialog) {
81 | AlertDialog.Builder builder = new AlertDialog.Builder(mainActivity);
82 | LayoutInflater inflater = mainActivity.getLayoutInflater();
83 | View view = inflater.inflate(R.layout.url_handler, null);
84 |
85 | builder.setView(view)
86 | .setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss());
87 | dialog = builder.create();
88 | playerName = view.findViewById(R.id.player_name);
89 |
90 | Button play_now = view.findViewById(R.id.play_now_button);
91 | Button play_next = view.findViewById(R.id.play_next_button);
92 | Button insert = view.findViewById(R.id.insert_button);
93 |
94 | play_now.setOnClickListener(view1 -> {
95 | dialog.dismiss();
96 | addUrlToPlayer("play");
97 | });
98 | play_next.setOnClickListener(view12 -> {
99 | dialog.dismiss();
100 | addUrlToPlayer("add");
101 | });
102 | insert.setOnClickListener(view13 -> {
103 | dialog.dismiss();
104 | addUrlToPlayer("insert");
105 | });
106 | playerName.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
107 | @Override
108 | public void onItemSelected(AdapterView> parent, View view, int position, long id) {
109 | chosenPlayer = position;
110 | }
111 |
112 | @Override
113 | public void onNothingSelected(AdapterView> parent) {
114 | }
115 | });
116 | }
117 |
118 | // Initialise items
119 | ArrayList player_names = new ArrayList<>();
120 | for (Player player: playerList) {
121 | player_names.add(player.name);
122 | }
123 | ArrayAdapter adapter = new ArrayAdapter<>(mainActivity, android.R.layout.simple_spinner_item, player_names);
124 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
125 | playerName.setAdapter(adapter);
126 |
127 | chosenPlayer = 0;
128 | if (null!=MainActivity.activePlayer) {
129 | for (int i=0; i addActionResponse = new Response.Listener () {
144 | @Override
145 | public void onResponse(JSONObject response) {
146 | if (chosenPlayer>=0 && chosenPlayer=playerList.size()) {
167 | return;
168 | }
169 | Player player = playerList.get(chosenPlayer);
170 | Utils.debug(action+": "+handlingUrl+", to: "+player.name);
171 | rpc.sendMessage(player.id, new String[]{"playlist", action, handlingUrl}, addActionResponse);
172 | handlingUrl = null;
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/Utils.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * Copyright (c) 2020-2024 Craig Drummond
5 | * MIT license.
6 | */
7 |
8 | package com.craigd.lmsmaterial.app;
9 | import android.Manifest;
10 | import android.annotation.SuppressLint;
11 | import android.app.Activity;
12 | import android.app.NotificationChannel;
13 | import android.app.NotificationManager;
14 | import android.content.Context;
15 | import android.content.pm.PackageManager;
16 | import android.content.res.Resources;
17 | import android.graphics.Insets;
18 | import android.graphics.Rect;
19 | import android.net.ConnectivityManager;
20 | import android.net.NetworkInfo;
21 | import android.os.Build;
22 | import android.util.DisplayMetrics;
23 | import android.util.Log;
24 | import android.view.DisplayCutout;
25 | import android.view.WindowInsets;
26 |
27 | import androidx.core.app.ActivityCompat;
28 | import androidx.core.app.NotificationManagerCompat;
29 |
30 | import java.io.UnsupportedEncodingException;
31 | import java.net.URLEncoder;
32 | import java.util.List;
33 | import java.util.concurrent.TimeUnit;
34 |
35 | public class Utils {
36 | public static final String LOG_TAG = "LMS";
37 |
38 | public static boolean isNetworkConnected(Context context) {
39 | ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
40 | NetworkInfo info = connectivityManager.getActiveNetworkInfo();
41 | return null!=info && info.isConnected();
42 | }
43 |
44 | public static float convertPixelsToDp(float px, Context context){
45 | return px / ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT);
46 | }
47 |
48 | public static boolean cutoutTopLeft(Activity activity) {
49 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
50 | DisplayCutout displayCutout = activity.getWindowManager().getDefaultDisplay().getCutout();
51 | if (null != displayCutout) {
52 | List rects = displayCutout.getBoundingRects();
53 | for (Rect rect : rects) {
54 | if (rect.left >= 0 && rect.left <= 10 && rect.width() > 100 && rect.width()<300) {
55 | return true;
56 | }
57 | }
58 | }
59 | }
60 | return false;
61 | }
62 |
63 | public static boolean usingGestureNavigation(Activity activity) {
64 | Resources resources = activity.getResources();
65 | @SuppressLint("DiscouragedApi") int resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android");
66 | if (resourceId > 0) {
67 | return 2==resources.getInteger(resourceId);
68 | }
69 | return false;
70 | }
71 |
72 | static int getTopPadding(Activity activity) {
73 | int def = 26;
74 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
75 | WindowInsets wi = activity.getWindowManager().getCurrentWindowMetrics().getWindowInsets();
76 | Insets i = wi.getInsets(WindowInsets.Type.systemBars());
77 | return Math.max((int)Math.ceil(convertPixelsToDp(i.top, activity)), def);
78 | }
79 | return def;
80 | }
81 |
82 | static int getBottomPadding(Activity activity) {
83 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
84 | WindowInsets wi = activity.getWindowManager().getCurrentWindowMetrics().getWindowInsets();
85 | Insets i = wi.getInsets(WindowInsets.Type.navigationBars());
86 | int val = (int)Math.ceil(convertPixelsToDp(i.bottom, activity));
87 | Utils.debug("inset:" + val);
88 | return val>8 ? Math.max(val, 14) : val;
89 | }
90 | return usingGestureNavigation(activity) ? 14 : 40;
91 | }
92 |
93 | static public boolean notificationAllowed(Context context, String channelId) {
94 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
95 | debug("Check if notif permission granted");
96 | if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
97 | debug("No notif permission");
98 | return false;
99 | }
100 | }
101 | if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
102 | debug("Notifs are disabled");
103 | return false;
104 | }
105 | if (channelId!=null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
106 | NotificationManager mgr = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
107 | NotificationChannel channel = mgr.getNotificationChannel(channelId);
108 | if (null!=channel) {
109 | debug("Channel " + channelId + " importance " + channel.getImportance());
110 | return channel.getImportance() != NotificationManager.IMPORTANCE_NONE;
111 | }
112 | }
113 | debug("Notifs are allowed");
114 | return true;
115 | }
116 |
117 | public static boolean isEmpty(String str) {
118 | return null==str || str.isEmpty();
119 | }
120 |
121 | public static String encodeURIComponent(String str) {
122 | try {
123 | return URLEncoder.encode(str, "UTF-8")
124 | .replaceAll("\\+", "%20")
125 | .replaceAll("\\%21", "!")
126 | .replaceAll("\\%27", "'")
127 | .replaceAll("\\%28", "(")
128 | .replaceAll("\\%29", ")")
129 | .replaceAll("\\%7E", "~");
130 | } catch (UnsupportedEncodingException ignored) {
131 | }
132 | return str;
133 | }
134 |
135 | public static String timeStr(long ms) {
136 | return String.format("%02d:%02d",
137 | TimeUnit.MILLISECONDS.toMinutes(ms) -
138 | TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(ms)),
139 | TimeUnit.MILLISECONDS.toSeconds(ms) -
140 | TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(ms)));
141 | }
142 |
143 | private static String logPrefix() {
144 | StackTraceElement st[] = Thread.currentThread().getStackTrace();
145 | if (null!=st && st.length>4) {
146 | // Remove com.craigd.lmsmaterial.app.
147 | return "["+st[4].getClassName().substring(27)+"."+st[4].getMethodName()+"] ";
148 | }
149 | return "";
150 | }
151 |
152 | public static void verbose(String message) {
153 | if (BuildConfig.DEBUG) {
154 | Log.v(LOG_TAG, logPrefix() + message);
155 | }
156 | }
157 |
158 | public static void debug(String message) {
159 | if (BuildConfig.DEBUG) {
160 | Log.d(LOG_TAG, logPrefix() + message);
161 | }
162 | }
163 |
164 | public static void info(String message) {
165 | if (BuildConfig.DEBUG) {
166 | Log.i(LOG_TAG, logPrefix() + message);
167 | }
168 | }
169 |
170 | public static void warn(String message) {
171 | if (BuildConfig.DEBUG) {
172 | Log.w(LOG_TAG, logPrefix() + message);
173 | }
174 | }
175 |
176 | public static void warn(String message, Throwable t) {
177 | if (BuildConfig.DEBUG) {
178 | Log.w(LOG_TAG, logPrefix() + message, t);
179 | }
180 | }
181 |
182 | public static void error(String message) {
183 | if (BuildConfig.DEBUG) {
184 | Log.e(LOG_TAG, logPrefix() + message);
185 | }
186 | }
187 |
188 | public static void error(String message, Throwable t) {
189 | if (BuildConfig.DEBUG) {
190 | Log.e(LOG_TAG, logPrefix() + message, t);
191 | }
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/BayeuxExtension.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * NOTE: This file copied from https://github.com/kaaholst/android-squeezer
5 | *
6 | * Apache-2.0 license
7 | */
8 |
9 | package com.craigd.lmsmaterial.app.cometd;
10 |
11 | import com.craigd.lmsmaterial.app.Utils;
12 |
13 | import org.cometd.bayeux.Channel;
14 | import org.cometd.bayeux.Message;
15 | import org.cometd.bayeux.client.ClientSession;
16 |
17 | public class BayeuxExtension extends ClientSession.Extension.Adapter {
18 | @Override
19 | public boolean sendMeta(ClientSession session, Message.Mutable message) {
20 | if (Channel.META_HANDSHAKE.equals(message.getChannel())) {
21 | if (message.getClientId() != null) {
22 | Utils.verbose("Reset client id");
23 | message.setClientId(null);
24 | }
25 | }
26 | return true;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/CometClient.java:
--------------------------------------------------------------------------------
1 | /**
2 | * LMS-Material-App
3 | *
4 | * NOTE: This file is *very* much inspired from https://github.com/kaaholst/android-squeezer
5 | *
6 | * Apache-2.0 license
7 | */
8 |
9 | package com.craigd.lmsmaterial.app.cometd;
10 |
11 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_PASSWORD_KEY;
12 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_USERNAME_KEY;
13 |
14 | import android.content.SharedPreferences;
15 | import android.os.Handler;
16 | import android.os.HandlerThread;
17 | import android.os.Looper;
18 | import android.os.SystemClock;
19 |
20 | import androidx.preference.PreferenceManager;
21 |
22 | import com.android.volley.Response;
23 | import com.craigd.lmsmaterial.app.ControlService;
24 | import com.craigd.lmsmaterial.app.JsonRpc;
25 | import com.craigd.lmsmaterial.app.MainActivity;
26 | import com.craigd.lmsmaterial.app.ServerDiscovery;
27 | import com.craigd.lmsmaterial.app.SettingsActivity;
28 | import com.craigd.lmsmaterial.app.Utils;
29 |
30 | import org.cometd.bayeux.Channel;
31 | import org.cometd.bayeux.Message;
32 | import org.cometd.bayeux.client.ClientSessionChannel;
33 | import org.cometd.client.transport.ClientTransport;
34 | import org.eclipse.jetty.client.HttpClient;
35 | import org.eclipse.jetty.http.HttpHeader;
36 | import org.eclipse.jetty.util.B64Code;
37 | import org.json.JSONArray;
38 | import org.json.JSONException;
39 | import org.json.JSONObject;
40 |
41 | import java.net.URL;
42 | import java.util.ArrayList;
43 | import java.util.Arrays;
44 | import java.util.HashMap;
45 | import java.util.List;
46 | import java.util.Map;
47 | import java.util.Objects;
48 |
49 |
50 | public class CometClient {
51 | private final SharedPreferences prefs;
52 | final ConnectionState connectionState;
53 | private SlimClient bayeuxClient;
54 | private String currentPlayer = null;
55 | private String subscribedPlayer = null;
56 | // Keep server details so that we can detect if changed
57 | private String serverAddress = "";
58 | private int serverPort = 9000;
59 | private String serverUser = "";
60 | private String serverPass = "";
61 | private final Handler backgroundHandler;
62 | private ControlService service;
63 | private JsonRpc rpc;
64 | private Response.Listener rpcResponse;
65 | private int handShakeFailures = 0;
66 |
67 | private static final int MAX_HANDSHAKE_FAILURES = 5;
68 | private static final String DEFAULT_RADIO_COVER = "/material/html/images/noradio.png";
69 | private static final String DEFAULT_COVER = "/material/html/images/nocover.png";
70 | private static final String DEFAULT_WORKS_COVER = "/material/html/images/nowork.png";
71 | private static final String RANDOMPLAY_COVER = "/material/html/images/randomplay.png";
72 | private static final String IMAGE_SIZE = "_600x600_f";
73 | private static final String PLAYER_STATUS_TAGS = "tags:acdlKN";
74 | private static final int HANDSHAKE_TIMEOUT = 4*1000;
75 | private static final int MSG_HANDSHAKE_TIMEOUT = 1;
76 | private static final int MSG_DISCONNECT = 2;
77 | private static final int MSG_RECONNECT = 3;
78 | private static final int MSG_SET_PLAYER = 4;
79 | private static final int MSG_PUBLISH = 5;
80 |
81 | private class PublishListener implements ClientSessionChannel.MessageListener {
82 | @Override
83 | public void onMessage(ClientSessionChannel channel, Message message) {
84 | if (!message.isSuccessful()) {
85 | if (Message.RECONNECT_HANDSHAKE_VALUE.equals(getAdviceAction(message.getAdvice()))) {
86 | Utils.info("rehandshake");
87 | bayeuxClient.rehandshake();
88 | } else {
89 | Map failure = getRecord(message, "failure");
90 | Exception exception = (failure != null) ? (Exception) failure.get("exception") : null;
91 | Utils.warn(channel + ": " + message.getJSON(), exception);
92 | }
93 | }
94 | }
95 | }
96 |
97 | private static class PublishMessage {
98 | final Object request;
99 | final String channel;
100 | final String responseChannel;
101 | final PublishListener publishListener;
102 |
103 | private PublishMessage(Object request, String channel, String responseChannel, PublishListener publishListener) {
104 | this.request = request;
105 | this.channel = channel;
106 | this.responseChannel = responseChannel;
107 | this.publishListener = publishListener;
108 | }
109 | }
110 |
111 | private class MessageHandler extends Handler {
112 | MessageHandler(Looper looper) {
113 | super(looper);
114 | }
115 |
116 | @Override
117 | public void handleMessage(android.os.Message msg) {
118 | Utils.debug(""+msg.what);
119 | switch (msg.what) {
120 | case MSG_HANDSHAKE_TIMEOUT:
121 | Utils.warn("Handshake timeout: " + connectionState);
122 | disconnectFromServer();
123 | break;
124 | case MSG_DISCONNECT:
125 | removeCallbacksAndMessages(null);
126 | disconnectFromServer();
127 | break;
128 | case MSG_RECONNECT:
129 | disconnectFromServer();
130 | connect();
131 | break;
132 | case MSG_SET_PLAYER:
133 | subscribeToPlayer((String)msg.obj);
134 | break;
135 | case MSG_PUBLISH: {
136 | PublishMessage message = (PublishMessage) msg.obj;
137 | doPublishMessage(message.request, message.channel, message.responseChannel, message.publishListener);
138 | break;
139 | }
140 | default:
141 | break;
142 | }
143 | }
144 | }
145 |
146 | public CometClient(ControlService service) {
147 | this.service = service;
148 | prefs = PreferenceManager.getDefaultSharedPreferences(service.getApplicationContext());
149 | connectionState = new ConnectionState();
150 | HandlerThread handlerThread = new HandlerThread(CometClient.class.getSimpleName());
151 | handlerThread.start();
152 | backgroundHandler = new MessageHandler(handlerThread.getLooper());
153 | }
154 |
155 | public synchronized void reconnectIfChanged() {
156 | Utils.debug("");
157 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY, null));
158 | boolean changed = !serverUser.equals(prefs.getString(LMS_USERNAME_KEY, "")) ||
159 | !serverPass.equals(prefs.getString(LMS_PASSWORD_KEY, "")) ||
160 | serverPort!=server.port || !serverAddress.equals(server.ip);
161 | if (changed) {
162 | disconnect(true);
163 | }
164 | }
165 |
166 | public synchronized boolean isConnected() {
167 | return connectionState.isConnected() && null!=bayeuxClient;
168 | }
169 |
170 | public synchronized void connect() {
171 | Utils.debug("");
172 | connectionState.setConnectionState(ConnectionState.State.CONNECTION_STARTED);
173 | backgroundHandler.post(() -> {
174 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY, null));
175 | if (null == server.ip) {
176 | connectionState.setConnectionError(ConnectionState.Error.INVALID_URL);
177 | return;
178 | }
179 |
180 | final HttpClient httpClient = new HttpClient();
181 | try {
182 | httpClient.start();
183 | } catch (Exception e) {
184 | connectionState.setConnectionError(ConnectionState.Error.START_CLIENT_ERROR);
185 | return;
186 | }
187 |
188 | serverAddress = server.ip;
189 | serverPort = server.port;
190 | String url = "http://"+serverAddress+":"+serverPort + "/cometd";
191 | Utils.debug("CometD URL: " + url);
192 | ClientTransport clientTransport = new HttpStreamingTransport(url, null, httpClient) {
193 | @Override
194 | protected void customize(org.eclipse.jetty.client.api.Request request) {
195 | serverUser = prefs.getString(LMS_USERNAME_KEY, "");
196 | serverPass= prefs.getString(LMS_PASSWORD_KEY, "");
197 |
198 | if (!serverUser.isEmpty() && !serverPass.isEmpty()) {
199 | request.header(HttpHeader.AUTHORIZATION, "Basic " + B64Code.encode(serverUser + ":" + serverPass));
200 | }
201 | }
202 | };
203 | bayeuxClient = new SlimClient(connectionState, url, clientTransport);
204 | bayeuxClient.addExtension(new BayeuxExtension());
205 | backgroundHandler.sendEmptyMessageDelayed(MSG_HANDSHAKE_TIMEOUT, HANDSHAKE_TIMEOUT);
206 | bayeuxClient.getChannel(Channel.META_HANDSHAKE).addListener((ClientSessionChannel.MessageListener) (channel, message) -> {
207 | handShakeFailures = message.isSuccessful() ? 0 : (handShakeFailures+1);
208 | Utils.debug("Handshake OK: " + message.isSuccessful() + ", canRehandshake: " + connectionState.canRehandshake() + ", failures:" +handShakeFailures);
209 | if (message.isSuccessful()) {
210 | onConnected();
211 | } else if (handShakeFailures>=MAX_HANDSHAKE_FAILURES && Utils.isNetworkConnected(service)) {
212 | Utils.error("Too many handshake errors, aborting");
213 | handShakeFailures = 0;
214 | try {
215 | clientTransport.abort();
216 | try {
217 | httpClient.stop();
218 | } catch (Exception e) {
219 | Utils.error("Failed to stop HTTP client", e);
220 | }
221 | bayeuxClient.stop();
222 | bayeuxClient = null;
223 | if (!MainActivity.isActive() && !SettingsActivity.isVisible()) {
224 | Utils.debug("UI is not visible, so terminate");
225 | service.quit();
226 | }
227 | } catch (Exception e) {
228 | Utils.error("Aborting", e);
229 | }
230 | connectionState.setConnectionState(ConnectionState.State.DISCONNECTED);
231 | } else if (!connectionState.canRehandshake()) {
232 | handShakeFailures = 0;
233 | Map failure = getRecord(message, "failure");
234 | Message failedMessage = (failure != null) ? (Message) failure.get("message") : message;
235 | // Advices are handled internally by the bayeux protocol, so skip these here
236 | if (failedMessage != null && getAdviceAction(failedMessage.getAdvice()) == null) {
237 | Utils.warn("Unsuccessful message on handshake channel: " + message.getJSON());
238 | disconnect();
239 | }
240 | }
241 | });
242 | bayeuxClient.getChannel(Channel.META_CONNECT).addListener((ClientSessionChannel.MessageListener) (channel, message) -> {
243 | Utils.debug("Connect OK? " + message.isSuccessful());
244 | // Advices are handled internally by the bayeux protocol, so skip these here
245 | if (!message.isSuccessful() && (getAdviceAction(message.getAdvice()) == null)) {
246 | Utils.warn("Unsuccessful message on connect channel: " + message.getJSON());
247 | disconnect();
248 | }
249 | });
250 | bayeuxClient.handshake();
251 | });
252 | }
253 |
254 | @SuppressWarnings("unchecked")
255 | private static Map getRecord(Map record, String name) {
256 | Object rec = record.get(name);
257 | return rec instanceof Map ? (Map) rec : null;
258 | }
259 |
260 | private static String getAdviceAction(Map advice) {
261 | if (advice != null && advice.containsKey(Message.RECONNECT_FIELD)) {
262 | return (String) advice.get(Message.RECONNECT_FIELD);
263 | }
264 | return null;
265 | }
266 |
267 | public synchronized void setPlayer(String id) {
268 | currentPlayer = id;
269 | if (bayeuxClient != null) {
270 | backgroundHandler.sendMessage(android.os.Message.obtain(null, MSG_SET_PLAYER, id));
271 | }
272 | }
273 |
274 | private synchronized void subscribeToPlayer(String id) {
275 | currentPlayer = id;
276 | if (null==id) {
277 | unsubscribePlayer(subscribedPlayer);
278 | } else if (!id.equals(subscribedPlayer)) {
279 | unsubscribePlayer(subscribedPlayer);
280 | subscribePlayer(id);
281 | }
282 | }
283 |
284 | public void disconnect() {
285 | disconnect(false);
286 | }
287 |
288 | private void disconnect(boolean andReconnect) {
289 | Utils.debug("connected:"+connectionState.isConnected());
290 | if (bayeuxClient != null && connectionState.isConnected()) {
291 | backgroundHandler.sendEmptyMessage(andReconnect ? MSG_RECONNECT : MSG_DISCONNECT);
292 | }
293 | connectionState.setConnectionState(ConnectionState.State.DISCONNECTED);
294 | }
295 |
296 | private synchronized void disconnectFromServer() {
297 | if (bayeuxClient != null) {
298 | String[] channels = new String[]{Channel.META_HANDSHAKE, Channel.META_CONNECT};
299 | for (String channelId : channels) {
300 | ClientSessionChannel channel = bayeuxClient.getChannel(channelId);
301 | for (ClientSessionChannel.ClientSessionChannelListener listener : channel.getListeners()) {
302 | channel.removeListener(listener);
303 | }
304 | channel.unsubscribe();
305 | }
306 | bayeuxClient.disconnect();
307 | bayeuxClient = null;
308 | }
309 | subscribedPlayer = null;
310 | }
311 |
312 | private synchronized void onConnected() {
313 | Utils.debug("currentPlayer:"+currentPlayer);
314 | subscribedPlayer = null;
315 | connectionState.setConnectionState(ConnectionState.State.CONNECTION_COMPLETED);
316 | bayeuxClient.getChannel("/"+bayeuxClient.getId() + "/slim/playerstatus/*").subscribe(this::handlePlayerStatus);
317 | subscribeToPlayer(currentPlayer);
318 | backgroundHandler.removeMessages(MSG_HANDSHAKE_TIMEOUT);
319 | }
320 |
321 | private void publishMessage(Object request, final String channel, final String responseChannel, final PublishListener publishListener) {
322 | // Make sure all requests are done in the handler thread
323 | if (backgroundHandler.getLooper() == Looper.myLooper()) {
324 | doPublishMessage(request, channel, responseChannel, publishListener);
325 | } else {
326 | PublishMessage publishMessage = new PublishMessage(request, channel, responseChannel, publishListener);
327 | android.os.Message message = backgroundHandler.obtainMessage(MSG_PUBLISH, publishMessage);
328 | backgroundHandler.sendMessage(message);
329 | }
330 | }
331 |
332 | private void doPublishMessage(Object request, String channel, String responseChannel, PublishListener publishListener) {
333 | Map data = new HashMap<>();
334 | if (request != null) {
335 | data.put("request", request);
336 | data.put("response", responseChannel);
337 | } else {
338 | data.put("unsubscribe", responseChannel);
339 | }
340 | bayeuxClient.getChannel(channel).publish(data, publishListener);
341 | }
342 |
343 | private void subscribePlayer(String id) {
344 | Utils.debug("ID:"+id+", connected:"+connectionState.isConnected());
345 | if (null!=id && !id.isEmpty() && connectionState.isConnected() && !id.equals(subscribedPlayer)) {
346 | List