Compare commits

..

No commits in common. "develop" and "4.2.2" have entirely different histories.

323 changed files with 7708 additions and 9706 deletions

224
.circleci/config.yml Normal file
View File

@ -0,0 +1,224 @@
version: 2.1
parameters:
memory-config:
type: string
default: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g"
memory-config-debug:
type: string
default: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g -verbose:gc -Xlog:gc*"
jobs:
Done in GitLab CI:
docker:
- image: busybox:stable
steps:
- run:
name: Done in GitLab CI
command: echo This build will be done in GitLab CI
Check Style:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Check Style
command: ./gradlew -Pqc ktlintCheck
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
Static Analysis:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Check Style
command: ./gradlew -Pqc detekt
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
Lint:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Lint
command: ./gradlew :ultrasonic:lintRelease
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
Unit Tests:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Check Style
command: ./gradlew ciTest testDebugUnitTest
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
Assemble Debug:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Assemble Debug
command: ./gradlew assembleDebug
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
Assemble Release:
docker:
- image: cimg/android:2023.02.1
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
keys:
- ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- ultrasonic-{{ .Branch }}
- ultrasonic
- run:
name: Assemble Release
command: ./gradlew assembleRelease
- save_cache:
paths:
- ~/.gradle
key: ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- store_artifacts:
path: ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk
destination: ultrasonic-release-unsigned
workflows:
version: 2
Check and Build:
jobs:
- Done in GitLab CI:
filters:
branches:
only:
- develop
- master
tags:
only: /.*/
- Check Style:
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/
- Static Analysis:
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/
- Lint:
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/
- Unit Tests:
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/
- Assemble Debug:
requires:
- Check Style
- Static Analysis
- Lint
- Unit Tests
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/
- Assemble Release:
requires:
- Check Style
- Static Analysis
- Lint
- Unit Tests
filters:
branches:
ignore:
- develop
- master
tags:
ignore: /.*/

View File

@ -1,2 +0,0 @@
[*.{kt,kts}]
ktlint_code_style = android_studio

1
.gitignore vendored
View File

@ -18,7 +18,6 @@ out/
# Gradle files
.gradle/
.kotlin/
build/
# Local configuration file (sdk path, etc)

View File

@ -1,9 +1,7 @@
default:
image: registry.gitlab.com/ultrasonic/ci-android:1.2.0
cache: &global_cache
key:
files:
- gradle/wrapper/gradle-wrapper.properties
image: ${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}/cimg/android:2023.02.1
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .gradle/
@ -17,79 +15,83 @@ variables:
PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/Ultrasonic/${CI_COMMIT_TAG}"
PACKAGE_APK: "ultrasonic-${CI_COMMIT_TAG}.apk"
PACKAGE_APK_IDSIG: "ultrasonic-${CI_COMMIT_TAG}.apk.idsig"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
# The project id of https://gitlab.com/ultrasonic/ultrasonic/
ROOT_PROJECT_ID: 37671564
stages:
- Check
- Build
- Sign APK
- Assemble
- Translations
- APK
- Publish
- Release
Check Style:
stage: Check
script: ./gradlew -Pqc ktlintCheck
cache:
# inherit all global cache settings
<<: *global_cache
policy: pull
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != $ROOT_PROJECT_ID
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Static Analysis:
stage: Check
script: ./gradlew -Pqc detekt
cache:
# inherit all global cache settings
<<: *global_cache
policy: pull
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != $ROOT_PROJECT_ID
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Lint:
stage: Check
script: ./gradlew :ultrasonic:lintRelease
cache:
# inherit all global cache settings
<<: *global_cache
policy: pull-push
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != $ROOT_PROJECT_ID
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Unit Tests:
stage: Check
script: ./gradlew ciTest testDebugUnitTest
cache:
# inherit all global cache settings
<<: *global_cache
policy: pull-push
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != $ROOT_PROJECT_ID
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Assemble Debug:
stage: Assemble
script: ./gradlew assembleDebug
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Assemble Release:
stage: Build
script:
- sed -i 's/applicationId \"org.moire.ultrasonic\"/applicationId "org.moire.ultrasonic.gitlab"/' ultrasonic/build.gradle
- ./gradlew assembleRelease
stage: Assemble
script: ./gradlew assembleRelease
artifacts:
name: ultrasonic-release-unsigned-${CI_COMMIT_SHA}
paths:
- ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk
rules:
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != $ROOT_PROJECT_ID
- if: $CI_COMMIT_REF_NAME == "develop" || $CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_TAG || $CI_PROJECT_ID != "37671564"
Push Translations:
stage: Translations
image: alpine:latest
script:
- wget -O /tmp/tx-linux-amd64.tar.gz https://github.com/transifex/cli/releases/latest/download/tx-linux-amd64.tar.gz
- tar xf /tmp/tx-linux-amd64.tar.gz -C /tmp
- mv /tmp/tx /usr/bin/tx
- tx push -s
rules:
- if: $CI_COMMIT_REF_NAME == "develop" && $CI_PROJECT_ID == "37671564"
Generate Signed Develop APK:
stage: APK
script:
- openssl aes-256-cbc -K ${ULTRASONIC_KEYSTORE_KEY} -iv ${ULTRASONIC_KEYSTORE_IV} -in ultrasonic-keystore.enc -out ultrasonic-keystore -d
- mkdir -p ${CI_PROJECT_DIR}/ultrasonic-release
- ${ANDROID_HOME}/build-tools/*/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk ${CI_PROJECT_DIR}/ultrasonic-release/ultrasonic-${CI_COMMIT_SHA}.apk
- ${ANDROID_HOME}/build-tools/*/apksigner sign --verbose --ks ${CI_PROJECT_DIR}/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} ${CI_PROJECT_DIR}/ultrasonic-release/ultrasonic-${CI_COMMIT_SHA}.apk
- ${ANDROID_HOME}/build-tools/*/apksigner verify --verbose ${CI_PROJECT_DIR}/ultrasonic-release/ultrasonic-${CI_COMMIT_SHA}.apk
artifacts:
name: ultrasonic-${CI_COMMIT_SHA}
paths:
- ultrasonic-release/
rules:
- if: $CI_COMMIT_REF_NAME == "develop" && $CI_PROJECT_ID == "37671564"
# We generate a signed package for each commit to develop as well as when making a release.
# Since the develop signed apk are not persistent they can be downloaded for around 3 weeks before Gitlab deletes them.
Generate Signed APK:
stage: Sign APK
# We don't need the gradle cache here
cache: []
stage: APK
script:
- openssl aes-256-cbc -K ${ULTRASONIC_KEYSTORE_KEY} -iv ${ULTRASONIC_KEYSTORE_IV} -in ultrasonic-keystore.enc -out ultrasonic-keystore -d
- mkdir -p ${CI_PROJECT_DIR}/ultrasonic-release
@ -97,17 +99,11 @@ Generate Signed APK:
- ${ANDROID_HOME}/build-tools/*/apksigner sign --verbose --ks ${CI_PROJECT_DIR}/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} ${CI_PROJECT_DIR}/ultrasonic-release/${PACKAGE_APK}
- ${ANDROID_HOME}/build-tools/*/apksigner verify --verbose ${CI_PROJECT_DIR}/ultrasonic-release/${PACKAGE_APK}
artifacts:
name: $PACKAGE_APK
name: ultrasonic-${CI_COMMIT_TAG}
paths:
- ultrasonic-release/
rules:
# Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
# Or when adding a new commit to develop (but never inside merge events)
- if: $CI_COMMIT_REF_NAME == "develop" && $CI_PROJECT_ID == $ROOT_PROJECT_ID && $CI_PIPELINE_SOURCE != "merge_request_event"
variables:
PACKAGE_APK: ultrasonic-${CI_COMMIT_SHA}.apk
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == "37671564"
Publish Signed APK:
stage: Publish
@ -118,7 +114,7 @@ Publish Signed APK:
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ultrasonic-release/${PACKAGE_APK_IDSIG} "${PACKAGE_REGISTRY_URL}/${PACKAGE_APK_IDSIG}"
rules:
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == "37671564"
Release:
stage: Release
@ -128,24 +124,4 @@ Release:
--assets-link "{\"name\":\"${PACKAGE_APK}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${PACKAGE_APK}\"}" \
--assets-link "{\"name\":\"${PACKAGE_APK_IDSIG}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${PACKAGE_APK_IDSIG}\"}"
rules:
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
RoboTest:
stage: Release
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:latest
# We don't need the gradle cache here
cache: []
script:
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
- gcloud auth activate-service-account --key-file .secure_files/firebase-key.json
- gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape
rules:
# Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
# or when requested by using [ROBO] inside the commit message and merging to develop
# Would be nice to be able to run it in a MR as well, but currently not possible
# Because it would not have access to the protected keys.
- if: $CI_COMMIT_MESSAGE =~ /^\[ROBO\].*/ && $CI_PROJECT_ID == $ROOT_PROJECT_ID && $CI_COMMIT_REF_NAME == "develop" && $CI_PIPELINE_SOURCE != "merge_request_event"
variables:
PACKAGE_APK: ultrasonic-${CI_COMMIT_SHA}.apk
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == "37671564"

View File

@ -1,3 +1,4 @@
<!-- Please describe your changes here -->
----------------------------------------------------------------------------
@ -6,6 +7,8 @@
- [ ] I ran `./gradlew -Pqc ktlintCheck`, `./gradlew -Pqc detekt` and
`./gradlew :ultrasonic:lintRelease` and no problems found. See
[CONTRIBUTING](CONTRIBUTING.md) for further information.
- [ ] I'm using my own branch in my local copy. Ej, I want to merge
`myuser/ultrasonic:my-new-contribution` into `develop`.
- [ ] All commits [are
signed](https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/).
- [ ] I agree to release my code and all other changes of this MR under the

View File

@ -1,10 +0,0 @@
#### Before merge:
- [ ] MR is targetting the master branch
- [ ] **Squash commits must be disabled!**
- [ ] RoboTests (5 physical, 10 virtual) on a Release apk return no errors
- [ ] Release notes present
#### After merge
- [ ] ``git fetch``
- [ ] Create an annotated and signed tag: ``git tag -sa``
- [ ] Push the tag to git:``git push --tags``

View File

@ -1,6 +1,11 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

2
.idea/compiler.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="11" />
</component>
</project>

10
.tx/config Normal file
View File

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
lang_map = fr_CA: fr-rCA, fr_FR: fr, es_ES: es, km_KH: km-rKH, th_TH: th, pl_PL: pl, zh_TW: zh-rTW, ru_RU: ru, pt_BR: pt-rBR, pt_PT: pt, he: iw, zh_HK: zh-rHK, da_DK: da-rDK, de_DE: de, bg_BG: bg, sr: sr, nl_NL: nl, zh_CN: zh-rCN, sv_SE: sv-rSE, el_GR: el, lt_LT: lt, it_IT: it, hu_HU: hu, kn_IN: kn-rIN, tr_TR: tr, cs_CZ: cs, id: in
[o:ultrasonic:p:ultrasonic:r:app]
file_filter = ultrasonic/src/main/res/values-<lang>/strings.xml
source_file = ultrasonic/src/main/res/values/strings.xml
source_lang = en
type = ANDROID

