Compare commits

..

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

796 changed files with 34488 additions and 32400 deletions

140
.circleci/config.yml Normal file
View File

@ -0,0 +1,140 @@
version: 3
jobs:
build:
docker:
- image: circleci/android:api-29
working_directory: ~/ultrasonic
environment:
JVM_OPTS: -Xmx3200m
steps:
- checkout
- restore_cache:
keys:
- v1-ultrasonic-{{ .Branch }}-{{ checksum "dependencies.gradle" }}
- v1-ultrasonic-{{ .Branch }}
- v1-ultrasonic
- run:
name: configure gradle.properties for CI building
command: |
sed -i '/^org.gradle.jvmargs/d' gradle.properties
sed -i 's/^org.gradle.daemon=true/org.gradle.daemon=false/g' gradle.properties
- run:
name: checkstyle
command: ./gradlew -Pqc ktlintCheck
- run:
name: static analysis
command: ./gradlew -Pqc detekt
- run:
name: build
command: ./gradlew assembleDebug
- run:
name: unit-tests
command: |
./gradlew ciTest testDebugUnitTest
./gradlew jacocoFullReport
- run:
name: lint
command: ./gradlew :ultrasonic:lintRelease
- run:
name: assemble release build
command: ./gradlew build assembleRelease
- save_cache:
paths:
- ~/.gradle
key: v1-ultrasonic-{{ .Branch }}-{{ checksum "dependencies.gradle" }}
- store_artifacts:
path: ultrasonic/build/reports
destination: reports
- store_artifacts:
path: subsonic-api/build/reports
destination: reports
- store_artifacts:
path: build/reports/jacoco/jacocoFullReport/
push_translations:
docker:
- image: circleci/python:3.6
working_directory: ~/ultrasonic
steps:
- checkout
- run:
name: install transifex client
command: |
python -m venv ~/venv
. ~/venv/bin/activate
pip install transifex-client
- run:
name: configure transifex client
command: echo $'[https://www.transifex.com]\nhostname = https://www.transifex.com\nusername = api\npassword = '"${TRANSIFEX_PASSWORD}"$'\n' > ~/.transifexrc
- run:
name: push changes in translation files
command: |
. ~/venv/bin/activate
tx push -s
generate_signed_apk:
docker:
- image: circleci/android:api-28
working_directory: ~/ultrasonic
steps:
- checkout
- restore_cache:
keys:
- v1-ultrasonic-{{ .Branch }}-{{ checksum "dependencies.gradle" }}
- v1-ultrasonic-{{ .Branch }}
- v1-ultrasonic
- run:
name: decrypt ultrasonic-keystore
command: openssl aes-256-cbc -K ${ULTRASONIC_KEYSTORE_KEY} -iv ${ULTRASONIC_KEYSTORE_IV} -in ultrasonic-keystore.enc -out ultrasonic-keystore -d
- run:
name: build release apk
command: ./gradlew build assembleRelease
- run:
name: sign release apk
command: |
mkdir -p /tmp/ultrasonic-release
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore ~/ultrasonic/ultrasonic-keystore -storepass ${ULTRASONIC_KEYSTORE_STOREPASS} -keypass ${ULTRASONIC_KEYSTORE_KEYPASS} ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk ultrasonic
jarsigner -verify ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk
${ANDROID_HOME}/build-tools/27.0.0/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
- persist_to_workspace:
root: /tmp/ultrasonic-release
paths:
- ultrasonic-*.apk
publish_github_signed_apk:
docker:
- image: circleci/golang
steps:
- attach_workspace:
at: /tmp/ultrasonic-release
- run:
name: install ghr
command: go get -v github.com/tcnksm/ghr
- run:
name: publish release on github tag
command: ghr -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} ${CIRCLE_TAG} /tmp/ultrasonic-release
workflows:
version: 2
build_and_push_translations:
jobs:
- build
- push_translations:
requires:
- build
filters:
branches:
only:
- develop
- master
- generate_signed_apk:
filters:
tags:
only: /^[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/
- publish_github_signed_apk:
requires:
- generate_signed_apk
filters:
tags:
only: /^[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/

View File

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

11
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

2
.gitignore vendored
View File

@ -18,7 +18,6 @@ out/
# Gradle files
.gradle/
.kotlin/
build/
# Local configuration file (sdk path, etc)
@ -40,7 +39,6 @@ captures/
*.iml
.idea/
# Keystore files
*.jks

View File

@ -1,151 +0,0 @@
default:
image: registry.gitlab.com/ultrasonic/ci-android:1.2.0
cache: &global_cache
key:
files:
- gradle/wrapper/gradle-wrapper.properties
paths:
- .gradle/
variables:
CACHE_FALLBACK_KEY: develop-protected
MEMORY_CONFIG: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g"
MEMORY_CONFIG_DEBUG: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g -verbose:gc -Xlog:gc*"
JVM_OPTS: ${MEMORY_CONFIG}
JAVA_TOOL_OPTIONS: ${MEMORY_CONFIG}
GRADLE_OPTS: ${MEMORY_CONFIG}
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
- 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"
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"
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"
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"
Assemble Release:
stage: Build
script:
- sed -i 's/applicationId \"org.moire.ultrasonic\"/applicationId "org.moire.ultrasonic.gitlab"/' ultrasonic/build.gradle
- ./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
# 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: []
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/${PACKAGE_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
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
Publish Signed APK:
stage: Publish
image: curlimages/curl:latest
script:
- |
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ultrasonic-release/${PACKAGE_APK} "${PACKAGE_REGISTRY_URL}/${PACKAGE_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
Release:
stage: Release
image: registry.gitlab.com/gitlab-org/release-cli:latest
script: |
release-cli create --name "Release ${CI_COMMIT_TAG}" --tag-name ${CI_COMMIT_TAG} \
--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

View File

@ -1,35 +0,0 @@
## Problem description
Describe your problem here. Describe what you want to happen, and what
happens if you try to do it. If you have a stack trace or any logs, please
format them using Markdown triple backquote notation.
### Steps to reproduce
Describe how somebody else could observe the same behavior you do. Don't
share here any logins and passwords!
### Please provide a log!
Many issues are hard to reproduce without a log file. Please enable logging by opening the setting and scrolling to the bottom.
There you will find an option to activate loging and to export the file. Please note that the log might contain personal information,
such as the name of the tracks you listened to. If you don't want to post it publicly, let us know, and we will find a way.
## System information
### Ultrasonic client
* **Ultrasonic version**: *version of the app*
* **Android version**: *Version of Android OS on the device*
* **Device info**: *Device manufacturer, model*
### Server
* **Server name**: *Airsonic, Ampache, Supysonic...*
* **Server version**: *version of server software*
* **Protocol used**: *http or https (self certificate, letsencrypt...)*
## Additional notes
Include any extra notes here. Otherwise you may remove this section.

View File

@ -1,32 +0,0 @@
## Motivation
Describe here what motivated you to make this enhancement request. What is
currently happening and what would you like to improve. If you have a stack
trace or any logs, please format them using Markdown triple backquote
notation.
### Proposal
Tell us in detail what you propose. How you want to achieve what you would
like to improve.
## System information
Include this section only if you consider that is relevant to this ticket,
otherwise you may remove it.
### Ultrasonic client
* **Ultrasonic version**: *version of the app*
* **Android version**: *Version of Android OS on the device*
* **Device info**: *Device manufacturer, model*
### Server
* **Server name**: *Airsonic, Ampache, Supysonic...*
* **Server version**: *version of server software*
* **Protocol used**: *http or https (self certificate, letsencrypt...)*
## Additional notes
Include any extra notes here. Otherwise you may remove this section.

View File

@ -1,27 +0,0 @@
## Proposal
Describe your proposed feature request here. Use this template only to
request a new feature that does not exist in Ultrasonic. If you are
requesting an enhancement to a current feature, please use the enhancement
template.
## System information
Include this section only if you consider that is relevant to this ticket,
otherwise you may remove it.
### Ultrasonic client
* **Ultrasonic version**: *version of the app*
* **Android version**: *Version of Android OS on the device*
* **Device info**: *Device manufacturer, model*
### Server
* **Server name**: *Airsonic, Ampache, Supysonic...*
* **Server version**: *version of server software*
* **Protocol used**: *http or https (self certificate, letsencrypt...)*
## Additional notes
Include any extra notes here. Otherwise you may remove this section.

View File

@ -1,12 +0,0 @@
<!-- Please describe your changes here -->
----------------------------------------------------------------------------
- [ ] I have opened an issue where I discuss the change I wish to make.
- [ ] I ran `./gradlew -Pqc ktlintCheck`, `./gradlew -Pqc detekt` and
`./gradlew :ultrasonic:lintRelease` and no problems found. See
[CONTRIBUTING](CONTRIBUTING.md) for further information.
- [ ] 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
[GNU GPLv3](LICENSE) license.

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 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"baseBranches": [ "develop" ],
"labels": [ "housekeeping", "dependencies" ]
}

View File

@ -1,123 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml generated
View File

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

View File

@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="&amp;#36;file.fileName&#10;Copyright (C) 2009-&amp;#36;today.year Ultrasonic developers&#10;&#10;Distributed under terms of the GNU GPLv3 license." />
<option name="myName" value="Default" />
</copyright>
</component>

View File

@ -1,7 +0,0 @@
<component name="CopyrightManager">
<settings default="Default">
<LanguageOptions name="Kotlin">
<option name="fileTypeOverride" value="3" />
</LanguageOptions>
</settings>
</component>

View File

@ -1,8 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Reformat" enabled="false" level="WEAK WARNING" enabled_by_default="false">
<option name="processChangedFilesOnly" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/vcs.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

10
.tx/config Normal file
View File

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
lang_map = nl_NL:nl,pl_PL:pl,fr_CA:fr-rCA,pt_BR:pt-rBR,pt_PT:pt,zh_CN:zh-rCN,zh_HK:zh-rHK,zh_TW:zh-rTW,da_DK:da-rDK,de_DE:de,tr_TR:tr,fr_FR:fr,es_ES:es,hu_HU:hu,sv_SE:sv-rSE,bg_BG:bg,el_GR:el,kn_IN:kn-rIN,cs_CZ:cs,sr:sr,he:iw,id:in,lt_LT:lt,km_KH:km-rKH,th_TH:th,ru_RU:ru,it_IT:it
[ultrasonic.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

@ -1,83 +1,35 @@
# Contributing
Ultrasonic development is a community project, and contributions are
welcomed.
Ultrasonic development is a community project, and contributions are welcomed.
First, see if your issue havent been yet reported
[here](https://gitlab.com/ultrasonic/ultrasonic/issues), then, please, first
discuss the change you wish to make via [a new
issue](https://gitlab.com/ultrasonic/ultrasonic/issues/new).
First, see if your issue havent been yet reported [here](https://github.com/ultrasonic/ultrasonic/issues),
then, please, first discuss the change you wish to make via [a new issue](https://github.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
By default, merge requests should be opened against the **develop** branch
from your own branch, MR against the **master** branch should only be used
for critical bug fixes.
By default Pull Request should be opened against **develop** branch, PR against **master** branch should be used only
for critical bugfixes.
### Here are a few guidelines you should follow before submitting:
1. **License Acceptance:** All contributions must be licensed as [GNU
GPLv3](LICENSE) to be accepted. Use `git commit --signoff` to acknowledge
this.
2. **No Breakage**: New features or changes to existing ones must not
degrade the user experience.
3. **Coding standards**: best-practices should be followed, comment
generously, and avoid "clever" algorithms. Refactoring existing messes is
great, but watch out for breakage.
4. **No large PR**: Try to limit the scope of PR only to the related issue,
so it will be easier to review and test.
5. **Make your own branch**: When you send us a merge request, please do it
from your own branch. Avoid using the `develop` branch.
1. **License Acceptance:** All contributions must be licensed as [GNU GPLv3](LICENSE) to be accepted.
Use `git commit --signoff` to acknowledge this.
2. **App is migrating to [Kotlin](https://kotlinlang.org/) programming language:** new Pull Requests
should be written in this programming language.
3. **No Breakage:** New features or changes to existing ones must not degrade the user experience.
4. **Coding standards:** best-practices should be followed, comment generously, and avoid "clever" algorithms.
Refactoring existing messes is great, but watch out for breakage.
5. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review
and test.
### Merge request process
### Pull Request Process
On each merge request GitLab runs a number of checks to make sure there are
no problems.
Take special note of point five of the previous paragraph. Do not use the
`develop` branch, make your own. Not following this step will cause your
merge request to be rejected without even checking it.
#### Signed commits
Commits must be signed. [See here how to set it
up](https://docs.gitlab.com/ee/user/project/repository/gpg_signed_commits/).
#### KtLint
This programm checks if the source code is formatted correctly. You can run
it yourself locally with
```
./gradlew -Pqc ktlintFormat
```
Running this command will fix common problems and will notify you of
problems it couldn't fix automatically.
#### Detekt
Detekt is a static analyser. It helps to find potential bugs in our code.
You can run it yourself locally with
```
./gradlew -Pqc detekt
```
There is a "baseline" file, in which errors which have been in the code base
before are noted. Sometimes it is necessary to regenerate this file by
running:
```
./gradlew -Pqc detektBaseline
```
#### Lint
Lint looks for general problems in the code or unused resources etc. You can
run it with
```
./gradlew -Pqc lintRelease
```
If there is a need to regenerate the baseline, remove
`ultrasonic/lint-baseline.xml` and rerun the command.
1. Ensure [all commits are signed-off](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/about-commit-signature-verification).
2. Check tests for the new code are added.
3. Check code style is passing.
4. Check code static analysis is passing.

20
ISSUE_TEMPLATE.md Normal file
View File

@ -0,0 +1,20 @@
## Problem description
Describe your problem here. Describe what you want to happen, and what happens
if you try to do it. If you have a stack trace or any logs, please format them using
github triple backquote notation
### Steps to reproduce
Describe how somebody else could observe the same behavior you do. Don't share here any logins and
passwords!
## System information
* **Ultrasonic version**: *version of the app*
* **Android version**: *Version of Android OS on the device*
* **Device info**: *Device manufacturer, model*
## Additional notes
Include any extra notes here. Otherwise you may remove this section.

View File

@ -1,14 +1,14 @@
# Ultrasonic
[![Build Status](https://circleci.com/gh/ultrasonic/ultrasonic/tree/develop.svg?style=shield&circle-token=:circle-token)](https://circleci.com/gh/ultrasonic)
[![Codecov branch](https://img.shields.io/codecov/c/github/ultrasonic/ultrasonic/develop.svg)]()
[![ktlint](https://img.shields.io/badge/code%20style-%E2%9D%A4-FF4081.svg)](https://ktlint.github.io/)
Ultrasonic is free and open-source music streaming Android client for
[Subsonic][subsonic] [API][subapi] (version 1.7.0 or higher) compatible
servers.
Ultrasonic is free and open-source music streaming Android client for [Subsonic](http://www.subsonic.org/) [API](http://www.subsonic.org/pages/api.jsp) (version 1.7.0 or higher) compatible servers.
## Help wanted
We currently don't have that much time to spend developing Subsonic, so any
contributions or active developers are always welcomed.
Have a look at [CONTRIBUTING](CONTRIBUTING.md) to get started.
## Download
@ -16,20 +16,22 @@ App is available to download at following stores:
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" alt="Get it on Google Play" height="70">](https://play.google.com/store/apps/details?id=org.moire.ultrasonic)
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="70">](https://f-droid.org/packages/org.moire.ultrasonic/)
[<img src="https://ultrasonic.gitlab.io/assets/img/get-it-on-gitlab.png" alt="Get it on GitLab" height="70">](https://gitlab.com/ultrasonic/ultrasonic/-/releases)
[<img src="https://ultrasonic.github.io/assets/img/get-it-on-github.png" alt="Get it on GitHub" height="70">](https://github.com/ultrasonic/ultrasonic/releases)
**Warning**: All three versions (Google Play, F-Droid and the APKs) are not
compatible (not signed by the same key)! You must uninstall one to install
the other, which will delete all your data.
If you want to use the version downloaded from F-Droid or from GitLab with
**Android Auto**, you must enable Unknown Sources as it is described in
[this wiki page][wikiaa].
## Bugs and issues
First, see if your issue havent been yet reported [here][issues], otherwise
open [a new issue][newissue].
First, see if your issue havent been yet reported [here](https://github.com/ultrasonic/ultrasonic/issues),
otherwise open [a new issue](https://github.com/ultrasonic/ultrasonic/issues/new).
### 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](https://github.com/ultrasonic/ultrasonic/issues/129).
## Contributing
@ -37,28 +39,16 @@ See [CONTRIBUTING](CONTRIBUTING.md).
## Supported (tested) Subsonic API implementations
- [Subsonic][subsonic]
- [Airsonic-Advanced][airsonic]
- [Supysonic][supysonic]
- [Ampache][ampache]
- [Subsonic](http://www.subsonic.org/pages/index.jsp)
- [Airsonic](https://github.com/airsonic/airsonic)
- [Supysonic](https://github.com/spl0k/supysonic)
- [Ampache](https://ampache.org/)
Other *Subsonic API* implementations should work as well as long as they
follow API [documentation][subapi].
Other *Subsonic API* implementations should work as well as long as they follow API
[documentation](http://www.subsonic.org/pages/api.jsp).
## License
This software is licensed under the terms of the GNU General Public License
version 3 (GPLv3).
This software is licensed under the terms of the GNU General Public License version 3 (GPLv3).
Full text of the license is available in the [LICENSE](LICENSE) file and
[online][gpl3].
[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
[subsonic]: http://www.subsonic.org/
[subapi]: http://www.subsonic.org/pages/api.jsp
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
[supysonic]: https://github.com/spl0k/supysonic
[ampache]: https://ampache.org/
[gpl3]: https://opensource.org/licenses/gpl-3.0.html
Full text of the license is available in the [LICENSE](LICENSE) file and [online](https://opensource.org/licenses/gpl-3.0.html).

View File

@ -1,8 +1,6 @@
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'
apply from: 'dependencies.gradle'
ext.bootstrap = [
kotlinModule : "${project.rootDir}/gradle_scripts/kotlin-module-bootstrap.gradle",
@ -12,15 +10,14 @@ buildscript {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url = "https://plugins.gradle.org/m2/" }
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath libs.gradle
classpath libs.kotlin
classpath libs.ktlintGradle
classpath libs.detekt
classpath libs.navigationSafeArgs
classpath gradlePlugins.gradle
classpath gradlePlugins.kotlin
classpath gradlePlugins.ktlintGradle
classpath gradlePlugins.detekt
classpath gradlePlugins.jacoco
}
}
@ -29,32 +26,27 @@ 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 = "1.8"
}
}
}
apply from: 'gradle_scripts/jacoco.gradle'
wrapper {
gradleVersion = libs.versions.gradle.get()
distributionType = "all"
}
gradleVersion(versions.gradle)
distributionType("all")
}

12
core/cache/build.gradle vendored Normal file
View File

@ -0,0 +1,12 @@
apply from: bootstrap.kotlinModule
dependencies {
api project(':core:domain')
api other.twitterSerial
testImplementation testing.kotlinJunit
testImplementation testing.mockito
testImplementation testing.mockitoInline
testImplementation testing.mockitoKotlin
testImplementation testing.kluent
}

View File

@ -0,0 +1,14 @@
package org.moire.ultrasonic.cache
import java.io.File
/**
* Provides access to generic directories:
* - for temporary caches
* - for permanent data storage
*/
interface Directories {
fun getInternalCacheDir(): File
fun getInternalDataDir(): File
fun getExternalCacheDir(): File?
}

View File

@ -0,0 +1,75 @@
package org.moire.ultrasonic.cache
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.serializer.Serializer
import com.twitter.serial.stream.Serial
import com.twitter.serial.stream.bytebuffer.ByteBufferSerial
import java.io.File
typealias DomainEntitySerializer<T> = Serializer<T>
internal const val STORAGE_DIR_NAME = "persistent_storage"
/**
* Provides access to permanent file based storage.
*
* [serverId] is currently active server. Should be unique per server so stored data will not
* interfere with other server data.
*
* Look at [org.moire.ultrasonic.cache.serializers] package for available [DomainEntitySerializer]s.
*/
class PermanentFileStorage(
private val directories: Directories,
private val serverId: String,
private val debug: Boolean = false
) {
private val serializationContext = object : SerializationContext {
override fun isDebug(): Boolean = debug
override fun isRelease(): Boolean = !debug
}
private val serializer: Serial = ByteBufferSerial(serializationContext)
/**
* Stores given [objectToStore] using [name] as a key and [objectSerializer] as serializer.
*/
fun <T> store(
name: String,
objectToStore: T,
objectSerializer: DomainEntitySerializer<T>
) {
val storeFile = getFile(name)
if (!storeFile.exists()) storeFile.createNewFile()
storeFile.writeBytes(serializer.toByteArray(objectToStore, objectSerializer))
}
/**
* Loads object with [name] key using [objectDeserializer] deserializer.
*/
fun <T> load(
name: String,
objectDeserializer: DomainEntitySerializer<T>
): T? {
val storeFile = getFile(name)
if (!storeFile.exists()) return null
return serializer.fromByteArray(storeFile.readBytes(), objectDeserializer)
}
/**
* Clear all files in storage.
*/
fun clearAll() {
val storageDir = getStorageDir()
storageDir.listFiles().forEach { it.deleteRecursively() }
}
private fun getFile(name: String) = File(getStorageDir(), "$name.ser")
private fun getStorageDir(): File {
val mainDir = File(directories.getInternalDataDir(), STORAGE_DIR_NAME)
val serverDir = File(mainDir, serverId)
if (!serverDir.exists()) serverDir.mkdirs()
return serverDir
}
}

View File

@ -0,0 +1,65 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.CollectionSerializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerDefs
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.Artist
private const val SERIALIZER_VERSION = 1
private val artistSerializer get() = object : ObjectSerializer<Artist>(SERIALIZER_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: Artist
) {
output.writeString(item.id)
.writeString(item.name)
.writeString(item.index)
.writeString(item.coverArt)
.apply {
val albumCount = item.albumCount
if (albumCount != null) writeLong(albumCount) else writeNull()
}
.writeInt(item.closeness)
}
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): Artist? {
if (versionNumber != SERIALIZER_VERSION) return null
val id = input.readString()
val name = input.readString()
val index = input.readString()
val coverArt = input.readString()
val albumCount = if (input.peekType() == SerializerDefs.TYPE_NULL) {
input.readNull()
null
} else {
input.readLong()
}
val closeness = input.readInt()
return Artist(id, name, index, coverArt, albumCount, closeness)
}
}
/**
* Serializer/deserializer for [Artist] domain entity.
*/
fun getArtistsSerializer(): DomainEntitySerializer<Artist> = artistSerializer
private val artistListSerializer = CollectionSerializers.getListSerializer(artistSerializer)
/**
* Serializer/deserializer for list of [Artist] domain entities.
*/
fun getArtistListSerializer(): DomainEntitySerializer<List<Artist>> = artistListSerializer

View File

@ -0,0 +1,51 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Indexes
private const val SERIALIZATION_VERSION = 1
private val indexesSerializer get() = object : ObjectSerializer<Indexes>(SERIALIZATION_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: Indexes
) {
val artistListSerializer = getArtistListSerializer()
output.writeLong(item.lastModified)
.writeString(item.ignoredArticles)
.writeObject<MutableList<Artist>>(context, item.shortcuts, artistListSerializer)
.writeObject<MutableList<Artist>>(context, item.artists, artistListSerializer)
}
@Suppress("ReturnCount")
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): Indexes? {
if (versionNumber != SERIALIZATION_VERSION) return null
val artistListDeserializer = getArtistListSerializer()
val lastModified = input.readLong()
val ignoredArticles = input.readString() ?: return null
val shortcutsList = input.readObject(context, artistListDeserializer) ?: return null
val artistsList = input.readObject(context, artistListDeserializer) ?: return null
return Indexes(
lastModified, ignoredArticles, shortcutsList.toMutableList(),
artistsList.toMutableList()
)
}
}
/**
* Get serializer/deserializer for [Indexes] entity.
*/
fun getIndexesSerializer(): DomainEntitySerializer<Indexes> = indexesSerializer

View File

@ -0,0 +1,51 @@
@file:JvmMultifileClass
@file:JvmName("DomainSerializers")
package org.moire.ultrasonic.cache.serializers
import com.twitter.serial.serializer.CollectionSerializers
import com.twitter.serial.serializer.ObjectSerializer
import com.twitter.serial.serializer.SerializationContext
import com.twitter.serial.stream.SerializerInput
import com.twitter.serial.stream.SerializerOutput
import org.moire.ultrasonic.cache.DomainEntitySerializer
import org.moire.ultrasonic.domain.MusicFolder
private const val SERIALIZATION_VERSION = 1
private val musicFolderSerializer = object : ObjectSerializer<MusicFolder>(SERIALIZATION_VERSION) {
override fun serializeObject(
context: SerializationContext,
output: SerializerOutput<out SerializerOutput<*>>,
item: MusicFolder
) {
output.writeString(item.id).writeString(item.name)
}
@Suppress("ReturnCount")
override fun deserializeObject(
context: SerializationContext,
input: SerializerInput,
versionNumber: Int
): MusicFolder? {
if (versionNumber != SERIALIZATION_VERSION) return null
val id = input.readString() ?: return null
val name = input.readString() ?: return null
return MusicFolder(id, name)
}
}
/**
* Serializer/deserializer for [MusicFolder] domain entity.
*/
fun getMusicFolderSerializer(): DomainEntitySerializer<MusicFolder> = musicFolderSerializer
private val musicFolderListSerializer =
CollectionSerializers.getListSerializer(musicFolderSerializer)
/**
* Serializer/deserializer for [List] of [MusicFolder] items.
*/
fun getMusicFolderListSerializer(): DomainEntitySerializer<List<MusicFolder>> =
musicFolderListSerializer

View File

@ -0,0 +1,42 @@
package org.moire.ultrasonic.cache
import com.twitter.serial.util.SerializationUtils
import java.io.File
import org.amshove.kluent.`it returns`
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.mockito.kotlin.mock
internal const val INTERNAL_DATA_FOLDER = "data"
internal const val INTERNAL_CACHE_FOLDER = "cache"
internal const val EXTERNAL_CACHE_FOLDER = "external_cache"
/**
* Base test class that inits the storage
*/
abstract class BaseStorageTest {
@get:Rule val tempFileRule = TemporaryFolder()
protected lateinit var mockDirectories: Directories
protected lateinit var storage: PermanentFileStorage
open val serverId: String = ""
@Before
fun setUp() {
mockDirectories = mock<Directories> {
on { getInternalDataDir() } `it returns` tempFileRule.newFolder(INTERNAL_DATA_FOLDER)
on { getInternalCacheDir() } `it returns` tempFileRule.newFolder(INTERNAL_CACHE_FOLDER)
on { getExternalCacheDir() } `it returns` tempFileRule.newFolder(EXTERNAL_CACHE_FOLDER)
}
storage = PermanentFileStorage(mockDirectories, serverId, true)
}
protected val storageDir get() = File(mockDirectories.getInternalDataDir(), STORAGE_DIR_NAME)
protected fun validateSerializedData(index: Int = 0) {
val serializedFileBytes = storageDir.listFiles()[index].readBytes()
SerializationUtils.validateSerializedData(serializedFileBytes)
}
}

View File

@ -0,0 +1,80 @@
package org.moire.ultrasonic.cache
import java.io.File
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should contain`
import org.junit.Test
import org.moire.ultrasonic.cache.serializers.getMusicFolderSerializer
import org.moire.ultrasonic.domain.MusicFolder
/**
* Integration test for [PermanentFileStorage].
*/
class PermanentFileStorageTest : BaseStorageTest() {
override val serverId: String
get() = "some-server-id"
@Test
fun `Should create storage dir if it is not exist`() {
val item = MusicFolder("1", "2")
storage.store("test", item, getMusicFolderSerializer())
storageDir.exists() `should be equal to` true
getServerStorageDir().exists() `should be equal to` true
}
@Test
fun `Should serialize to file`() {
val item = MusicFolder("1", "23")
val name = "some-name"
storage.store(name, item, getMusicFolderSerializer())
val storageFiles = getServerStorageDir().listFiles()
storageFiles.size `should be equal to` 1
storageFiles[0].name `should contain` name
}
@Test
fun `Should deserialize stored object`() {
val item = MusicFolder("some", "nice")
val name = "some-name"
storage.store(name, item, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should overwrite existing stored object`() {
val name = "some-nice-name"
val item1 = MusicFolder("1", "1")
val item2 = MusicFolder("2", "2")
storage.store(name, item1, getMusicFolderSerializer())
storage.store(name, item2, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item2
}
@Test
fun `Should clear all files when clearAll is called`() {
storage.store("name1", MusicFolder("1", "1"), getMusicFolderSerializer())
storage.store("name2", MusicFolder("2", "2"), getMusicFolderSerializer())
storage.clearAll()
getServerStorageDir().listFiles().size `should be equal to` 0
}
@Test
fun `Should return null if serialized file not available`() {
val loadedItem = storage.load("some-name", getMusicFolderSerializer())
loadedItem `should be equal to` null
}
private fun getServerStorageDir() = File(storageDir, serverId)
}

View File

@ -0,0 +1,57 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.Artist
/**
* [Artist] serializers test.
*/
class ArtistSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize Artist object`() {
val item = Artist("id", "name", "index", "coverArt", 1, 0)
storage.store("some-name", item, getArtistsSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize Artist object`() {
val itemName = "some-name"
val item = Artist("id", "name", "index", "coverArt", null, 0)
storage.store(itemName, item, getArtistsSerializer())
val loadedItem = storage.load(itemName, getArtistsSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should correctly serialize list of Artists`() {
val itemsList = listOf(
Artist(id = "1"),
Artist(id = "2", name = "some")
)
storage.store("some-name", itemsList, getArtistListSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize list of Artists`() {
val name = "some-name"
val itemsList = listOf(
Artist(id = "1"),
Artist(id = "2", name = "some")
)
storage.store(name, itemsList, getArtistListSerializer())
val loadedItems = storage.load(name, getArtistListSerializer())
loadedItems `should be equal to` itemsList
}
}

View File

@ -0,0 +1,38 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Indexes
/**
* Test [Indexes] domain entity serializer.
*/
class IndexesSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize Indexes object`() {
val item = Indexes(
220L, "", mutableListOf(Artist("12")),
mutableListOf(Artist("233", "some"))
)
storage.store("some-name", item, getIndexesSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize Indexes object`() {
val name = "some-name"
val item = Indexes(
220L, "", mutableListOf(Artist("12")),
mutableListOf(Artist("233", "some"))
)
storage.store(name, item, getIndexesSerializer())
val loadedItem = storage.load(name, getIndexesSerializer())
loadedItem `should be equal to` item
}
}

View File

@ -0,0 +1,57 @@
package org.moire.ultrasonic.cache.serializers
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.cache.BaseStorageTest
import org.moire.ultrasonic.domain.MusicFolder
/**
* [MusicFolder] serializers test.
*/
class MusicFolderSerializerTest : BaseStorageTest() {
@Test
fun `Should correctly serialize MusicFolder object`() {
val item = MusicFolder("Music", "Folder")
storage.store("some-name", item, getMusicFolderSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize MusicFolder object`() {
val name = "name"
val item = MusicFolder("some", "none")
storage.store(name, item, getMusicFolderSerializer())
val loadedItem = storage.load(name, getMusicFolderSerializer())
loadedItem `should be equal to` item
}
@Test
fun `Should correctly serialize list of MusicFolders objects`() {
val itemsList = listOf(
MusicFolder("1", "1"),
MusicFolder("2", "2")
)
storage.store("some-name", itemsList, getMusicFolderListSerializer())
validateSerializedData()
}
@Test
fun `Should correctly deserialize list of MusicFolder objects`() {
val name = "some-name"
val itemsList = listOf(
MusicFolder("1", "1"),
MusicFolder("2", "2")
)
storage.store(name, itemsList, getMusicFolderListSerializer())
val loadedItem = storage.load(name, getMusicFolderListSerializer())
loadedItem `should be equal to` itemsList
}
}

View File

@ -1,20 +1,7 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.kotlinModule
apply from: bootstrap.androidModule
dependencies {
implementation libs.core
implementation libs.roomRuntime
implementation libs.roomKtx
ksp libs.room
}
android {
namespace = 'org.moire.ultrasonic.subsonic.domain'
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
ext {
jacocoExclude = [
'**/domain/**'
]
}

View File

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -1,38 +0,0 @@
/*
* Album.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity
import java.util.Date
@Entity(tableName = "albums", primaryKeys = ["id", "serverId"])
data class Album(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var parent: String? = null,
override var album: String? = null,
override var title: String? = null,
override val name: String? = null,
override var discNumber: Int? = 0,
override var coverArt: String? = null,
override var songCount: Long? = null,
override var created: Date? = null,
override var artist: String? = null,
override var artistId: String? = null,
override var duration: Int? = 0,
override var year: Int? = 0,
override var genre: String? = null,
override var starred: Boolean = false,
override var path: String? = null,
override var closeness: Int = 0
) : MusicDirectory.Child() {
override var isDirectory = true
override var isVideo = false
}

View File

@ -1,23 +1,30 @@
/*
* Artist.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity
import java.io.Serializable
@Entity(tableName = "artists", primaryKeys = ["id", "serverId"])
data class Artist(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var id: String? = null,
override var name: String? = null,
override var index: String? = null,
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0
) : ArtistOrIndex(id, serverId)
var index: String? = null,
var coverArt: String? = null,
var albumCount: Long? = null,
var closeness: Int = 0
) : Serializable, GenericEntry(), Comparable<Artist> {
companion object {
private const val serialVersionUID = -5790532593784846982L
}
override fun compareTo(other: Artist): Int {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
}

View File

@ -1,45 +0,0 @@
/*
* ArtistOrIndex.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.Ignore
@Suppress("LongParameterList")
abstract class ArtistOrIndex(
@Ignore
override var id: String,
@Ignore
open var serverId: Int,
@Ignore
override var name: String? = null,
@Ignore
open var index: String? = null,
@Ignore
open var coverArt: String? = null,
@Ignore
open var albumCount: Long? = null,
@Ignore
open var closeness: Int = 0
) : GenericEntry() {
fun compareTo(other: ArtistOrIndex): Int {
return when {
this.closeness == other.closeness -> {
0
}
this.closeness > other.closeness -> {
-1
}
else -> {
1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as ArtistOrIndex)
}

View File

@ -2,6 +2,7 @@ package org.moire.ultrasonic.domain
import java.io.Serializable
import java.util.Date
import org.moire.ultrasonic.domain.MusicDirectory.Entry
data class Bookmark(
val position: Int = 0,
@ -9,7 +10,7 @@ data class Bookmark(
val comment: String,
val created: Date? = null,
val changed: Date? = null,
val track: Track
val entry: Entry
) : Serializable {
companion object {
private const val serialVersionUID = 8988990025189807803L

View File

@ -0,0 +1,19 @@
package org.moire.ultrasonic.domain
abstract class GenericEntry {
// TODO: Should be non-null!
abstract val id: String?
open val name: String? = null
// These are just a formality and will never be called,
// because Kotlin data classes will have autogenerated equals() and hashCode() functions
override operator fun equals(other: Any?): Boolean {
return this === other
}
override fun hashCode(): Int {
var result = id?.hashCode() ?: 0
result = 31 * result + (name?.hashCode() ?: 0)
return result
}
}

View File

@ -1,13 +1,10 @@
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
@Entity
data class Genre(
@PrimaryKey val index: String,
val name: String
val name: String,
val index: String
) : Serializable {
companion object {
private const val serialVersionUID = -3943025175219134028L

View File

@ -1,19 +0,0 @@
package org.moire.ultrasonic.domain
import androidx.room.Ignore
abstract class GenericEntry : Identifiable {
@Ignore
open val name: String? = null
}
interface Identifiable : Comparable<Identifiable> {
val id: String
val longId: Long
get() = id.hashCode().toLong()
override fun compareTo(other: Identifiable): Int {
return longId.compareTo(other.longId)
}
}

View File

@ -1,24 +0,0 @@
/*
* Index.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(tableName = "indexes", primaryKeys = ["id", "serverId"])
data class Index(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var name: String? = null,
override var index: String? = null,
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0,
var musicFolderId: String? = null
) : ArtistOrIndex(id, serverId)

View File

@ -0,0 +1,14 @@
package org.moire.ultrasonic.domain
import java.io.Serializable
data class Indexes(
val lastModified: Long,
val ignoredArticles: String,
val shortcuts: MutableList<Artist> = mutableListOf(),
val artists: MutableList<Artist> = mutableListOf()
) : Serializable {
companion object {
private const val serialVersionUID = 8156117238598414701L
}
}

View File

@ -1,58 +1,92 @@
/*
* MusicDirectory.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import java.io.Serializable
import java.util.Date
class MusicDirectory : ArrayList<MusicDirectory.Child>() {
class MusicDirectory {
var name: String? = null
private val children = mutableListOf<Entry>()
fun addAll(entries: Collection<Entry>) {
children.addAll(entries)
}
fun addFirst(child: Entry) {
children.add(0, child)
}
fun addChild(child: Entry) {
children.add(child)
}
fun findChild(id: String): Entry? = children.lastOrNull { it.id == id }
fun getAllChild(): List<Entry> = children.toList()
@JvmOverloads
fun getChildren(includeDirs: Boolean = true, includeFiles: Boolean = true): List<Child> {
fun getChildren(
includeDirs: Boolean = true,
includeFiles: Boolean = true
): List<Entry> {
if (includeDirs && includeFiles) {
return toList()
return children
}
return filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
return children.filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
}
fun getTracks(): List<Track> {
return mapNotNull {
it as? Track
data class Entry(
override var id: String,
var parent: String? = null,
var isDirectory: Boolean = false,
var title: String? = null,
var album: String? = null,
var albumId: String? = null,
var artist: String? = null,
var artistId: String? = null,
var track: Int? = 0,
var year: Int? = 0,
var genre: String? = null,
var contentType: String? = null,
var suffix: String? = null,
var transcodedContentType: String? = null,
var transcodedSuffix: String? = null,
var coverArt: String? = null,
var size: Long? = null,
var songCount: Long? = null,
var duration: Int? = null,
var bitRate: Int? = null,
var path: String? = null,
var isVideo: Boolean = false,
var starred: Boolean = false,
var discNumber: Int? = null,
var type: String? = null,
var created: Date? = null,
var closeness: Int = 0,
var bookmarkPosition: Int = 0,
var userRating: Int? = null,
var averageRating: Float? = null
) : Serializable, GenericEntry(), Comparable<Entry> {
fun setDuration(duration: Long) {
this.duration = duration.toInt()
}
}
fun getAlbums(): List<Album> {
return mapNotNull {
it as? Album
companion object {
private const val serialVersionUID = -3339106650010798108L
}
}
abstract class Child : GenericEntry() {
abstract override var id: String
abstract var serverId: Int
abstract var parent: String?
abstract var isDirectory: Boolean
abstract var album: String?
abstract var title: String?
abstract override val name: String?
abstract var discNumber: Int?
abstract var coverArt: String?
abstract var songCount: Long?
abstract var created: Date?
abstract var artist: String?
abstract var artistId: String?
abstract var duration: Int?
abstract var year: Int?
abstract var genre: String?
abstract var starred: Boolean
abstract var path: String?
abstract var closeness: Int
abstract var isVideo: Boolean
override fun compareTo(other: Entry): Int {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
}
}

View File

@ -1,22 +1,9 @@
/*
* MusicFolder.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity
/**
* Represents a top level directory in which music or other media is stored.
*/
@Entity(tableName = "music_folders", primaryKeys = ["id", "serverId"])
data class MusicFolder(
override val id: String,
override val name: String,
@ColumnInfo(defaultValue = "-1")
var serverId: Int
override val name: String
) : GenericEntry()

View File

@ -0,0 +1,12 @@
package org.moire.ultrasonic.domain
enum class PlayerState {
IDLE,
DOWNLOADING,
PREPARING,
PREPARED,
STARTED,
STOPPED,
PAUSED,
COMPLETED
}

View File

@ -14,8 +14,4 @@ data class Playlist @JvmOverloads constructor(
companion object {
private const val serialVersionUID = -4160515427075433798L
}
override fun toString(): String {
return name
}
}

View File

@ -12,6 +12,4 @@ data class PodcastsChannel(
companion object {
private const val serialVersionUID = -4160515427075433798L
}
override fun toString(): String = title.toString()
}

View File

@ -0,0 +1,15 @@
package org.moire.ultrasonic.domain
enum class RepeatMode {
OFF {
override operator fun next(): RepeatMode = ALL
},
ALL {
override operator fun next(): RepeatMode = SINGLE
},
SINGLE {
override operator fun next(): RepeatMode = OFF
};
abstract operator fun next(): RepeatMode
}

View File

@ -1,10 +1,12 @@
package org.moire.ultrasonic.domain
import org.moire.ultrasonic.domain.MusicDirectory.Entry
/**
* The result of a search. Contains matching artists, albums and songs.
*/
data class SearchResult(
val artists: List<ArtistOrIndex> = listOf(),
val albums: List<Album> = listOf(),
val songs: List<Track> = listOf()
val artists: List<Artist>,
val albums: List<Entry>,
val songs: List<Entry>
)

View File

@ -1,9 +1,10 @@
package org.moire.ultrasonic.domain
import java.io.Serializable
import org.moire.ultrasonic.domain.MusicDirectory.Entry
data class Share(
override var id: String,
override var id: String? = null,
var url: String? = null,
var description: String? = null,
var username: String? = null,
@ -11,22 +12,17 @@ data class Share(
var lastVisited: String? = null,
var expires: String? = null,
var visitCount: Long? = null,
private val tracks: MutableList<Track> = mutableListOf()
private val entries: MutableList<Entry> = mutableListOf()
) : Serializable, GenericEntry() {
override val name: String?
get() {
if (url != null) {
return urlPattern.matcher(url!!).replaceFirst("$1")
}
return null
}
get() = url?.let { urlPattern.matcher(url).replaceFirst("$1") }
fun getEntries(): List<Track> {
return tracks.toList()
fun getEntries(): List<Entry> {
return entries.toList()
}
fun addEntry(track: Track) {
tracks.add(track)
fun addEntry(entry: Entry) {
entries.add(entry)
}
companion object {

View File

@ -1,74 +0,0 @@
/*
* Track.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.ColumnInfo
import androidx.room.Entity
import java.io.Serializable
import java.util.Date
@Entity(tableName = "tracks", primaryKeys = ["id", "serverId"])
data class Track(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
override var parent: String? = null,
override var isDirectory: Boolean = false,
override var title: String? = null,
override var album: String? = null,
var albumId: String? = null,
override var artist: String? = null,
override var artistId: String? = null,
var track: Int? = null,
override var year: Int? = null,
override var genre: String? = null,
var contentType: String? = null,
var suffix: String? = null,
var transcodedContentType: String? = null,
var transcodedSuffix: String? = null,
override var coverArt: String? = null,
var size: Long? = null,
override var songCount: Long? = null,
override var duration: Int? = null,
var bitRate: Int? = null,
override var path: String? = null,
override var isVideo: Boolean = false,
override var starred: Boolean = false,
override var discNumber: Int? = null,
var type: String? = null,
override var created: Date? = null,
override var closeness: Int = 0,
var bookmarkPosition: Int = 0,
var userRating: Int? = null,
var averageRating: Float? = null,
override var name: String? = null
) : Serializable, MusicDirectory.Child() {
fun setDuration(duration: Long) {
this.duration = duration.toInt()
}
companion object {
private const val serialVersionUID = -3339106650010798108L
}
fun compareTo(other: Track): Int {
return when {
this.closeness == other.closeness -> {
0
}
this.closeness > other.closeness -> {
-1
}
else -> {
1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as Track)
}

View File

@ -1,29 +1,30 @@
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
api libs.koinCore
api other.retrofit
api other.jacksonConverter
api other.koinCore
implementation(libs.jacksonKotlin) {
implementation(other.jacksonKotlin) {
exclude module: 'kotlin-reflect'
}
implementation libs.kotlinReflect // for jackson kotlin, but to use the same version
implementation libs.okhttpLogging
implementation other.kotlinReflect // for jackson kotlin, but to use the same version
implementation other.okhttpLogging
implementation other.timber
testImplementation libs.kotlinJunit
testImplementation libs.mockito
testImplementation libs.mockitoKotlin
testImplementation libs.kluent
testImplementation libs.mockWebServer
testImplementation libs.apacheCodecs
testImplementation testing.kotlinJunit
testImplementation testing.mockito
testImplementation testing.mockitoInline
testImplementation testing.mockitoKotlin
testImplementation testing.kluent
testImplementation testing.mockWebServer
testImplementation testing.apacheCodecs
}
ext {
// Excluding data classes
jacocoExclude = [
'**/models/**',
'**/di/**'
]
}

View File

@ -8,8 +8,7 @@ import java.util.Locale
import java.util.TimeZone
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import okio.source
import okio.Okio
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should contain`
import org.amshove.kluent.`should not be`
@ -41,12 +40,12 @@ fun MockWebServer.enqueueResponse(resourceName: String) {
}
fun Any.loadJsonResponse(name: String): String {
val source = javaClass.classLoader.getResourceAsStream(name)!!.source().buffer()
val source = Okio.buffer(Okio.source(javaClass.classLoader.getResourceAsStream(name)))
return source.readString(Charset.forName("UTF-8"))
}
fun Any.loadResourceStream(name: String): InputStream {
val source = javaClass.classLoader.getResourceAsStream(name)!!.source().buffer()
val source = Okio.buffer(Okio.source(javaClass.classLoader.getResourceAsStream(name)))
return source.inputStream()
}

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

@ -2,9 +2,9 @@ package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should be equal to`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.Album
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.api.subsonic.models.AlbumListType.BY_GENRE
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
/**
* Integration tests for [SubsonicAPIDefinition] for getAlbumList call.
@ -28,8 +28,8 @@ class SubsonicApiGetAlbumListRequestTest : SubsonicAPIClientTest() {
assertResponseSuccessful(response)
with(response.body()!!.albumList) {
size `should be equal to` 2
this[1] `should be equal to` Album(
id = "9997", parent = "9996",
this[1] `should be equal to` MusicDirectoryChild(
id = "9997", parent = "9996", isDir = true,
title = "Endless Forms Most Beautiful", album = "Endless Forms Most Beautiful",
artist = "Nightwish", year = 2015, genre = "Symphonic Metal",
coverArt = "9997", playCount = 11,

View File

@ -1,8 +1,8 @@
package org.moire.ultrasonic.api.subsonic
import okhttp3.mockwebserver.MockResponse
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should not be`
import org.junit.Test

View File

@ -1,8 +1,8 @@
package org.moire.ultrasonic.api.subsonic
import okhttp3.mockwebserver.MockResponse
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should not be`
import org.junit.Test

View File

@ -1,7 +1,7 @@
package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should not be`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.MusicDirectory

View File

@ -3,7 +3,6 @@ package org.moire.ultrasonic.api.subsonic
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should not be`
import org.junit.Test
import org.moire.ultrasonic.api.subsonic.models.Album
import org.moire.ultrasonic.api.subsonic.models.Artist
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
@ -33,8 +32,9 @@ class SubsonicApiSearchTwoTest : SubsonicAPIClientTest() {
artistList.size `should be equal to` 1
artistList[0] `should be equal to` Artist(id = "522", name = "The Prodigy")
albumList.size `should be equal to` 1
albumList[0] `should be equal to` Album(
id = "8867", parent = "522", title = "Always Outnumbered, Never Outgunned",
albumList[0] `should be equal to` MusicDirectoryChild(
id = "8867", parent = "522",
isDir = true, title = "Always Outnumbered, Never Outgunned",
album = "Always Outnumbered, Never Outgunned", artist = "The Prodigy",
year = 2004, genre = "Electronic", coverArt = "8867", playCount = 0,
created = parseDate("2016-10-23T20:57:27.000Z")

View File

@ -1,8 +1,8 @@
package org.moire.ultrasonic.api.subsonic
import okhttp3.mockwebserver.MockResponse
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should not be`
import org.junit.Test

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

@ -1,10 +1,3 @@
/*
* ApiVersionCheckWrapper.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.api.subsonic
import okhttp3.ResponseBody
@ -92,13 +85,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 +101,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 +221,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 +328,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

@ -17,8 +17,8 @@ fun Response<out ResponseBody>.toStreamResponse(): StreamResponse {
val contentType = responseBody?.contentType()
if (
contentType != null &&
contentType.type.equals("application", true) &&
contentType.subtype.equals("json", true)
contentType.type().equals("application", true) &&
contentType.subtype().equals("json", true)
) {
val error = SubsonicAPIClient.jacksonMapper.readValue<SubsonicResponse>(
responseBody.byteStream()
@ -40,11 +40,11 @@ fun Response<out ResponseBody>.toStreamResponse(): StreamResponse {
* It creates Exceptions from the results returned by the Subsonic API
*/
@Suppress("ThrowsCount")
fun <T : SubsonicResponse> Response<T>.throwOnFailure(): Response<T> {
fun <T : SubsonicResponse> Response<out T>.throwOnFailure(): Response<out T> {
val response = this
if (response.isSuccessful && response.body()!!.status === SubsonicResponse.Status.OK) {
return this
return this as Response<T>
}
if (!response.isSuccessful) {
throw IOException("Server error, code: " + response.code())

View File

@ -8,7 +8,6 @@ import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit.MILLISECONDS
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
@ -44,7 +43,7 @@ class SubsonicAPIClient(
config.minimalProtocolVersion,
PasswordHexInterceptor(config.password),
PasswordMD5Interceptor(config.password),
config.forcePlainTextPassword
config.enableLdapUserSupport
)
var onProtocolChange: (SubsonicAPIVersions) -> Unit = {}
@ -64,30 +63,17 @@ class SubsonicAPIClient(
}
val okHttpClient: OkHttpClient = baseOkClient.newBuilder()
// Disable HTTP2 because OkHttp with Exoplayer causes a bug. See https://github.com/square/okhttp/issues/6749
.readTimeout(READ_TIMEOUT, MILLISECONDS)
.apply { if (config.allowSelfSignedCertificate) allowSelfSignedCertificates() }
.addInterceptor { chain ->
// Adds default request params
val originalRequest = chain.request()
val newUrl = originalRequest.url.newBuilder()
val newUrl = originalRequest.url().newBuilder()
.addQueryParameter("u", config.username)
.addQueryParameter("c", config.clientID)
.addQueryParameter("f", "json")
.build()
val newRequestBuilder = originalRequest.newBuilder().url(newUrl)
if (originalRequest.url.username.isNotEmpty() &&
originalRequest.url.password.isNotEmpty()
) {
newRequestBuilder.addHeader(
"Authorization",
Credentials.basic(
originalRequest.url.username,
originalRequest.url.password
)
)
}
chain.proceed(newRequestBuilder.build())
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
.addInterceptor(versionInterceptor)
.addInterceptor(proxyPasswordInterceptor)
@ -95,12 +81,10 @@ class SubsonicAPIClient(
.apply { if (config.debug) addLogging() }
.build()
val baseUrl = "${config.baseUrl}/rest/"
// Create the Retrofit instance, and register a special converter factory
// It will update our protocol version to the correct version, once we made a successful call
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("${config.baseUrl}/rest/")
.client(okHttpClient)
.addConverterFactory(
VersionAwareJacksonConverterFactory.create(
@ -125,19 +109,17 @@ class SubsonicAPIClient(
private fun OkHttpClient.Builder.addLogging() {
val loggingInterceptor = HttpLoggingInterceptor(okLogger)
loggingInterceptor.level = HttpLoggingInterceptor.Level.HEADERS
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
this.addInterceptor(loggingInterceptor)
}
@Suppress("CustomX509TrustManager", "TrustAllX509TrustManager")
@SuppressWarnings("TrustAllX509TrustManager", "EmptyFunctionBlock")
private fun OkHttpClient.Builder.allowSelfSignedCertificates() {
val trustManager =
object : X509TrustManager {
override fun checkClientTrusted(p0: Array<out X509Certificate>?, p1: String?) {}
override fun checkServerTrusted(p0: Array<out X509Certificate>?, p1: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val trustManager = object : X509TrustManager {
override fun checkClientTrusted(p0: Array<out X509Certificate>?, p1: String?) {}
override fun checkServerTrusted(p0: Array<out X509Certificate>?, p1: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustManager), SecureRandom())
@ -154,17 +136,11 @@ class SubsonicAPIClient(
return call.toStreamResponse()
}
val isOffline by lazy {
config.baseUrl == OFFLINE_DB_URL
}
companion object {
val jacksonMapper: ObjectMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
.registerModule(KotlinModule.Builder().build())
const val OFFLINE_DB_URL = "http://localhost"
.registerModule(KotlinModule())
}
}

View File

@ -1,10 +1,3 @@
/*
* SubsonicAPIDefinition.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.api.subsonic
import okhttp3.ResponseBody
@ -54,9 +47,6 @@ interface SubsonicAPIDefinition {
@GET("ping.view")
fun ping(): Call<SubsonicResponse>
@GET("ping.view")
suspend fun pingSuspend(): SubsonicResponse
@GET("getLicense.view")
fun getLicense(): Call<LicenseResponse>
@ -90,7 +80,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 +148,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(
@ -163,12 +157,6 @@ interface SubsonicAPIDefinition {
@Query("id") id: String? = null
): Call<GetPodcastsResponse>
@GET("getPodcasts.view")
suspend fun getPodcastsSuspend(
@Query("includeEpisodes") includeEpisodes: Boolean? = null,
@Query("id") id: String? = null
): GetPodcastsResponse
@GET("getLyrics.view")
fun getLyrics(
@Query("artist") artist: String? = null,
@ -223,7 +211,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")
@ -238,19 +229,6 @@ interface SubsonicAPIDefinition {
@Header("Range") offset: Long? = null
): Call<ResponseBody>
@Streaming
@GET("download.view")
fun download(
@Query("id") id: String,
@Query("maxBitRate") maxBitRate: Int? = null,
@Query("format") format: String? = null,
@Query("timeOffset") timeOffset: Int? = null,
@Query("size") videoSize: String? = null,
@Query("estimateContentLength") estimateContentLength: Boolean? = null,
@Query("converted") converted: Boolean? = null,
@Header("Range") offset: Long? = null
): Call<ResponseBody>
@GET("jukeboxControl.view")
fun jukeboxControl(
@Query("action") action: JukeboxAction,
@ -263,9 +241,6 @@ interface SubsonicAPIDefinition {
@GET("getShares.view")
fun getShares(): Call<SharesResponse>
@GET("getShares.view")
suspend fun getSharesSuspend(): SharesResponse
@GET("createShare.view")
fun createShare(
@Query("id") idsToShare: List<String>,
@ -297,24 +272,15 @@ interface SubsonicAPIDefinition {
@GET("getUser.view")
fun getUser(@Query("username") username: String): Call<GetUserResponse>
@GET("getUser.view")
suspend fun getUserSuspend(@Query("username") username: String): GetUserResponse
@GET("getChatMessages.view")
fun getChatMessages(@Query("since") since: Long? = null): Call<ChatMessagesResponse>
@GET("getChatMessages.view")
suspend fun getChatMessagesSuspend(@Query("since") since: Long? = null): ChatMessagesResponse
@GET("addChatMessage.view")
fun addChatMessage(@Query("message") message: String): Call<SubsonicResponse>
@GET("getBookmarks.view")
fun getBookmarks(): Call<BookmarksResponse>
@GET("getBookmarks.view")
suspend fun getBookmarksSuspend(): BookmarksResponse
@GET("createBookmark.view")
fun createBookmark(
@Query("id") id: String,
@ -328,9 +294,6 @@ interface SubsonicAPIDefinition {
@GET("getVideos.view")
fun getVideos(): Call<VideosResponse>
@GET("getVideos.view")
suspend fun getVideosSuspend(): VideosResponse
@GET("getAvatar.view")
fun getAvatar(@Query("username") username: String): Call<ResponseBody>
}

View File

@ -29,25 +29,20 @@ 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(".")
require(versionComponents.size >= 2) { "Unknown api version $apiVersion" }
if (versionComponents.size < 2)
throw IllegalArgumentException("Unknown api version $apiVersion")
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

@ -10,7 +10,7 @@ data class SubsonicClientConfiguration(
val minimalProtocolVersion: SubsonicAPIVersions,
val clientID: String,
val allowSelfSignedCertificate: Boolean = false,
val forcePlainTextPassword: Boolean = false,
val enableLdapUserSupport: Boolean = false,
val debug: Boolean = false,
val isRealProtocolVersion: Boolean = false
)

View File

@ -35,7 +35,7 @@ class VersionAwareJacksonConverterFactory(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *> {
): Converter<ResponseBody, *>? {
val javaType: JavaType = mapper!!.typeFactory.constructType(type)
val reader: ObjectReader? = mapper!!.readerFor(javaType)
return VersionAwareResponseBodyConverter<Any>(notifier, reader!!)
@ -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

@ -18,7 +18,7 @@ class PasswordHexInterceptor(private val password: String) : Interceptor {
override fun intercept(chain: Chain): Response {
val originalRequest = chain.request()
val updatedUrl = originalRequest.url.newBuilder()
val updatedUrl = originalRequest.url().newBuilder()
.addEncodedQueryParameter("p", passwordHex).build()
return chain.proceed(originalRequest.newBuilder().url(updatedUrl).build())
}

View File

@ -21,7 +21,7 @@ class PasswordMD5Interceptor(private val password: String) : Interceptor {
override fun intercept(chain: Chain): Response {
val originalRequest = chain.request()
val salt = getSalt()
val updatedUrl = originalRequest.url.newBuilder()
val updatedUrl = originalRequest.url().newBuilder()
.addQueryParameter("t", getPasswordMD5Hash(salt))
.addQueryParameter("s", salt)
.build()

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
@ -20,7 +19,7 @@ internal const val TIMEOUT_MILLIS_PER_OFFSET_BYTE = 0.02
internal class RangeHeaderInterceptor : Interceptor {
override fun intercept(chain: Chain): Response {
val originalRequest = chain.request()
val headers = originalRequest.headers
val headers = originalRequest.headers()
return if (headers.names().contains("Range")) {
val offsetValue = headers["Range"] ?: "0"
val offset = "bytes=$offsetValue-"

View File

@ -18,7 +18,7 @@ internal class VersionInterceptor(
val newRequest = originalRequest.newBuilder()
.url(
originalRequest
.url
.url()
.newBuilder()
.addQueryParameter("v", protocolVersion.restApiVersion)
.build()

View File

@ -5,20 +5,14 @@ import java.util.Calendar
data class Album(
val id: String = "",
val parent: String = "",
val album: String = "",
val title: String? = null,
val name: String? = null,
val discNumber: Int = 0,
val name: String = "",
val coverArt: String = "",
val songCount: Int = 0,
val created: Calendar? = null,
val artist: String = "",
val artistId: String = "",
val songCount: Int = 0,
val duration: Int = 0,
val created: Calendar? = null,
val year: Int = 0,
val genre: String = "",
val playCount: Int = 0,
@JsonProperty("song") val songList: List<MusicDirectoryChild> = emptyList(),
@JsonProperty("starred") val starredDate: String = ""
@JsonProperty("song") val songList: List<MusicDirectoryChild> = emptyList()
)

View File

@ -1,10 +1,3 @@
/*
* AlbumListOrderType.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.api.subsonic.models
/**
@ -23,8 +16,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

@ -4,6 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty
data class SearchTwoResult(
@JsonProperty("artist") val artistList: List<Artist> = emptyList(),
@JsonProperty("album") val albumList: List<Album> = emptyList(),
@JsonProperty("album") val albumList: List<MusicDirectoryChild> = emptyList(),
@JsonProperty("song") val songList: List<MusicDirectoryChild> = emptyList()
)

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

@ -3,20 +3,19 @@ package org.moire.ultrasonic.api.subsonic.response
import com.fasterxml.jackson.annotation.JsonProperty
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicError
import org.moire.ultrasonic.api.subsonic.models.Album
import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild
class GetAlbumListResponse(
status: Status,
version: SubsonicAPIVersions,
error: SubsonicError?
) : SubsonicResponse(status, version, error) {
@JsonProperty("albumList")
private val albumWrapper = AlbumWrapper()
@JsonProperty("albumList") private val albumWrapper = AlbumWrapper()
val albumList: List<Album>
val albumList: List<MusicDirectoryChild>
get() = albumWrapper.albumList
}
private class AlbumWrapper(
@JsonProperty("album") val albumList: List<Album> = emptyList()
@JsonProperty("album") val albumList: List<MusicDirectoryChild> = emptyList()
)

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,13 +20,12 @@ 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) =
values().firstOrNull { it.jsonValue == jsonValue }
?: throw IllegalArgumentException("Unknown status value: $jsonValue")
fun getStatusFromJson(jsonValue: String) = values()
.filter { it.jsonValue == jsonValue }.firstOrNull()
?: throw IllegalArgumentException("Unknown status value: $jsonValue")
class StatusJsonDeserializer : JsonDeserializer<Status>() {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): Status {

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)

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.api.subsonic.models
import java.util.Locale
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should throw`
import org.junit.Test
@ -11,7 +10,7 @@ import org.junit.Test
class AlbumListTypeTest {
@Test
fun `Should create type from string ignoring case`() {
val type = AlbumListType.SORTED_BY_NAME.typeName.lowercase(Locale.ROOT)
val type = AlbumListType.SORTED_BY_NAME.typeName.toLowerCase()
val albumListType = AlbumListType.fromName(type)
@ -36,7 +35,6 @@ class AlbumListTypeTest {
@Test
fun `Should return type name for toString call`() {
AlbumListType.STARRED.typeName `should be equal to`
AlbumListType.STARRED.toString()
AlbumListType.STARRED.typeName `should be equal to` AlbumListType.STARRED.toString()
}
}

108
dependencies.gradle Normal file
View File

@ -0,0 +1,108 @@
ext.versions = [
minSdk : 14,
targetSdk : 29,
compileSdk : 29,
// You need to run ./gradlew wrapper after updating the version
gradle : '7.0',
navigation : "2.3.5",
gradlePlugin : "4.2.0",
androidxcore : "1.5.0",
ktlint : "0.37.1",
ktlintGradle : "9.2.1",
detekt : "1.17.1",
jacoco : "0.8.7",
preferences : "1.1.1",
media : "1.3.1",
androidSupport : "28.0.0",
androidLegacySupport : "1.0.0",
androidSupportDesign : "1.3.0",
constraintLayout : "2.0.4",
multidex : "2.0.1",
room : "2.3.0",
kotlin : "1.5.10",
kotlinxCoroutines : "1.5.0-native-mt",
viewModelKtx : "2.2.0",
retrofit : "2.6.4",
jackson : "2.9.5",
okhttp : "3.12.13",
twitterSerial : "0.1.6",
koin : "3.0.2",
picasso : "2.71828",
sortListView : "1.0.1",
junit4 : "4.13.2",
junit5 : "5.7.1",
mockito : "3.11.0",
mockitoKotlin : "3.2.0",
kluent : "1.64",
apacheCodecs : "1.15",
robolectric : "4.5.1",
dexter : "6.2.2",
timber : "4.7.1",
fastScroll : "2.0.1",
]
ext.gradlePlugins = [
gradle : "com.android.tools.build:gradle:$versions.gradlePlugin",
kotlin : "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin",
ktlintGradle : "org.jlleitschuh.gradle:ktlint-gradle:$versions.ktlintGradle",
detekt : "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$versions.detekt",
jacoco : "org.jacoco:org.jacoco.core:$versions.jacoco",
]
ext.androidSupport = [
core : "androidx.core:core-ktx:$versions.androidxcore",
support : "androidx.legacy:legacy-support-v4:$versions.androidLegacySupport",
design : "com.google.android.material:material:$versions.androidSupportDesign",
annotations : "com.android.support:support-annotations:$versions.androidSupport",
multidex : "androidx.multidex:multidex:$versions.multidex",
constraintLayout : "androidx.constraintlayout:constraintlayout:$versions.constraintLayout",
room : "androidx.room:room-compiler:$versions.room",
roomRuntime : "androidx.room:room-runtime:$versions.room",
roomKtx : "androidx.room:room-ktx:$versions.room",
viewModelKtx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.viewModelKtx",
navigationFragment : "androidx.navigation:navigation-fragment:$versions.navigation",
navigationUi : "androidx.navigation:navigation-ui:$versions.navigation",
navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$versions.navigation",
navigationUiKtx : "androidx.navigation:navigation-ui-ktx:$versions.navigation",
navigationFeature : "androidx.navigation:navigation-dynamic-features-fragment:$versions.navigation",
preferences : "androidx.preference:preference:$versions.preferences",
media : "androidx.media:media:$versions.media",
]
ext.other = [
kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin",
kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin",
kotlinxCoroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.kotlinxCoroutines",
retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit",
gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit",
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson",
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial",
koinCore : "io.insert-koin:koin-core:$versions.koin",
koinAndroid : "io.insert-koin:koin-android:$versions.koin",
koinViewModel : "io.insert-koin:koin-android-viewmodel:$versions.koin",
picasso : "com.squareup.picasso:picasso:$versions.picasso",
dexter : "com.karumi:dexter:$versions.dexter",
timber : "com.jakewharton.timber:timber:$versions.timber",
fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll",
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",
]
ext.testing = [
junit : "junit:junit:$versions.junit4",
junitVintage : "org.junit.vintage:junit-vintage-engine:$versions.junit5",
kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin",
mockitoKotlin : "org.mockito.kotlin:mockito-kotlin:$versions.mockitoKotlin",
mockito : "org.mockito:mockito-core:$versions.mockito",
mockitoInline : "org.mockito:mockito-inline:$versions.mockito",
kluent : "org.amshove.kluent:kluent:$versions.kluent",
kluentAndroid : "org.amshove.kluent:kluent-android:$versions.kluent",
mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp",
apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs",
robolectric : "org.robolectric:robolectric:$versions.robolectric"
]

86
detekt-baseline.xml Normal file
View File

@ -0,0 +1,86 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided&gt;$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID>
<ID>ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt"</ID>
<ID>ComplexCondition:LocalMediaPlayer.kt$LocalMediaPlayer$Util.getGaplessPlaybackPreference() &amp;&amp; Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.JELLY_BEAN &amp;&amp; ( playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED )</ID>
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>ComplexMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File)</ID>
<ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>EmptyFunctionBlock:SongView.kt$SongView${}</ID>
<ID>FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent()</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("DownloadTask (%s)", song)</ID>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided&gt;$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:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$String.format("BufferTask (%s)", downloadFile)</ID>
<ID>ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$String.format("CheckCompletionTask (%s)", downloadFile)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler.&lt;no name provided&gt;$String.format("%s\n\n%s", Util.getShareGreeting(), result.url)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%02d.", trackNumber)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s ", bitRate)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s &gt; %s", suffix, transcodedSuffix)</ID>
<ID>LargeClass:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
<ID>LongMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File)</ID>
<ID>LongMethod:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean)</ID>
<ID>LongMethod:MediaPlayerService.kt$MediaPlayerService$private fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState)</ID>
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken )</ID>
<ID>LongMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateDisplay(refresh: Boolean)</ID>
<ID>LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, private var data: Array&lt;ServerSetting&gt;, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -&gt; Unit), private val serverEditRequestedCallback: ((Int) -&gt; Unit) )</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$10</ID>
<ID>MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$60</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.&lt;no name provided&gt;$60000</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$1024L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$86400L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$5000L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$50L</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$256</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$3</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$4</ID>
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
<ID>MagicNumber:SongView.kt$SongView$3</ID>
<ID>MagicNumber:SongView.kt$SongView$4</ID>
<ID>MagicNumber:SongView.kt$SongView$60</ID>
<ID>MagicNumber:TrackCollectionFragment.kt$TrackCollectionFragment$10</ID>
<ID>NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</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>NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler()</ID>
<ID>ReturnCount:CommunicationErrorHandler.kt$CommunicationErrorHandler.Companion$fun getErrorMessage(error: Throwable, context: Context): String</ID>
<ID>ReturnCount:ServerRowAdapter.kt$ServerRowAdapter$ private fun popupMenuItemClick(menuItem: MenuItem, position: Int): Boolean</ID>
<ID>ReturnCount:TrackCollectionFragment.kt$TrackCollectionFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>TooGenericExceptionCaught:DownloadFile.kt$DownloadFile$e: Exception</ID>
<ID>TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$ex: Exception</ID>
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$exception: Throwable</ID>
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer$x: Exception</ID>
<ID>TooGenericExceptionCaught:LocalMediaPlayer.kt$LocalMediaPlayer.PositionCache$e: Exception</ID>
<ID>TooGenericExceptionCaught:MediaPlayerService.kt$MediaPlayerService$e: Exception</ID>
<ID>TooGenericExceptionCaught:SongView.kt$SongView$e: Exception</ID>
<ID>TooGenericExceptionCaught:SubsonicUncaughtExceptionHandler.kt$SubsonicUncaughtExceptionHandler$x: Throwable</ID>
<ID>TooGenericExceptionCaught:VideoPlayer.kt$VideoPlayer$e: Exception</ID>
<ID>TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw Exception(String.format("Download of '%s' was cancelled", song))</ID>
<ID>TooManyFunctions:LocalMediaPlayer.kt$LocalMediaPlayer</ID>
<ID>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
<ID>TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
<ID>UtilityClassWithPublicConstructor:CommunicationErrorHandler.kt$CommunicationErrorHandler</ID>
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</CurrentIssues>
</SmellBaseline>

View File

@ -2,11 +2,14 @@ build:
maxIssues: 0
weights:
complexity: 2
formatting: 1
LongParameterList: 1
comments: 1
potential-bugs:
active: true
DuplicateCaseInWhenExpression:
active: true
EqualsWithHashCodeExist:
active: true
ExplicitGarbageCollectionCall:
@ -39,39 +42,41 @@ empty-blocks:
complexity:
active: true
TooManyFunctions:
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 30
LabeledExpression:
active: false
formatting:
autoCorrect: true
active: false
style:
active: true
NewLineAtEndOfFile:
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:
active: false
active: true
maxLineLength: 120
excludePackageStatements: false
excludeImportStatements: false
MagicNumber:
# 100 common in percentage, 1000 in milliseconds
ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024', '4096']
ignoreNumbers: ['-1', '0', '1', '2', '100', '1000']
ignoreEnums: true
ignorePropertyDeclaration: true
UnnecessaryAbstractClass:
active: false
ReturnCount:
max: 5
ForbiddenImport:
imports: ['android.app.AlertDialog']
max: 3
comments:
active: true

View File

@ -1,3 +0,0 @@
Others
- #671: Bump versions.mockito from 4.1.0 to 4.3.1.
- Update translations.

View File

@ -1,3 +0,0 @@
Enhancements
- #683: Rewrite the about and remove the webview.
- #685: Server coloring feature.

View File

@ -1,2 +0,0 @@
Bug fixes
- #688: Connection failure.

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