View File

@ -11,7 +11,7 @@ issue](https://gitlab.com/ultrasonic/ultrasonic/issues/new).
## Contributing Translations
Interested in help to translate Ultrasonic? You can contribute in our
[Weblate team](https://hosted.weblate.org/projects/ultrasonic/).
[Transifex team](https://www.transifex.com/ultrasonic/ultrasonic/).
## Contributing Code

View File

@ -31,6 +31,12 @@ If you want to use the version downloaded from F-Droid or from GitLab with
First, see if your issue havent been yet reported [here][issues], otherwise
open [a new issue][newissue].
### Known (not our) bugs
If you are using *Madsonic 5.1.X* several sections of Ultrasonic will not
work. This is caused by bad implementation of Subsonic API by Madsonic. For
more info about this you can read [this bug][madbug].
## Contributing
See [CONTRIBUTING](CONTRIBUTING.md).
@ -56,6 +62,7 @@ Full text of the license is available in the [LICENSE](LICENSE) file and
[wikiaa]: https://gitlab.com/ultrasonic/ultrasonic/-/wikis/Using-Ultrasonic-with-Android-Auto
[issues]: https://gitlab.com/ultrasonic/ultrasonic/-/issues
[newissue]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/new
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
[subsonic]: http://www.subsonic.org/
[subapi]: http://www.subsonic.org/pages/api.jsp
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced

View File

@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
apply from: 'gradle/versions.gradle'
@ -12,8 +10,8 @@ buildscript {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url = "https://plugins.gradle.org/m2/" }
maven { url "https://plugins.gradle.org/m2/" }
maven { url 'https://jitpack.io' }
}
dependencies {
classpath libs.gradle
@ -29,32 +27,25 @@ allprojects {
buildscript {
repositories {
mavenCentral()
gradlePluginPortal()
google()
}
}
repositories {
mavenCentral()
gradlePluginPortal()
google()
maven { url 'https://jitpack.io' }
}
// Set Kotlin JVM target to the same for all subprojects
tasks.withType(KotlinCompile).configureEach {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "21"
}
}
tasks.withType(JavaCompile).tap {
configureEach {
options.compilerArgs.add("-Xlint:deprecation")
jvmTarget = "11"
}
}
}
wrapper {
gradleVersion = libs.versions.gradle.get()
distributionType = "all"
}
gradleVersion(libs.versions.gradle.get())
distributionType("all")
}

View File

@ -1,20 +1,12 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
dependencies {
implementation libs.core
implementation libs.roomRuntime
implementation libs.roomKtx
ksp libs.room
kapt libs.room
}
android {
namespace = 'org.moire.ultrasonic.subsonic.domain'
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
namespace 'org.moire.ultrasonic.subsonic.domain'
}

View File

@ -31,7 +31,7 @@ data class Album(
override var genre: String? = null,
override var starred: Boolean = false,
override var path: String? = null,
override var closeness: Int = 0
override var closeness: Int = 0,
) : MusicDirectory.Child() {
override var isDirectory = true
override var isVideo = false

View File

@ -13,7 +13,10 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
var name: String? = null
@JvmOverloads
fun getChildren(includeDirs: Boolean = true, includeFiles: Boolean = true): List<Child> {
fun getChildren(
includeDirs: Boolean = true,
includeFiles: Boolean = true
): List<Child> {
if (includeDirs && includeFiles) {
return toList()
}

View File

@ -1,14 +1,5 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.kotlinModule
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
dependencies {
api libs.retrofit
api libs.jacksonConverter
@ -22,6 +13,7 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.kluent
testImplementation libs.mockWebServer

View File

@ -8,8 +8,7 @@ import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
* Base class for integration tests for [SubsonicAPIClient] class.
*/
abstract class SubsonicAPIClientTest {
@JvmField @Rule
val mockWebServerRule = MockWebServerRule()
@JvmField @Rule val mockWebServerRule = MockWebServerRule()
protected lateinit var config: SubsonicClientConfiguration
protected lateinit var client: SubsonicAPIClient

View File

@ -11,8 +11,7 @@ import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
* Base class for testing [okhttp3.Interceptor] implementations.
*/
abstract class BaseInterceptorTest {
@Rule @JvmField
val mockWebServerRule = MockWebServerRule()
@Rule @JvmField val mockWebServerRule = MockWebServerRule()
lateinit var client: OkHttpClient

View File

@ -92,13 +92,7 @@ internal class ApiVersionCheckWrapper(
checkVersion(V1_4_0)
checkParamVersion(musicFolderId, V1_12_0)
return api.search2(
query,
artistCount,
artistOffset,
albumCount,
albumOffset,
songCount,
musicFolderId
query, artistCount, artistOffset, albumCount, albumOffset, songCount, musicFolderId
)
}
@ -114,13 +108,7 @@ internal class ApiVersionCheckWrapper(
checkVersion(V1_8_0)
checkParamVersion(musicFolderId, V1_12_0)
return api.search3(
query,
artistCount,
artistOffset,
albumCount,
albumOffset,
songCount,
musicFolderId
query, artistCount, artistOffset, albumCount, albumOffset, songCount, musicFolderId
)
}
@ -240,13 +228,7 @@ internal class ApiVersionCheckWrapper(
checkParamVersion(estimateContentLength, V1_8_0)
checkParamVersion(converted, V1_14_0)
return api.stream(
id,
maxBitRate,
format,
timeOffset,
videoSize,
estimateContentLength,
converted
id, maxBitRate, format, timeOffset, videoSize, estimateContentLength, converted
)
}
@ -353,9 +335,8 @@ internal class ApiVersionCheckWrapper(
private fun checkVersion(expectedVersion: SubsonicAPIVersions) {
// If it is true, it is probably the first call with this server
if (!isRealProtocolVersion) return
if (currentApiVersion < expectedVersion) {
if (currentApiVersion < expectedVersion)
throw ApiNotSupportedException(currentApiVersion)
}
}
private fun checkParamVersion(param: Any?, expectedVersion: SubsonicAPIVersions) {

View File

@ -90,7 +90,10 @@ interface SubsonicAPIDefinition {
): Call<SubsonicResponse>
@GET("setRating.view")
fun setRating(@Query("id") id: String, @Query("rating") rating: Int): Call<SubsonicResponse>
fun setRating(
@Query("id") id: String,
@Query("rating") rating: Int
): Call<SubsonicResponse>
@GET("getArtist.view")
fun getArtist(@Query("id") id: String): Call<GetArtistResponse>
@ -155,7 +158,8 @@ interface SubsonicAPIDefinition {
@Query("public") public: Boolean? = null,
@Query("songIdToAdd") songIdsToAdd: List<String>? = null,
@Query("songIndexToRemove") songIndexesToRemove: List<Int>? = null
): Call<SubsonicResponse>
):
Call<SubsonicResponse>
@GET("getPodcasts.view")
fun getPodcasts(
@ -223,7 +227,10 @@ interface SubsonicAPIDefinition {
@Streaming
@GET("getCoverArt.view")
fun getCoverArt(@Query("id") id: String, @Query("size") size: Long? = null): Call<ResponseBody>
fun getCoverArt(
@Query("id") id: String,
@Query("size") size: Long? = null
): Call<ResponseBody>
@Streaming
@GET("stream.view")

View File

@ -29,12 +29,10 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
V1_13_0("5.3", "1.13.0"),
V1_14_0("6.0", "1.14.0"),
V1_15_0("6.1", "1.15.0"),
V1_16_0("6.1.2", "1.16.0")
;
V1_16_0("6.1.2", "1.16.0");
companion object {
@JvmStatic
@Throws(IllegalArgumentException::class)
@JvmStatic @Throws(IllegalArgumentException::class)
fun getClosestKnownClientApiVersion(apiVersion: String): SubsonicAPIVersions {
val versionComponents = apiVersion.split(".")
@ -43,11 +41,8 @@ enum class SubsonicAPIVersions(val subsonicVersions: String, val restApiVersion:
try {
val majorVersion = versionComponents[0].toInt()
val minorVersion = versionComponents[1].toInt()
val patchVersion = if (versionComponents.size > 2) {
versionComponents[2].toInt()
} else {
0
}
val patchVersion = if (versionComponents.size > 2) versionComponents[2].toInt()
else 0
when (majorVersion) {
1 -> when {

View File

@ -48,10 +48,7 @@ class VersionAwareJacksonConverterFactory(
retrofit: Retrofit
): Converter<*, RequestBody>? {
return jacksonConverterFactory?.requestBodyConverter(
type,
parameterAnnotations,
methodAnnotations,
retrofit
type, parameterAnnotations, methodAnnotations, retrofit
)
}
@ -66,7 +63,7 @@ class VersionAwareJacksonConverterFactory(
}
}
class VersionAwareResponseBodyConverter<T>(
class VersionAwareResponseBodyConverter<T> (
private val notifier: (SubsonicAPIVersions) -> Unit = {},
private val adapter: ObjectReader
) : Converter<ResponseBody, T> {

View File

@ -6,7 +6,6 @@ import okhttp3.Interceptor.Chain
import okhttp3.Response
internal const val SOCKET_READ_TIMEOUT_DOWNLOAD = 30 * 1000
// Allow 20 seconds extra timeout pear MB offset.
internal const val TIMEOUT_MILLIS_PER_OFFSET_BYTE = 0.02

View File

@ -23,8 +23,7 @@ enum class AlbumListType(val typeName: String) {
SORTED_BY_ARTIST("alphabeticalByArtist"),
STARRED("starred"),
BY_YEAR("byYear"),
BY_GENRE("byGenre")
;
BY_GENRE("byGenre");
override fun toString(): String {
return typeName

View File

@ -16,8 +16,7 @@ enum class JukeboxAction(val action: String) {
CLEAR("clear"),
REMOVE("remove"),
SHUFFLE("shuffle"),
SET_GAIN("setGain")
;
SET_GAIN("setGain");
override fun toString(): String {
return action

View File

@ -10,8 +10,7 @@ class BookmarksResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("bookmarks")
private val bookmarksWrapper = BookmarkWrapper()
@JsonProperty("bookmarks") private val bookmarksWrapper = BookmarkWrapper()
val bookmarkList: List<Bookmark> get() = bookmarksWrapper.bookmarkList
}

View File

@ -10,8 +10,7 @@ class ChatMessagesResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("chatMessages")
private val wrapper = ChatMessagesWrapper()
@JsonProperty("chatMessages") private val wrapper = ChatMessagesWrapper()
val chatMessages: List<ChatMessage> get() = wrapper.messagesList
}

View File

@ -10,8 +10,7 @@ class GenresResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("genres")
private val genresWrapper = GenresWrapper()
@JsonProperty("genres") private val genresWrapper = GenresWrapper()
val genresList: List<Genre> get() = genresWrapper.genresList
}

View File

@ -11,8 +11,7 @@ class GetAlbumList2Response(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("albumList2")
private val albumWrapper2 = AlbumWrapper2()
@JsonProperty("albumList2") private val albumWrapper2 = AlbumWrapper2()
val albumList: List<Album>
get() = albumWrapper2.albumList

View File

@ -10,8 +10,7 @@ class GetAlbumListResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("albumList")
private val albumWrapper = AlbumWrapper()
@JsonProperty("albumList") private val albumWrapper = AlbumWrapper()
val albumList: List<Album>
get() = albumWrapper.albumList

View File

@ -10,8 +10,7 @@ class GetPodcastsResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("podcasts")
private val channelsWrapper = PodcastChannelWrapper()
@JsonProperty("podcasts") private val channelsWrapper = PodcastChannelWrapper()
val podcastChannels: List<PodcastChannel>
get() = channelsWrapper.channelsList

View File

@ -10,8 +10,7 @@ class GetRandomSongsResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("randomSongs")
private val songsWrapper = RandomSongsWrapper()
@JsonProperty("randomSongs") private val songsWrapper = RandomSongsWrapper()
val songsList
get() = songsWrapper.songsList

View File

@ -10,8 +10,7 @@ class GetSongsByGenreResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("songsByGenre")
private val songsByGenreList = SongsByGenreWrapper()
@JsonProperty("songsByGenre") private val songsByGenreList = SongsByGenreWrapper()
val songsList get() = songsByGenreList.songsList
}

View File

@ -11,13 +11,11 @@ class JukeboxResponse(
error: SubsonicError?,
var jukebox: JukeboxStatus = JukeboxStatus()
) : SubsonicResponse(status, version, error) {
@JsonSetter("jukeboxStatus")
fun setJukeboxStatus(jukebox: JukeboxStatus) {
@JsonSetter("jukeboxStatus") fun setJukeboxStatus(jukebox: JukeboxStatus) {
this.jukebox = jukebox
}
@JsonSetter("jukeboxPlaylist")
fun setJukeboxPlaylist(jukebox: JukeboxStatus) {
@JsonSetter("jukeboxPlaylist") fun setJukeboxPlaylist(jukebox: JukeboxStatus) {
this.jukebox = jukebox
}
}

View File

@ -10,8 +10,7 @@ class MusicFoldersResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("musicFolders")
private val wrapper = MusicFoldersWrapper()
@JsonProperty("musicFolders") private val wrapper = MusicFoldersWrapper()
val musicFolders get() = wrapper.musicFolders
}

View File

@ -10,8 +10,7 @@ class SharesResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("shares")
private val wrappedShares = SharesWrapper()
@JsonProperty("shares") private val wrappedShares = SharesWrapper()
val shares get() = wrappedShares.share
}

View File

@ -20,8 +20,7 @@ open class SubsonicResponse(
) {
@JsonDeserialize(using = Status.Companion.StatusJsonDeserializer::class)
enum class Status(val jsonValue: String) {
OK("ok"),
ERROR("failed");
OK("ok"), ERROR("failed");
companion object {
fun getStatusFromJson(jsonValue: String) =

View File

@ -10,8 +10,7 @@ class VideosResponse(
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("videos")
private val videosWrapper = VideosWrapper()
@JsonProperty("videos") private val videosWrapper = VideosWrapper()
val videosList: List<MusicDirectoryChild> get() = videosWrapper.videosList
}

View File

@ -18,9 +18,7 @@ class ProxyPasswordInterceptorTest {
private val proxyInterceptor = ProxyPasswordInterceptor(
V1_12_0,
mockPasswordHexInterceptor,
mockPasswordMd5Interceptor,
false
mockPasswordHexInterceptor, mockPasswordMd5Interceptor, false
)
@Test
@ -42,10 +40,8 @@ class ProxyPasswordInterceptorTest {
@Test
fun `Should use hex password if forceHex is true`() {
val interceptor = ProxyPasswordInterceptor(
V1_16_0,
mockPasswordHexInterceptor,
mockPasswordMd5Interceptor,
true
V1_16_0, mockPasswordHexInterceptor,
mockPasswordMd5Interceptor, true
)
interceptor.intercept(mockChain)

27
detekt-baseline.xml Normal file
View File

@ -0,0 +1,27 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType)</ID>
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
<ID>NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean )</ID>
<ID>TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</CurrentIssues>
</SmellBaseline>

View File

@ -52,11 +52,7 @@ style:
active: true
ForbiddenComment:
active: true
comments:
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
value: 'FIXME:'
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
value: 'STOPSHIP:'
values: ['FIXME:', 'STOPSHIP:']
WildcardImport:
active: true
MaxLineLength:

View File

@ -1,7 +0,0 @@
Bug fixes
- #831: Version 4.1.1 and develop: 'jukebox on/off' no longer shown in 'Now Playing'.
Enhancements
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.

View File

@ -1,4 +0,0 @@
Bug fixes
- Fix a crash when a ID3 tag date is in a wrong format.
- Fix a crash on API 31 (newest Android).
- Fix empty search results.

View File

@ -1,2 +0,0 @@
Bug fixes
- Fix a crash when downloading the album art.

View File

@ -1,8 +0,0 @@
Bug fixes
- Fix various crashes
Changes since 4.2.0
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.
- #854: Remove Videos menu option for servers which don't support it.

View File

@ -1,8 +0,0 @@
Bug fixes
- Fix more exceptions
Changes since 4.2.0
- #827: Make app full compliant Android Auto to publish in Play Store.
- #878: "Play shuffled" option for playlists always begins with the first track.
- #891: Dump config to log file when logging is enabled.
- #854: Remove Videos menu option for servers which don't support it.

View File

@ -1,10 +0,0 @@
Features:
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
- Use Coroutines for triggering the download or playback of music through the context menus
- Enable Artists pictures by Default
Bug fixes:
- Remove an unhelpful popup that "ID must be set"
- Shuffle mode doesn't always play all tracks
- Shuffle mode starts with the first track most of the time

View File

@ -1,10 +0,0 @@
Features:
- This releases focuses on shuffled playback. The view of the playlist will now present itself in the order it will actually play. You can toggle the shuffle mode to create a new order, while the past playback history will be preserved.
- Use Coroutines for triggering the download or playback of music through the context menus
- Enable Artists pictures by Default
Bug fixes:
- Remove an unhelpful popup that "ID must be set"
- Shuffle mode doesn't always play all tracks
- Shuffle mode starts with the first track most of the time

View File

@ -1,10 +0,0 @@
Features:
- Revamp management of ratings. Tracks can be starred from the notification in Android 13, and the changes will show up everywhere immediately.
- Add a setting to control the maximum bitrate when pinning music (can be used to avoid downloading lossless files like flac).
- Modernize the Jukebox player.
- The hardware keys can be used to set the Jukebox volume.
- The current playlist shows a spinner when loading takes some time
Bug fixes:
- Request correct bluetooth permission on Android 13 (needed to pause/play on connect)
- Update dependencies (OkHttp, Material)

View File

@ -1,12 +0,0 @@
Features:
- Search is accesible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active
- Upgrade material to 1.9.0

View File

@ -1,15 +0,0 @@
Features:
- Search is accessible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Readd the "Star" button to the Now Playing screen
- Fix a rare crash when shuffling playlists with duplicate entries
- Fix a crash when choosing "Play next" on an empty playlist.
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active
- Upgrade material to 1.9.0

View File

@ -1,15 +0,0 @@
Features:
- Search is accessible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Avoid triggering a bug in Supysonic
- Readd the "Star" button to the Now Playing screen
- Fix a rare crash when shuffling playlists with duplicate entries
- Fix a crash when choosing "Play next" on an empty playlist.
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active

View File

@ -1,15 +0,0 @@
Features:
- Search is accessible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Fix a few crashes
- Avoid triggering a bug in Supysonic
- Readd the "Star" button to the Now Playing screen
- Fix a rare crash when shuffling playlists with duplicate entries
- Fix a crash when choosing "Play next" on an empty playlist.
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active

View File

@ -1,9 +0,0 @@
### Features
- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto)
- Properly handling nested directory structures (Android Auto)
- Add a toast when adding tracks to the playlist
- Allow pinning when offline
### Dependencies
- Update koin
- Update media3 to v1.1.0

View File

@ -1,12 +0,0 @@
### Fixes
- Fix a bug in 4.7.0 that repeat mode was activated by default.
### Features
- Added custom buttons for shuffling the current queue and setting repeat mode (Android Auto)
- Properly handling nested directory structures (Android Auto)
- Add a toast when adding tracks to the playlist
- Allow pinning when offline
### Dependencies
- Update koin
- Update media3 to v1.1.0

View File

@ -1,5 +0,0 @@
### Features
- Improved display of rating stars
- Completely modernize all older code parts
- Updates for Android 14
- Update dependencies

View File

@ -1,17 +1,17 @@
Ultrasonic is a Subsonic (and compatible servers) client to Android. You can use Ultrasonic to connect with your server and listen music.
Main features:
* Small size & fast
* Material You theme with dark and light variants
* Thin
* Fast
* Dark and light theme
* Multiple server support
* Download tracks for offline playback
* Offline Mode
* Bookmarks
* Playlists on server
* Shuffled playback
* Ramdom play
* Jukebox mode
* And much more!!
Note: Ultrasonic uses semantic release versions. Releases with a zero in the last digit introduce new features or significant changes, all other releases focus on fixing bugs.
* Server chat
* And much more!!!
The source code is available with GPL license in GitLab: https://gitlab.com/ultrasonic/ultrasonic
If you have any issue, please post in: https://gitlab.com/ultrasonic/ultrasonic/issues

View File

@ -1,7 +0,0 @@
Corrección de errores
- #831: Versión 4.1.1 y desarrollo: 'jukebox on/off' ya no se muestra en 'Reproduciendo ahora'.
Mejoras
- #827: Hacer aplicación completamente compatible con Android Auto para publicar en Play Store.
- #878: "Reproducir aleatoriamente" ya no siempre comienza con la primera pista.
- #891: Volcado de configuración al archivo de registro cuando el registro está habilitado.

View File

@ -1,4 +0,0 @@
Corrección de errores
- Corrección de un fallo cuando la fecha de una etiqueta ID3 tiene un formato incorrecto.
- Corrección de un fallo en la API 31 (versión de Android más reciente).
- Corrección de resultados de búsqueda vacíos.

View File

@ -1,2 +0,0 @@
Corrección de errores
- Corrección de un fallo al descargar la carátula del álbum.

View File

@ -4,22 +4,10 @@ org.gradle.configureondemand=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
kotlin.incremental=true
kotlin.caching.enabled=true
kotlin.incremental.usePreciseJavaTracking=true
android.useAndroidX=true
android.enableJetifier=false
# This properties enables transitive Resource classes, which decreases build time,
# but could lead to problems referencing Resources. Set them to false if needed.
android.nonTransitiveRClass=true
android.nonFinalResIds=true
# This config was suggested by Android Studio to reduce build time
# It can be removed if it makes problems
org.gradle.unsafe.configuration-cache=true
# TODO Renable on day (check that Retrofit, Jackson, and Imageloader are working)
android.enableR8.fullMode=true

View File

@ -1,46 +1,44 @@
[versions]
# You need to run ./gradlew wrapper after updating the version
gradle = "8.13"
gradle = "7.6"
navigation = "2.8.9"
gradlePlugin = "8.9.1"
androidxcar = "1.4.0"
androidxcore = "1.16.0"
ktlint = "1.0.1"
ktlintGradle = "12.2.0"
detekt = "1.23.8"
preferences = "1.2.1"
media3 = "1.6.1"
navigation = "2.5.3"
gradlePlugin = "7.4.2"
androidxcore = "1.9.0"
ktlint = "0.43.2"
ktlintGradle = "11.3.1"
detekt = "1.22.0"
preferences = "1.2.0"
media3 = "1.0.0-rc02"
androidSupport = "1.9.1"
materialDesign = "1.12.0"
constraintLayout = "2.2.1"
activity = "1.10.1"
androidSupport = "1.6.0"
materialDesign = "1.8.0"
constraintLayout = "2.1.4"
multidex = "2.0.1"
room = "2.7.0"
kotlin = "2.1.20"
ksp = "2.1.20-2.0.0"
kotlinxCoroutines = "1.10.2"
viewModelKtx = "2.8.7"
room = "2.5.0"
kotlin = "1.8.10"
kotlinxCoroutines = "1.6.4"
kotlinxGuava = "1.6.4"
viewModelKtx = "2.6.0"
swipeRefresh = "1.1.0"
retrofit = "2.11.0"
jackson = "2.18.3"
okhttp = "4.12.0"
koin = "4.0.4"
retrofit = "2.9.0"
jackson = "2.14.2"
okhttp = "4.10.0"
koin = "3.3.2"
picasso = "2.8"
junit4 = "4.13.2"
junit5 = "5.12.2"
mockito = "5.17.0"
mockitoKotlin = "5.4.0"
kluent = "1.73"
apacheCodecs = "1.18.0"
robolectric = "4.14.1"
junit5 = "5.9.2"
mockito = "5.2.0"
mockitoKotlin = "4.1.0"
kluent = "1.72"
apacheCodecs = "1.15"
robolectric = "4.9.2"
timber = "5.0.1"
fastScroll = "2.0.1"
colorPicker = "2.3.0"
rxJava = "3.1.10"
colorPicker = "2.2.4"
rxJava = "3.1.6"
rxAndroid = "3.0.2"
multiType = "4.3.0"
@ -50,7 +48,6 @@ kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin"
ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" }
detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
car = { module = "androidx.car.app:app", version.ref = "androidxcar" }
core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" }
design = { module = "com.google.android.material:material", version.ref = "materialDesign" }
annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" }
@ -66,7 +63,6 @@ navigationFragmentKtx = { module = "androidx.navigation:navigation-fragment-kt
navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"}
activity = { module = "androidx.activity:activity-ktx", version.ref = "activity" }
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
media3common = { module = "androidx.media3:media3-common", version.ref = "media3" }
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
@ -77,7 +73,7 @@ swipeRefresh = { module = "androidx.swiperefreshlayout:swiperefreshla
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines"}
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxGuava"}
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
@ -99,11 +95,9 @@ junitVintage = { module = "org.junit.vintage:junit-vintage-engine", v
kotlinJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockitoInline = { module = "org.mockito:mockito-inline", version.ref = "mockito" }
kluent = { module = "org.amshove.kluent:kluent", version.ref = "kluent" }
kluentAndroid = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" }
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@ -1,5 +1,5 @@
ext.versions = [
minSdk : 26,
minSdk : 21,
targetSdk : 33,
compileSdk : 35,
]
compileSdk : 33,
]

Binary file not shown.

View File

@ -1,7 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -2,9 +2,9 @@
* This module provides a base for for submodules which depend on the Android runtime
*/
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'kotlin-android'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion versions.compileSdk
@ -16,8 +16,8 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
sourceSets {

View File

@ -25,8 +25,11 @@ if (isCodeQualityEnabled) {
// Builds the AST in parallel. Rules are always executed in parallel.
// Can lead to speedups in larger projects.
parallel = true
baseline = file("${rootProject.projectDir}/detekt-baseline.xml")
config = files("${rootProject.projectDir}/detekt-config.yml")
}
}
tasks.detekt.jvmTarget = "17"
tasks.detekt.jvmTarget = "11"
}
}

View File

@ -2,7 +2,7 @@
* This module provides a base for for pure kotlin modules
*/
apply plugin: 'kotlin'
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'kotlin-kapt'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
sourceSets {
@ -12,6 +12,7 @@ sourceSets {
test.resources.srcDirs += "${projectDir}/src/integrationTest/resources"
}
dependencies {
api libs.kotlinStdlib

33
gradlew vendored
View File

@ -15,8 +15,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -57,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -85,8 +83,10 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,13 +133,10 @@ location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@ -147,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@ -155,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -200,15 +197,11 @@ if "$cygwin" || "$msys" ; then
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View File

@ -13,8 +13,6 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@ -45,11 +43,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
@ -59,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail

View File

@ -1,9 +1,6 @@
plugins {
alias libs.plugins.ksp
}
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle"
@ -12,12 +9,12 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 130
versionName "4.8.0"
versionCode 112
versionName "4.2.2"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
resourceConfigurations += ['cs', 'de', 'en', 'es', 'fr', 'gl', 'hu', 'it', 'ja', 'nb-rNO', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW']
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'hu', 'it', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
}
bundle.language.enableSplit = false
@ -34,10 +31,10 @@ android {
'minify/proguard-kotlin.pro'
}
debug {
minifyEnabled = false
multiDexEnabled = true
testCoverageEnabled = true
applicationIdSuffix = '.debug'
minifyEnabled false
multiDexEnabled true
testCoverageEnabled true
applicationIdSuffix ".debug"
}
}
@ -53,43 +50,41 @@ android {
}
kotlinOptions {
jvmTarget = "21"
jvmTarget = "11"
}
buildFeatures {
viewBinding = true
dataBinding = true
buildConfig = true
viewBinding true
dataBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
ksp {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas")
kapt {
arguments {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
}
}
lint {
baseline = file("lint-baseline.xml")
abortOnError = true
warningsAsErrors = true
warning 'ImpliedQuantity'
abortOnError true
warningsAsErrors true
disable 'IconMissingDensityFolder', 'VectorPath'
disable 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity'
disable 'ObsoleteLintCustomCheck'
// We manage dependencies on Gitlab with RenovateBot
disable 'GradleDependency'
disable 'AndroidGradlePluginVersion'
textReport = true
checkDependencies = true
textReport true
checkDependencies true
}
namespace = 'org.moire.ultrasonic'
namespace 'org.moire.ultrasonic'
}
tasks.withType(Test).configureEach {
tasks.withType(Test) {
useJUnitPlatform()
}
@ -101,7 +96,6 @@ dependencies {
exclude group: "com.android.support"
}
implementation libs.car
implementation libs.core
implementation libs.design
implementation libs.multidex
@ -134,7 +128,7 @@ dependencies {
implementation libs.rxAndroid
implementation libs.multiType
ksp libs.room
kapt libs.room
testImplementation libs.kotlinReflect
testImplementation libs.junit
@ -142,9 +136,11 @@ dependencies {
testImplementation libs.kotlinJunit
testImplementation libs.kluent
testImplementation libs.mockito
testImplementation libs.mockitoInline
testImplementation libs.mockitoKotlin
testImplementation libs.robolectric
implementation libs.timber
}

View File

@ -1,19 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Failed to write log to %s", file)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Log file rotated, logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType)</ID>
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
</CurrentIssues>
</SmellBaseline>

View File

@ -1,5 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.0.1" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.1)" variant="all" version="8.0.1">
<issues format="6" by="lint 7.4.0" type="baseline" client="gradle" dependencies="true" name="AGP (7.4.0)" variant="all" version="7.4.0">
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" manager.notify(NOTIFICATION_ID, notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt"
line="260"
column="17"/>
</issue>
<issue
id="MissingPermission"
message="Call requires permission which may be rejected by user: code should explicitly check to see if permission is available (with `checkPermission`) or explicitly handle a potential `SecurityException`"
errorLine1=" notificationManagerCompat.notify(notification.notificationId, notification.notification)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
line="194"
column="9"/>
</issue>
<issue
id="PluralsCandidate"
@ -8,7 +30,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="151"
line="152"
column="5"/>
</issue>
@ -26,6 +48,50 @@
file="../core/subsonic-api/build/libs/subsonic-api.jar"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="128"
column="10"/>
</issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="133"
column="10"/>
</issue>
<issue
id="ExportedReceiver"
message="Exported receiver does not require permission"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="88"
column="10"/>
</issue>
<issue
id="ExportedService"
message="Exported service does not require permission"
errorLine1=" &lt;service android:name=&quot;.playback.PlaybackService&quot;"
errorLine2=" ~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="77"
column="10"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
@ -70,6 +136,61 @@
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_small_icon.xml"
line="1"
column="1"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/chat.xml"
line="33"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/save_playlist.xml"
line="9"
column="6"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="29"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/time_span_dialog.xml"
line="28"
column="10"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"

View File

@ -1,4 +1,5 @@
#### From Jackson
-keepattributes *Annotation*,EnclosingMethod,Signature
-keepnames class com.fasterxml.jackson.** {
*;

View File

@ -1,14 +1,8 @@
-dontobfuscate
### Don't remove subsonic api serializers/entities
-keep class org.moire.ultrasonic.api.subsonic.** { *; }
## Don't remove the domain models
-keep class org.moire.ultrasonic.domain.** { *; }
## Don't remove the imageloader
-keep class org.moire.ultrasonic.imageloader.** { *; }
-keep class org.moire.ultrasonic.provider.AlbumArtContentProvider { *; }
-keep class org.moire.ultrasonic.api.subsonic.response.** { *; }
-keep class org.moire.ultrasonic.api.subsonic.models.** { *; }
## Don't remove NowPlayingFragment
-keep class org.moire.ultrasonic.fragment.NowPlayingFragment { *; }

View File

@ -1,41 +1,10 @@
# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and
# EnclosingMethod is required to use InnerClasses.
-keepattributes Signature, InnerClasses, EnclosingMethod
#### From retrofit
# Retrofit does reflection on method and parameter annotations.
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
# Keep annotation default values (e.g., retrofit2.http.Field.encoded).
-keepattributes AnnotationDefault
# Retain service method parameters when optimizing.
-keepclassmembers,allowshrinking,allowobfuscation interface * {
# Retain generic type information for use by reflection by converters and adapters.
-keepattributes Signature
# Retain service method parameters.
-keepclassmembernames,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
# Ignore annotation used for build tooling.
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
# Ignore JSR 305 annotations for embedding nullability information.
-dontwarn javax.annotation.**
# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath.
-dontwarn kotlin.Unit
# Top-level functions that can only be used by Kotlin.
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface <1>
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

View File

@ -3,17 +3,15 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-sdk tools:overrideLibrary="androidx.car.app" />
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
@ -22,23 +20,18 @@
android:xlargeScreens="true"/>
<application
android:name=".app.UApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_descriptor"
android:hasFragileUserData="true"
android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/common.appname"
android:networkSecurityConfig="@xml/network_security_config"
android:preserveLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/Theme.Material3.DynamicColors.Dark"
android:name=".app.UApp"
android:label="@string/common.appname"
android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"
tools:targetApi="q">
<!-- Add for API 34 android:enableOnBackInvokedCallBack="true" -->
android:supportsRtl="false"
android:preserveLegacyExternalStorage="true"
tools:ignore="UnusedAttribute">
<meta-data android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
@ -73,12 +66,18 @@
android:exported="false">
</service>
<service
android:name=".service.JukeboxMediaPlayer"
android:label="Ultrasonic Jukebox Media Player Service"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".service.PlaybackService"
<service android:name=".playback.PlaybackService"
android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="ExportedService">
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
@ -87,8 +86,7 @@
</service>
<receiver android:name=".receiver.UltrasonicIntentReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
android:exported="true">
<intent-filter>
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
@ -109,12 +107,6 @@
<action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/>
</intent-filter>
</receiver>
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider"
android:label="Ultrasonic"
@ -127,16 +119,21 @@
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info"/>
</receiver>
<receiver android:name=".receiver.MediaButtonIntentReceiver"
android:exported="true">
<intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<provider
android:name=".provider.SearchSuggestionProvider"
android:authorities="${applicationId}.provider.SearchSuggestionProvider"
android:exported="true"
tools:ignore="ExportedContentProvider" />
android:exported="true" />
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="${applicationId}.provider.AlbumArtContentProvider"
android:exported="true"
tools:ignore="ExportedContentProvider" />
android:exported="true" />
</application>
</manifest>

View File

@ -0,0 +1,297 @@
package org.moire.ultrasonic.fragment;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.ListAdapter;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.ChatMessage;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import org.moire.ultrasonic.util.BackgroundTask;
import org.moire.ultrasonic.util.CancellationToken;
import org.moire.ultrasonic.util.FragmentBackgroundTask;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
import org.moire.ultrasonic.view.ChatAdapter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
import com.google.android.material.button.MaterialButton;
/**
* Provides online chat functionality
*/
public class ChatFragment extends Fragment {
private ListView chatListView;
private EditText messageEditText;
private MaterialButton sendButton;
private Timer timer;
private volatile static Long lastChatMessageTime = (long) 0;
private static final ArrayList<ChatMessage> messageList = new ArrayList<>();
private CancellationToken cancellationToken;
private SwipeRefreshLayout swipeRefresh;
private final Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
Util.applyTheme(this.getContext());
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
return inflater.inflate(R.layout.chat, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
swipeRefresh = view.findViewById(R.id.chat_refresh);
swipeRefresh.setEnabled(false);
cancellationToken = new CancellationToken();
messageEditText = view.findViewById(R.id.chat_edittext);
sendButton = view.findViewById(R.id.chat_send);
sendButton.setOnClickListener(view1 -> sendMessage());
chatListView = view.findViewById(R.id.chat_entries_list);
chatListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_ALWAYS_SCROLL);
chatListView.setStackFromBottom(true);
String serverName = activeServerProvider.getValue().getActiveServer().getName();
String userName = activeServerProvider.getValue().getActiveServer().getUserName();
String title = String.format("%s [%s@%s]", getResources().getString(R.string.button_bar_chat), userName, serverName);
FragmentTitle.Companion.setTitle(this, title);
setHasOptionsMenu(true);
messageEditText.setImeActionLabel("Send", KeyEvent.KEYCODE_ENTER);
messageEditText.addTextChangedListener(new TextWatcher()
{
@Override
public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2)
{
}
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1, int i2)
{
}
@Override
public void afterTextChanged(Editable editable)
{
sendButton.setEnabled(!Util.isNullOrWhiteSpace(editable.toString()));
}
});
messageEditText.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE || (actionId == EditorInfo.IME_NULL && event.getAction() == KeyEvent.ACTION_DOWN))
{
sendMessage();
return true;
}
return false;
});
load();
timerMethod();
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.chat, menu);
super.onCreateOptionsMenu(menu, inflater);
}
/*
* Listen for option item selections so that we receive a notification
* when the user requests a refresh by selecting the refresh action bar item.
*/
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Check if user triggered a refresh:
if (item.getItemId() == R.id.menu_refresh) {
// Start the refresh background task.
load();
return true;
}
// User didn't trigger a refresh, let the superclass handle this action
return super.onOptionsItemSelected(item);
}
@Override
public void onResume()
{
super.onResume();
if (!messageList.isEmpty())
{
ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList);
chatListView.setAdapter(chatAdapter);
}
if (timer == null)
{
timerMethod();
}
}
@Override
public void onPause()
{
super.onPause();
if (timer != null)
{
timer.cancel();
timer = null;
}
}
@Override
public void onDestroyView() {
cancellationToken.cancel();
super.onDestroyView();
}
private void timerMethod()
{
int refreshInterval = Settings.getChatRefreshInterval();
if (refreshInterval > 0)
{
timer = new Timer();
timer.schedule(new TimerTask()
{
@Override
public void run()
{
getActivity().runOnUiThread(() -> load());
}
}, refreshInterval, refreshInterval);
}
}
private void sendMessage()
{
if (messageEditText != null)
{
final String message;
Editable text = messageEditText.getText();
if (text == null)
{
return;
}
message = text.toString();
if (!Util.isNullOrWhiteSpace(message))
{
messageEditText.setText("");
BackgroundTask<Void> task = new FragmentBackgroundTask<Void>(getActivity(), false, swipeRefresh, cancellationToken)
{
@Override
protected Void doInBackground() throws Throwable
{
MusicService musicService = MusicServiceFactory.getMusicService();
musicService.addChatMessage(message);
return null;
}
@Override
protected void done(Void result)
{
load();
}
};
task.execute();
}
}
}
private synchronized void load()
{
BackgroundTask<List<ChatMessage>> task = new FragmentBackgroundTask<List<ChatMessage>>(getActivity(), false, swipeRefresh, cancellationToken)
{
@Override
protected List<ChatMessage> doInBackground() throws Throwable
{
MusicService musicService = MusicServiceFactory.getMusicService();
return musicService.getChatMessages(lastChatMessageTime);
}
@Override
protected void done(List<ChatMessage> result)
{
if (result != null && !result.isEmpty())
{
// Reset lastChatMessageTime if we have a newer message
for (ChatMessage message : result)
{
if (message.getTime() > lastChatMessageTime)
{
lastChatMessageTime = message.getTime();
}
}
// Reverse results to show them on the bottom
Collections.reverse(result);
messageList.addAll(result);
ListAdapter chatAdapter = new ChatAdapter(getContext(), messageList);
chatListView.setAdapter(chatAdapter);
}
}
@Override
protected void error(Throwable error) {
// Stop the timer in case of an error, otherwise it may repeat the error message forever
if (timer != null)
{
timer.cancel();
timer = null;
}
super.error(error);
}
};
task.execute();
}
}

View File

@ -0,0 +1,37 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.provider;
import android.content.SearchRecentSuggestionsProvider;
/**
* Provides search suggestions based on recent searches.
*
* @author Sindre Mehus
*/
public class SearchSuggestionProvider extends SearchRecentSuggestionsProvider
{
public static final String AUTHORITY = SearchSuggestionProvider.class.getName();
public static final int MODE = DATABASE_MODE_QUERIES;
public SearchSuggestionProvider()
{
setupSuggestions(AUTHORITY, MODE);
}
}

View File

@ -0,0 +1,100 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.receiver;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.Settings;
import timber.log.Timber;
/**
* Resume or pause playback on Bluetooth A2DP connect/disconnect.
*
* @author Sindre Mehus
*/
@SuppressLint("MissingPermission")
public class BluetoothIntentReceiver extends BroadcastReceiver
{
@Override
public void onReceive(Context context, Intent intent)
{
int state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1);
BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
String action = intent.getAction();
String name = device != null ? device.getName() : "Unknown";
String address = device != null ? device.getAddress() : "Unknown";
Timber.d("A2DP State: %d; Action: %s; Device: %s; Address: %s", state, action, name, address);
boolean actionBluetoothDeviceConnected = false;
boolean actionBluetoothDeviceDisconnected = false;
boolean actionA2dpConnected = false;
boolean actionA2dpDisconnected = false;
if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action))
{
actionBluetoothDeviceConnected = true;
}
else if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action) || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED.equals(action))
{
actionBluetoothDeviceDisconnected = true;
}
if (state == android.bluetooth.BluetoothA2dp.STATE_CONNECTED) actionA2dpConnected = true;
else if (state == android.bluetooth.BluetoothA2dp.STATE_DISCONNECTED) actionA2dpDisconnected = true;
boolean resume = false;
boolean pause = false;
switch (Settings.getResumeOnBluetoothDevice())
{
case Constants.PREFERENCE_VALUE_ALL: resume = actionA2dpConnected || actionBluetoothDeviceConnected;
break;
case Constants.PREFERENCE_VALUE_A2DP: resume = actionA2dpConnected;
break;
}
switch (Settings.getPauseOnBluetoothDevice())
{
case Constants.PREFERENCE_VALUE_ALL: pause = actionA2dpDisconnected || actionBluetoothDeviceDisconnected;
break;
case Constants.PREFERENCE_VALUE_A2DP: pause = actionA2dpDisconnected;
break;
}
if (resume)
{
Timber.i("Connected to Bluetooth device %s address %s, resuming playback.", name, address);
context.sendBroadcast(new Intent(Constants.CMD_RESUME_OR_PLAY).setPackage(context.getPackageName()));
}
if (pause)
{
Timber.i("Disconnected from Bluetooth device %s address %s, requesting pause.", name, address);
context.sendBroadcast(new Intent(Constants.CMD_PAUSE).setPackage(context.getPackageName()));
}
}
}

View File

@ -0,0 +1,38 @@
package org.moire.ultrasonic.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import timber.log.Timber;
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
public class UltrasonicIntentReceiver extends BroadcastReceiver
{
private Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
@Override
public void onReceive(Context context, Intent intent)
{
String intentAction = intent.getAction();
Timber.i("Received Ultrasonic Intent: %s", intentAction);
try
{
lifecycleSupport.getValue().receiveIntent(intent);
if (isOrderedBroadcast())
{
abortBroadcast();
}
}
catch (Exception x)
{
// Ignored.
}
}
}

View File

@ -0,0 +1,53 @@
package org.moire.ultrasonic.service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import org.moire.ultrasonic.app.UApp;
import timber.log.Timber;
/**
* Monitors the state of the mobile's external storage
*/
public class ExternalStorageMonitor
{
private BroadcastReceiver ejectEventReceiver;
private boolean externalStorageAvailable = true;
public void onCreate(final Runnable ejectedCallback)
{
// Stop when SD card is ejected.
ejectEventReceiver = new BroadcastReceiver()
{
@Override
public void onReceive(Context context, Intent intent)
{
externalStorageAvailable = Intent.ACTION_MEDIA_MOUNTED.equals(intent.getAction());
if (!externalStorageAvailable)
{
Timber.i("External media is ejecting. Stopping playback.");
ejectedCallback.run();
}
else
{
Timber.i("External media is available.");
}
}
};
IntentFilter ejectFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT);
ejectFilter.addAction(Intent.ACTION_MEDIA_MOUNTED);
ejectFilter.addDataScheme("file");
UApp.Companion.applicationContext().registerReceiver(ejectEventReceiver, ejectFilter);
}
public void onDestroy()
{
UApp.Companion.applicationContext().unregisterReceiver(ejectEventReceiver);
}
public boolean isExternalStorageAvailable() { return externalStorageAvailable; }
}

View File

@ -0,0 +1,50 @@
package org.moire.ultrasonic.service;
import timber.log.Timber;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.Track;
/**
* Scrobbles played songs to Last.fm.
*
* @author Sindre Mehus
* @version $Id$
*/
public class Scrobbler
{
private String lastSubmission;
private String lastNowPlaying;
public void scrobble(final Track song, final boolean submission)
{
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
final String id = song.getId();
// Avoid duplicate registrations.
if (submission && id.equals(lastSubmission)) return;
if (!submission && id.equals(lastNowPlaying)) return;
if (submission) lastSubmission = id;
else lastNowPlaying = id;
new Thread(String.format("Scrobble %s", song))
{
@Override
public void run()
{
MusicService service = MusicServiceFactory.getMusicService();
try
{
service.scrobble(id, submission);
Timber.i("Scrobbled '%s' for %s", submission ? "submission" : "now playing", song);
}
catch (Exception x)
{
Timber.i(x, "Failed to scrobble'%s' for %s", submission ? "submission" : "now playing", song);
}
}
}.start();
}
}

View File

@ -0,0 +1,12 @@
package org.moire.ultrasonic.service;
/**
* Abstract class for supplying items to a consumer
* @param <T> The type of the item supplied
*/
public abstract class Supplier<T>
{
public abstract T get();
}

View File

@ -0,0 +1,72 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.app.Activity;
import android.os.Handler;
/**
* @author Sindre Mehus
*/
public abstract class BackgroundTask<T> implements ProgressListener
{
private final Activity activity;
private final Handler handler;
public BackgroundTask(Activity activity)
{
this.activity = activity;
handler = new Handler();
}
protected Activity getActivity()
{
return activity;
}
protected Handler getHandler()
{
return handler;
}
public abstract void execute();
protected abstract T doInBackground() throws Throwable;
protected abstract void done(T result);
protected void error(Throwable error)
{
CommunicationError.handleError(error, activity);
}
protected String getErrorMessage(Throwable error)
{
return CommunicationError.getErrorMessage(error, activity);
}
@Override
public abstract void updateProgress(final String message);
@Override
public void updateProgress(int messageId)
{
updateProgress(activity.getResources().getString(messageId));
}
}

View File

@ -0,0 +1,89 @@
package org.moire.ultrasonic.util;
import android.app.Activity;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
/**
* @author Sindre Mehus
* @version $Id$
*/
public abstract class FragmentBackgroundTask<T> extends BackgroundTask<T>
{
private final boolean changeProgress;
private final SwipeRefreshLayout swipe;
private final CancellationToken cancel;
public FragmentBackgroundTask(Activity activity, boolean changeProgress,
SwipeRefreshLayout swipe, CancellationToken cancel)
{
super(activity);
this.changeProgress = changeProgress;
this.swipe = swipe;
this.cancel = cancel;
}
@Override
public void execute()
{
if (changeProgress)
{
if (swipe != null) swipe.setRefreshing(true);
}
new Thread()
{
@Override
public void run()
{
try
{
final T result = doInBackground();
if (cancel.isCancellationRequested())
{
return;
}
getHandler().post(new Runnable()
{
@Override
public void run()
{
if (changeProgress)
{
if (swipe != null) swipe.setRefreshing(false);
}
done(result);
}
});
}
catch (final Throwable t)
{
if (cancel.isCancellationRequested())
{
return;
}
getHandler().post(new Runnable()
{
@Override
public void run()
{
if (changeProgress)
{
if (swipe != null) swipe.setRefreshing(false);
}
error(t);
}
});
}
}
}.start();
}
@Override
public void updateProgress(final String message)
{
}
}

View File

@ -0,0 +1,66 @@
package org.moire.ultrasonic.util;
import android.app.Activity;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
/**
* @author Sindre Mehus
* @version $Id$
*/
public abstract class LoadingTask<T> extends BackgroundTask<T>
{
private final SwipeRefreshLayout swipe;
private final CancellationToken cancel;
public LoadingTask(Activity activity, SwipeRefreshLayout swipe, CancellationToken cancel)
{
super(activity);
this.swipe = swipe;
this.cancel = cancel;
}
@Override
public void execute()
{
swipe.setRefreshing(true);
new Thread()
{
@Override
public void run()
{
try
{
final T result = doInBackground();
if (cancel.isCancellationRequested())
{
return;
}
getHandler().post(() -> {
swipe.setRefreshing(false);
done(result);
});
}
catch (final Throwable t)
{
if (cancel.isCancellationRequested())
{
return;
}
getHandler().post(() -> {
swipe.setRefreshing(false);
error(t);
});
}
}
}.start();
}
@Override
public void updateProgress(final String message)
{
}
}

View File

@ -0,0 +1,158 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.app.Activity;
import androidx.appcompat.app.AlertDialog;
import org.moire.ultrasonic.R;
import timber.log.Timber;
/**
* @author Sindre Mehus
*/
public abstract class ModalBackgroundTask<T> extends BackgroundTask<T>
{
private final AlertDialog progressDialog;
private Thread thread;
private final boolean finishActivityOnCancel;
private boolean cancelled;
public ModalBackgroundTask(Activity activity, boolean finishActivityOnCancel)
{
super(activity);
this.finishActivityOnCancel = finishActivityOnCancel;
progressDialog = createProgressDialog();
}
public ModalBackgroundTask(Activity activity)
{
this(activity, true);
}
private androidx.appcompat.app.AlertDialog createProgressDialog()
{
InfoDialog.Builder builder = new InfoDialog.Builder(getActivity().getApplicationContext());
builder.setTitle(R.string.background_task_wait);
builder.setMessage(R.string.background_task_loading);
builder.setOnCancelListener(dialogInterface -> cancel());
builder.setPositiveButton(R.string.common_cancel, (dialogInterface, i) -> cancel());
return builder.create();
}
@Override
public void execute()
{
cancelled = false;
progressDialog.show();
thread = new Thread()
{
@Override
public void run()
{
try
{
final T result = doInBackground();
if (cancelled)
{
progressDialog.dismiss();
return;
}
getHandler().post(new Runnable()
{
@Override
public void run()
{
try
{
progressDialog.dismiss();
}
catch (Exception e)
{
// nothing
}
done(result);
}
});
}
catch (final Throwable t)
{
if (cancelled)
{
return;
}
getHandler().post(new Runnable()
{
@Override
public void run()
{
try
{
progressDialog.dismiss();
}
catch (Exception e)
{
// nothing
}
error(t);
}
});
}
}
};
thread.start();
}
protected void cancel()
{
cancelled = true;
if (thread != null)
{
thread.interrupt();
}
if (finishActivityOnCancel)
{
getActivity().finish();
}
}
protected boolean isCancelled()
{
return cancelled;
}
@Override
protected void error(Throwable error)
{
Timber.w(error);
new ErrorDialog(getActivity(), getErrorMessage(error), getActivity(), finishActivityOnCancel);
}
@Override
public void updateProgress(final String message)
{
getHandler().post(() -> progressDialog.setMessage(message));
}
}

View File

@ -0,0 +1,29 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
/**
* @author Sindre Mehus
*/
public interface ProgressListener
{
void updateProgress(String message);
void updateProgress(int messageId);
}

View File

@ -0,0 +1,16 @@
package org.moire.ultrasonic.util;
import org.moire.ultrasonic.domain.Track;
import java.util.List;
/**
* Created by Josh on 12/17/13.
*/
public class ShareDetails
{
public String Description;
public boolean ShareOnServer;
public long Expiration;
public List<Track> Entries;
}

View File

@ -0,0 +1,39 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import android.os.Binder;
/**
* @author Sindre Mehus
*/
public class SimpleServiceBinder<S> extends Binder
{
private final S service;
public SimpleServiceBinder(S service)
{
this.service = service;
}
public S getService()
{
return service;
}
}

View File

@ -0,0 +1,38 @@
package org.moire.ultrasonic.util;
import android.content.Context;
import android.util.AttributeSet;
import androidx.preference.DialogPreference;
import org.moire.ultrasonic.R;
/**
* Created by Joshua Bahnsen on 12/22/13.
*/
public class TimeSpanPreference extends DialogPreference
{
Context context;
public TimeSpanPreference(Context context, AttributeSet attrs)
{
super(context, attrs);
this.context = context;
setPositiveButtonText(android.R.string.ok);
setNegativeButtonText(android.R.string.cancel);
setDialogIcon(null);
}
public String getText()
{
String persisted = getPersistedString("");
if (!"".equals(persisted))
{
return persisted.replace(':', ' ');
}
return this.context.getResources().getString(R.string.time_span_disabled);
}
}

View File

@ -0,0 +1,159 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.util.Linkify;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.ChatMessage;
import org.moire.ultrasonic.imageloader.ImageLoader;
import org.moire.ultrasonic.subsonic.ImageLoaderProvider;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.regex.Pattern;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
public class ChatAdapter extends ArrayAdapter<ChatMessage>
{
private final Context context;
private final List<ChatMessage> messages;
private static final String phoneRegex = "1?\\W*([2-9][0-8][0-9])\\W*([2-9][0-9]{2})\\W*([0-9]{4})";
private static final Pattern phoneMatcher = Pattern.compile(phoneRegex);
private final Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
private final Lazy<ImageLoaderProvider> imageLoaderProvider = inject(ImageLoaderProvider.class);
public ChatAdapter(Context context, List<ChatMessage> messages)
{
super(context, R.layout.chat_item, messages);
this.context = context;
this.messages = messages;
}
@Override
public boolean areAllItemsEnabled() {
return true;
}
@Override
public boolean isEnabled(int position) {
return false;
}
@Override
public int getCount()
{
return messages.size();
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
ChatMessage message = this.getItem(position);
ViewHolder holder;
int layout;
String messageUser = message.getUsername();
Date messageTime = new java.util.Date(message.getTime());
String messageText = message.getMessage();
String me = activeServerProvider.getValue().getActiveServer().getUserName();
layout = messageUser.equals(me) ? R.layout.chat_item_reverse : R.layout.chat_item;
if (convertView == null)
{
convertView = inflateView(layout, parent);
holder = createViewHolder(layout, convertView);
}
else
{
holder = (ViewHolder) convertView.getTag();
if (!holder.chatMessage.equals(message))
{
convertView = inflateView(layout, parent);
holder = createViewHolder(layout, convertView);
}
}
holder.chatMessage = message;
DateFormat timeFormat = android.text.format.DateFormat.getTimeFormat(context);
String messageTimeFormatted = String.format("[%s]", timeFormat.format(messageTime));
ImageLoader imageLoader = imageLoaderProvider.getValue().getImageLoader();
if (holder.avatar != null && !TextUtils.isEmpty(messageUser))
{
imageLoader.loadAvatarImage(holder.avatar, messageUser);
}
holder.username.setText(messageUser);
holder.message.setText(messageText);
holder.time.setText(messageTimeFormatted);
return convertView;
}
private View inflateView(int layout, ViewGroup parent)
{
return LayoutInflater.from(context).inflate(layout, parent, false);
}
private static ViewHolder createViewHolder(int layout, View convertView)
{
ViewHolder holder = new ViewHolder();
holder.layout = layout;
TextView usernameView;
TextView timeView;
TextView messageView;
ImageView imageView;
if (convertView != null)
{
usernameView = (TextView) convertView.findViewById(R.id.chat_username);
timeView = (TextView) convertView.findViewById(R.id.chat_time);
messageView = (TextView) convertView.findViewById(R.id.chat_message);
imageView = (ImageView) convertView.findViewById(R.id.chat_avatar);
messageView.setMovementMethod(LinkMovementMethod.getInstance());
Linkify.addLinks(messageView, Linkify.ALL);
Linkify.addLinks(messageView, phoneMatcher, "tel:");
holder.avatar = imageView;
holder.message = messageView;
holder.username = usernameView;
holder.time = timeView;
convertView.setTag(holder);
}
return holder;
}
private static class ViewHolder
{
int layout;
ImageView avatar;
TextView message;
TextView username;
TextView time;
ChatMessage chatMessage;
}
}

View File

@ -0,0 +1,110 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.SectionIndexer;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Genre;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
/**
* @author Sindre Mehus
*/
public class GenreAdapter extends ArrayAdapter<Genre> implements SectionIndexer
{
private final LayoutInflater layoutInflater;
// Both arrays are indexed by section ID.
private final Object[] sections;
private final Integer[] positions;
public GenreAdapter(Context context, List<Genre> genres)
{
super(context, R.layout.list_item_generic, genres);
layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
Collection<String> sectionSet = new LinkedHashSet<String>(30);
List<Integer> positionList = new ArrayList<Integer>(30);
for (int i = 0; i < genres.size(); i++)
{
Genre genre = genres.get(i);
String index = genre.getIndex();
if (!sectionSet.contains(index))
{
sectionSet.add(index);
positionList.add(i);
}
}
sections = sectionSet.toArray(new Object[0]);
positions = positionList.toArray(new Integer[0]);
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View rowView = convertView;
if (rowView == null) {
rowView = layoutInflater.inflate(R.layout.list_item_generic, parent, false);
}
((TextView) rowView).setText(getItem(position).getName());
return rowView;
}
@Override
public Object[] getSections()
{
return sections;
}
@Override
public int getPositionForSection(int section)
{
return positions[section];
}
@Override
public int getSectionForPosition(int pos)
{
for (int i = 0; i < sections.length - 1; i++)
{
if (pos < positions[i + 1])
{
return i;
}
}
return sections.length - 1;
}
}

View File

@ -0,0 +1,56 @@
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Share;
import java.util.List;
/**
* @author Sindre Mehus
*/
public class ShareAdapter extends ArrayAdapter<Share>
{
private final Context context;
public ShareAdapter(Context context, List<Share> Shares)
{
super(context, R.layout.share_list_item, Shares);
this.context = context;
}
@Override
public View getView(int position, View convertView, ViewGroup parent)
{
Share entry = getItem(position);
ShareView view;
if (convertView instanceof ShareView)
{
ShareView currentView = (ShareView) convertView;
ViewHolder viewHolder = (ViewHolder) convertView.getTag();
view = currentView;
view.setViewHolder(viewHolder);
}
else
{
view = new ShareView(context);
view.setLayout();
}
view.setShare(entry);
return view;
}
static class ViewHolder
{
TextView url;
TextView description;
}
}

View File

@ -0,0 +1,64 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.view;
import android.content.Context;
import android.view.LayoutInflater;
import android.widget.LinearLayout;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.domain.Share;
/**
* Used to display playlists in a {@code ListView}.
*
* @author Joshua Bahnsen
*/
public class ShareView extends LinearLayout
{
private final Context context;
private ShareAdapter.ViewHolder viewHolder;
public ShareView(Context context)
{
super(context);
this.context = context;
}
public void setLayout()
{
LayoutInflater.from(context).inflate(R.layout.share_list_item, this, true);
viewHolder = new ShareAdapter.ViewHolder();
viewHolder.url = findViewById(R.id.share_url);
viewHolder.description = findViewById(R.id.share_description);
setTag(viewHolder);
}
public void setViewHolder(ShareAdapter.ViewHolder viewHolder)
{
this.viewHolder = viewHolder;
setTag(this.viewHolder);
}
public void setShare(Share share)
{
viewHolder.url.setText(share.getName());
viewHolder.description.setText(share.getDescription());
}
}

View File

@ -1,6 +1,6 @@
/*
* NavigationActivity.kt
* Copyright (C) 2009-2023 Ultrasonic developers
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
@ -13,23 +13,21 @@ import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.SearchRecentSuggestions
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player.STATE_BUFFERING
@ -37,7 +35,6 @@ import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.onNavDestinationSelected
@ -49,18 +46,17 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.fragment.OnBackPressedHandler
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Constants
@ -68,7 +64,6 @@ import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.LocaleHelper
import org.moire.ultrasonic.util.ServerColor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.ShortcutUtil
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.UncaughtExceptionHandler
import org.moire.ultrasonic.util.Util
@ -80,7 +75,7 @@ import timber.log.Timber
* onCreate/onResume/onDestroy methods...
*/
@Suppress("TooManyFunctions")
class NavigationActivity : ScopeActivity() {
class NavigationActivity : AppCompatActivity() {
private var videoMenuItem: MenuItem? = null
private var chatMenuItem: MenuItem? = null
private var bookmarksMenuItem: MenuItem? = null
@ -95,20 +90,15 @@ class NavigationActivity : ScopeActivity() {
private var drawerLayout: DrawerLayout? = null
private var host: NavHostFragment? = null
private var selectServerButton: MaterialButton? = null
private var selectServerDropdownImage: ImageView? = null
private var headerBackgroundImage: ImageView? = null
// We store the last search string in this variable.
// Seems a bit like a hack, is there a better way?
var searchQuery: String? = null
private lateinit var appBarConfiguration: AppBarConfiguration
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
private val mediaPlayerManager: MediaPlayerManager by inject()
private val mediaPlayerController: MediaPlayerController by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject()
@ -137,8 +127,6 @@ class NavigationActivity : ScopeActivity() {
navigationView = findViewById(R.id.nav_view)
drawerLayout = findViewById(R.id.drawer_layout)
setupDrawerLayout(drawerLayout!!)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
@ -165,7 +153,7 @@ class NavigationActivity : ScopeActivity() {
drawerLayout
)
setupActionBarWithNavController(navController, appBarConfiguration)
setupActionBar(navController, appBarConfiguration)
setupNavigationMenu(navController)
@ -204,11 +192,10 @@ class NavigationActivity : ScopeActivity() {
}
rxBusSubscription += RxBus.playerStateObservable.subscribe {
if (it.state == STATE_READY) {
if (it.state == STATE_READY)
showNowPlaying()
} else {
else
hideNowPlaying()
}
}
rxBusSubscription += RxBus.themeChangedEventObservable.subscribe {
@ -224,80 +211,6 @@ class NavigationActivity : ScopeActivity() {
cachedServerCount = count ?: 0
updateNavigationHeaderForServer()
}
// Setup app shortcuts on supported devices, but not on first start, when the server
// is not configured yet.
if (!UApp.instance!!.isFirstRun) {
ShortcutUtil.registerShortcuts(this)
}
// Register our options menu
addMenuProvider(
searchMenuProvider,
this,
Lifecycle.State.RESUMED
)
}
private val searchMenuProvider: MenuProvider = object : MenuProvider {
override fun onPrepareMenu(menu: Menu) {
setupSearchField(menu)
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.search_view_menu, menu)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return false
}
}
fun setupSearchField(menu: Menu) {
Timber.i("Recreating search field")
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
val searchableInfo = searchManager.getSearchableInfo(this.componentName)
searchView.setSearchableInfo(searchableInfo)
searchView.setIconifiedByDefault(false)
if (searchQuery != null) {
Timber.e("Found existing search query")
searchItem.expandActionView()
searchView.isIconified = false
searchView.setQuery(searchQuery, false)
searchView.clearFocus()
// Restore search text only once!
searchQuery = null
}
}
private fun setupDrawerLayout(drawerLayout: DrawerLayout) {
// Set initial state passed on drawer state
closeNavigationDrawerOnBack.isEnabled = drawerLayout.isOpen
// Add the back press listener
onBackPressedDispatcher.addCallback(this, closeNavigationDrawerOnBack)
// Listen to changes in the drawer state and enable the back press listener accordingly.
drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
// Nothing
}
override fun onDrawerOpened(drawerView: View) {
closeNavigationDrawerOnBack.isEnabled = true
}
override fun onDrawerClosed(drawerView: View) {
closeNavigationDrawerOnBack.isEnabled = false
}
override fun onDrawerStateChanged(newState: Int) {
// Nothing
}
})
}
override fun onResume() {
@ -307,7 +220,7 @@ class NavigationActivity : ScopeActivity() {
Storage.reset()
lifecycleScope.launch(Dispatchers.IO) {
Storage.checkForErrorsWithCustomRoot()
Storage.ensureRootIsAvailable()
}
setMenuForServerCapabilities()
@ -315,11 +228,8 @@ class NavigationActivity : ScopeActivity() {
// Lifecycle support's constructor registers some event receivers so it should be created early
lifecycleSupport.onCreate()
if (!nowPlayingHidden) {
showNowPlaying()
} else {
hideNowPlaying()
}
if (!nowPlayingHidden) showNowPlaying()
else hideNowPlaying()
}
/*
@ -333,31 +243,47 @@ class NavigationActivity : ScopeActivity() {
}
private fun updateNavigationHeaderForServer() {
// Only show the vector graphic on Android 11 or earlier
val showVectorBackground = (Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
val activeServer = activeServerProvider.getActiveServer()
if (cachedServerCount == 0) {
if (cachedServerCount == 0)
selectServerButton?.text = getString(R.string.main_setup_server, activeServer.name)
} else {
selectServerButton?.text = activeServer.name
}
else selectServerButton?.text = activeServer.name
val foregroundColor =
ServerColor.getForegroundColor(this, activeServer.color)
ServerColor.getForegroundColor(this, activeServer.color, showVectorBackground)
val backgroundColor =
ServerColor.getBackgroundColor(this, activeServer.color)
if (activeServer.index == 0) {
if (activeServer.index == 0)
selectServerButton?.icon =
ContextCompat.getDrawable(this, R.drawable.ic_menu_screen_on_off)
} else {
else
selectServerButton?.icon =
ContextCompat.getDrawable(this, R.drawable.ic_menu_select_server)
}
selectServerButton?.iconTint = ColorStateList.valueOf(foregroundColor)
selectServerButton?.setTextColor(foregroundColor)
selectServerDropdownImage?.imageTintList = ColorStateList.valueOf(foregroundColor)
headerBackgroundImage?.setBackgroundColor(backgroundColor)
// Hide the vector graphic on Android 12 or later
if (!showVectorBackground) {
headerBackgroundImage?.setImageDrawable(null)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val isVolumeDown = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
val isVolumeUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP
val isVolumeAdjust = isVolumeDown || isVolumeUp
val isJukebox = mediaPlayerController.isJukeboxEnabled
if (isVolumeAdjust && isJukebox) {
mediaPlayerController.adjustVolume(isVolumeUp)
return true
}
return super.onKeyDown(keyCode, event)
}
private fun setupNavigationMenu(navController: NavController) {
@ -382,7 +308,7 @@ class NavigationActivity : ScopeActivity() {
}
R.id.menu_exit -> {
setResult(Constants.RESULT_CLOSE_ALL)
mediaPlayerManager.onDestroy()
mediaPlayerController.onDestroy()
finish()
exit()
}
@ -402,26 +328,26 @@ class NavigationActivity : ScopeActivity() {
selectServerButton =
navigationView?.getHeaderView(0)?.findViewById(R.id.header_select_server)
selectServerDropdownImage =
navigationView?.getHeaderView(0)?.findViewById(R.id.edit_server_button)
val onClick: (View) -> Unit = {
if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true) {
selectServerButton?.setOnClickListener {
if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true)
this.drawerLayout?.closeDrawer(GravityCompat.START)
}
navController.navigate(R.id.serverSelectorFragment)
}
selectServerButton?.setOnClickListener(onClick)
selectServerDropdownImage?.setOnClickListener(onClick)
headerBackgroundImage =
navigationView?.getHeaderView(0)?.findViewById(R.id.img_header_bg)
}
private val closeNavigationDrawerOnBack = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
drawerLayout?.closeDrawer(GravityCompat.START)
private fun setupActionBar(navController: NavController, appBarConfig: AppBarConfiguration) {
setupActionBarWithNavController(navController, appBarConfig)
}
override fun onBackPressed() {
if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true) {
this.drawerLayout?.closeDrawer(GravityCompat.START)
} else {
val currentFragment = host!!.childFragmentManager.fragments.last()
if (currentFragment is OnBackPressedHandler) currentFragment.onBackPressed()
else super.onBackPressed()
}
}
@ -435,79 +361,44 @@ class NavigationActivity : ScopeActivity() {
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val navController = findNavController(R.id.nav_host_fragment)
// Check if this item ID exists in the nav graph
val destinationExists = navController.graph.findNode(item.itemId) != null
return if (destinationExists) {
item.onNavDestinationSelected(navController) || super.onOptionsItemSelected(item)
} else {
// Let the fragments handle their own menu items
return item.onNavDestinationSelected(findNavController(R.id.nav_host_fragment)) ||
super.onOptionsItemSelected(item)
}
}
override fun onSupportNavigateUp(): Boolean {
// This override is required by design when using setupActionBarWithNavController()
// with an AppBarConfiguration. It ensures that the Up button behavior is correctly
// delegated — either navigating "up" in the back stack, or opening the drawer if
// we're at a top-level destination.
return findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration) ||
super.onSupportNavigateUp()
val currentFragment = host!!.childFragmentManager.fragments.last()
return if (currentFragment is OnBackPressedHandler) {
currentFragment.onBackPressed()
true
} else {
findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration)
}
}
override fun onNewIntent(intent: Intent) {
// TODO Test if this works with external Intents
// android.intent.action.SEARCH and android.media.action.MEDIA_PLAY_FROM_SEARCH calls here
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
when (intent.action) {
Constants.INTENT_PLAY_RANDOM_SONGS -> {
playRandomSongs()
}
Intent.ACTION_MAIN -> {
if (intent.getBooleanExtra(Constants.INTENT_SHOW_PLAYER, false)) {
findNavController(R.id.nav_host_fragment).navigate(R.id.playerFragment)
}
}
Intent.ACTION_SEARCH -> {
searchQuery = intent.getStringExtra(SearchManager.QUERY)
handleSearchIntent(searchQuery, false)
}
MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH -> {
searchQuery = intent.getStringExtra(SearchManager.QUERY)
handleSearchIntent(searchQuery, true)
}
}
}
private fun handleSearchIntent(query: String?, autoPlay: Boolean) {
val suggestions = SearchRecentSuggestions(
this,
SearchSuggestionProvider.AUTHORITY,
SearchSuggestionProvider.MODE
)
suggestions.saveRecentQuery(query, null)
val action = NavigationGraphDirections.toSearchFragment(query, autoPlay)
findNavController(R.id.nav_host_fragment).navigate(action)
}
private fun playRandomSongs() {
val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return
val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(Settings.maxSongs)
mediaPlayerManager.addToPlaylist(
songs = musicDirectory.getTracks(),
autoPlay = true,
shuffle = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR
)
if (Settings.shouldTransitionOnPlayback) {
currentFragment.findNavController().popBackStack(R.id.playerFragment, true)
currentFragment.findNavController().navigate(R.id.playerFragment)
if (intent.getBooleanExtra(Constants.INTENT_SHOW_PLAYER, false)) {
findNavController(R.id.nav_host_fragment).navigate(R.id.playerFragment)
return
}
return
val query = intent.getStringExtra(SearchManager.QUERY)
if (query != null) {
val autoPlay = intent.action == MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH
val suggestions = SearchRecentSuggestions(
this,
SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.MODE
)
suggestions.saveRecentQuery(query, null)
val action = NavigationGraphDirections.toSearchFragment(query, autoPlay)
findNavController(R.id.nav_host_fragment).navigate(action)
}
}
/**
@ -537,6 +428,7 @@ class NavigationActivity : ScopeActivity() {
private fun showWelcomeDialog() {
if (!UApp.instance!!.setupDialogDisplayed) {
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
InfoDialog.Builder(this)
@ -583,9 +475,9 @@ class NavigationActivity : ScopeActivity() {
}
if (nowPlayingView != null) {
val playerState: Int = mediaPlayerManager.playbackState
val playerState: Int = mediaPlayerController.playbackState
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
val item: MediaItem? = mediaPlayerManager.currentMediaItem
val item: MediaItem? = mediaPlayerController.currentMediaItem
if (item != null) {
nowPlayingView?.visibility = View.VISIBLE
}

View File

@ -15,28 +15,28 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.media3.common.HeartRating
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewDelegate
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.RatingUpdate
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.LayoutType
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import timber.log.Timber
/**
* Creates a Row in a RecyclerView which contains the details of an Album
*/
open class AlbumRowDelegate(
open val onItemClick: (Album) -> Unit,
open val onContextMenuClick: (MenuItem, Album) -> Boolean
open val onContextMenuClick: (MenuItem, Album) -> Boolean,
private val imageLoader: ImageLoader
) : ItemViewDelegate<Album, AlbumRowDelegate.ListViewHolder>(), KoinComponent {
private val starDrawable: Int = R.drawable.rating_star_full
private val starHollowDrawable: Int = R.drawable.rating_star_hollow
private val starDrawable: Int = R.drawable.ic_star_full
private val starHollowDrawable: Int = R.drawable.ic_star_hollow
open var layoutType = LayoutType.LIST
@ -58,16 +58,10 @@ open class AlbumRowDelegate(
holder.star.setImageResource(if (item.starred) starDrawable else starHollowDrawable)
holder.star.setOnClickListener { onStarClick(item, holder.star) }
val imageLoaderProvider: ImageLoaderProvider by inject()
imageLoaderProvider.executeOn {
it.loadImage(
holder.coverArt,
item,
false,
0,
R.drawable.unknown_album
)
}
imageLoader.loadImage(
holder.coverArt, item,
false, 0, R.drawable.unknown_album
)
}
/**
@ -115,13 +109,27 @@ open class AlbumRowDelegate(
private fun onStarClick(entry: Album, star: ImageView) {
entry.starred = !entry.starred
star.setImageResource(if (entry.starred) starDrawable else starHollowDrawable)
RxBus.ratingSubmitter.onNext(
RatingUpdate(
entry.id,
HeartRating(entry.starred)
)
)
val musicService = getMusicService()
Thread {
val useId3 = shouldUseId3Tags
try {
if (entry.starred) {
musicService.star(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
)
} else {
musicService.unstar(
if (!useId3) entry.id else null,
if (useId3) entry.id else null,
null
)
}
} catch (all: Exception) {
Timber.e(all)
}
}.start()
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup): ListViewHolder {
@ -140,7 +148,8 @@ open class AlbumRowDelegate(
class AlbumGridDelegate(
onItemClick: (Album) -> Unit,
onContextMenuClick: (MenuItem, Album) -> Boolean
) : AlbumRowDelegate(onItemClick, onContextMenuClick) {
onContextMenuClick: (MenuItem, Album) -> Boolean,
imageLoader: ImageLoader
) : AlbumRowDelegate(onItemClick, onContextMenuClick, imageLoader) {
override var layoutType = LayoutType.COVER
}

View File

@ -17,17 +17,12 @@ import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
@ -38,6 +33,7 @@ import org.moire.ultrasonic.util.Util
class ArtistRowBinder(
val onItemClick: (ArtistOrIndex) -> Unit,
val onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader,
private val enableSections: Boolean = true
) : ItemViewBinder<ArtistOrIndex, ArtistRowBinder.ViewHolder>(),
KoinComponent,
@ -63,26 +59,17 @@ class ArtistRowBinder(
holder.coverArtId = item.coverArt
val imageLoaderProvider: ImageLoaderProvider by inject()
if (showArtistPicture()) {
holder.coverArt.visibility = View.VISIBLE
CoroutineScope(Dispatchers.IO).launch {
val key = FileUtil.getArtistArtKey(item.name, false)
withContext(Dispatchers.Main) {
imageLoaderProvider.executeOn {
it.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
}
}
}
val key = FileUtil.getArtistArtKey(item.name, false)
imageLoader.loadImage(
view = holder.coverArt,
id = holder.coverArtId,
key = key,
large = false,
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
} else {
holder.coverArt.visibility = View.GONE
}
@ -124,7 +111,7 @@ class ArtistRowBinder(
}
private fun showArtistPicture(): Boolean {
return ActiveServerProvider.shouldUseId3Tags() && Settings.shouldShowArtistPicture
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
}
/**

View File

@ -26,8 +26,7 @@ import timber.log.Timber
* It should be kept generic enough that it can be used a Base for all lists in the app.
*/
@Suppress("unused", "UNUSED_PARAMETER")
class BaseAdapter<T : Identifiable>(allowDuplicateEntries: Boolean = false) :
MultiTypeAdapter(), FastScrollRecyclerView.SectionedAdapter {
class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView.SectionedAdapter {
// Update the BoundedTreeSet if selection type is changed
internal var selectionType: SelectionType = SelectionType.MULTIPLE
@ -42,7 +41,7 @@ class BaseAdapter<T : Identifiable>(allowDuplicateEntries: Boolean = false) :
private val diffCallback = GenericDiffCallback<T>()
init {
setHasStableIds(!allowDuplicateEntries)
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {

Some files were not shown because too many files have changed in this diff Show More