Compare commits

..

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

216 changed files with 7901 additions and 7425 deletions

View File

@ -1,20 +1,11 @@
version: 2.1
parameters:
memory-config:
type: string
default: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g"
memory-config-debug:
type: string
default: "-Xmx3200m -Xms256m -XX:MaxMetaspaceSize=1g -verbose:gc -Xlog:gc*"
version: 3
jobs:
build:
docker:
- image: cimg/android:2022.06.1
- image: circleci/android:api-30
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
JVM_OPTS: -Xmx3200m
steps:
- checkout
- restore_cache:
@ -27,7 +18,6 @@ jobs:
command: |
sed -i '/^org.gradle.jvmargs/d' gradle.properties
sed -i 's/^org.gradle.daemon=true/org.gradle.daemon=false/g' gradle.properties
cat gradle.properties
- run:
name: checkstyle
command: ./gradlew -Pqc ktlintCheck
@ -41,6 +31,7 @@ jobs:
name: unit-tests
command: |
./gradlew ciTest testDebugUnitTest
./gradlew jacocoFullReport
- run:
name: lint
command: ./gradlew :ultrasonic:lintRelease
@ -53,16 +44,18 @@ jobs:
- save_cache:
paths:
- ~/.gradle
key: v2-ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
key: v1-ultrasonic-{{ .Branch }}-{{ checksum "gradle/libs.versions.toml" }}
- 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: cimg/python:3.6
- image: circleci/python:3.6
working_directory: ~/ultrasonic
steps:
- checkout
@ -82,12 +75,8 @@ jobs:
tx push -s
generate_signed_apk:
docker:
- image: cimg/android:2022.06.1
- image: circleci/android:api-30
working_directory: ~/ultrasonic
environment:
JVM_OPTS: << pipeline.parameters.memory-config >>
JAVA_TOOL_OPTIONS: << pipeline.parameters.memory-config >>
GRADLE_OPTS: << pipeline.parameters.memory-config >>
steps:
- checkout
- restore_cache:
@ -106,22 +95,22 @@ jobs:
command: |
export PATH="${JAVA_HOME}/bin:${PATH}"
mkdir -p /tmp/ultrasonic-release
${ANDROID_HOME}/build-tools/32.0.0/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
${ANDROID_HOME}/build-tools/32.0.0/apksigner sign --verbose --ks ~/ultrasonic/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
${ANDROID_HOME}/build-tools/32.0.0/apksigner verify --verbose /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
${ANDROID_HOME}/build-tools/30.0.0/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
${ANDROID_HOME}/build-tools/30.0.0/apksigner sign --verbose --ks ~/ultrasonic/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
${ANDROID_HOME}/build-tools/30.0.0/apksigner verify --verbose /tmp/ultrasonic-release/ultrasonic-${CIRCLE_TAG}.apk
- persist_to_workspace:
root: /tmp/ultrasonic-release
paths:
- ultrasonic-*.apk*
publish_github_signed_apk:
docker:
- image: cimg/go:1.18
- image: circleci/golang
steps:
- attach_workspace:
at: /tmp/ultrasonic-release
- run:
name: install ghr
command: go install -v github.com/tcnksm/ghr@latest
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
@ -140,7 +129,7 @@ workflows:
- generate_signed_apk:
filters:
tags:
only: /^[0-9]+(\.[0-9]+)*(-beta\.[0-9]+)?/
only: /^[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/
- publish_github_signed_apk:
@ -148,7 +137,7 @@ workflows:
- generate_signed_apk
filters:
tags:
only: /^[0-9]+(\.[0-9]+)*(-beta\.[0-9]+)?/
only: /^[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/

1
.gitignore vendored
View File

@ -39,7 +39,6 @@ captures/
*.iml
.idea/
# Keystore files
*.jks

View File

@ -1,128 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<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>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</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="true" level="WEAK WARNING" enabled_by_default="true">
<option name="processChangedFilesOnly" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK" />
</project>

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>

View File

@ -18,46 +18,18 @@ By default Pull Request should be opened against **develop** branch, PR against
### 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.
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.
4. **No large PR:** Try to limit the scope of PR only to the related issue, so it will be easier to review
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.
### Pull Request Process
On each Pull Request Github runs a number of checks to make sure there are no problems.
#### Signed commits
Commits must be signed. [See here how to set it up](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-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.

View File

@ -1,28 +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.
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!
Describe how somebody else could observe the same behavior you do. Don't share here any logins and
passwords!
## 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,25 +1,14 @@
# WE HAVE MOVED
Ultrasonic code is now hosted in [GitLab][ultrasonic].
- New Web: https://ultrasonic.gitlab.io
- New Git: https://gitlab.com/ultrasonic/ultrasonic
- New bugtracker: https://gitlab.com/ultrasonic/ultrasonic/-/issues
- New releases: https://gitlab.com/ultrasonic/ultrasonic/-/packages
[ultrasonic]: https://gitlab.com/ultrasonic/ultrasonic
# 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
@ -27,26 +16,24 @@ 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.
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].
If you want to use the version downloaded from F-Droid or form Github with **Android Auto**, you must enable Unknown Sources as it is described in [this wiki page](https://github.com/ultrasonic/ultrasonic/wiki/Using-Ultrasonic-with-Android-Auto).
## 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][madbug].
more info about this you can read [this bug](https://github.com/ultrasonic/ultrasonic/issues/129).
## Contributing
@ -54,29 +41,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
[madbug]: https://gitlab.com/ultrasonic/ultrasonic/-/issues/129
[subsonic]: http://www.subsonic.org/
[subapi]: http://www.subsonic.org/pages/api.jsp
[airsonic]: https://github.com/airsonic-advanced/airsonic-advanced
[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

@ -17,6 +17,7 @@ buildscript {
classpath libs.kotlin
classpath libs.ktlintGradle
classpath libs.detekt
classpath libs.jacoco
}
}
@ -43,6 +44,8 @@ allprojects {
}
}
apply from: 'gradle_scripts/jacoco.gradle'
wrapper {
gradleVersion(libs.versions.gradle.get())
distributionType("all")

View File

@ -1,6 +1,12 @@
apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
ext {
jacocoExclude = [
'**/domain/**'
]
}
dependencies {
implementation libs.roomRuntime
implementation libs.roomKtx

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,14 @@
/*
* 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 androidx.room.PrimaryKey
@Entity(tableName = "artists", primaryKeys = ["id", "serverId"])
@Entity(tableName = "artists")
data class Artist(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
@PrimaryKey override var id: String,
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)
) : ArtistOrIndex(id)

View File

@ -1,21 +1,11 @@
/*
* 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,
@ -28,15 +18,15 @@ abstract class ArtistOrIndex(
) : GenericEntry() {
fun compareTo(other: ArtistOrIndex): Int {
return when {
when {
this.closeness == other.closeness -> {
0
return 0
}
this.closeness > other.closeness -> {
-1
return -1
}
else -> {
1
return 1
}
}
}

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

@ -1,24 +1,15 @@
/*
* 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
import androidx.room.PrimaryKey
@Entity(tableName = "indexes", primaryKeys = ["id", "serverId"])
@Entity(tableName = "indexes")
data class Index(
override var id: String,
@ColumnInfo(defaultValue = "-1")
override var serverId: Int = -1,
@PrimaryKey override var id: String,
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)
) : ArtistOrIndex(id)

View File

@ -1,12 +1,8 @@
/*
* MusicDirectory.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
import java.util.Date
class MusicDirectory : ArrayList<MusicDirectory.Child>() {
@ -24,9 +20,9 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
return filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
}
fun getTracks(): List<Track> {
fun getTracks(): List<Entry> {
return mapNotNull {
it as? Track
it as? Entry
}
}
@ -38,7 +34,6 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
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?
@ -58,4 +53,87 @@ class MusicDirectory : ArrayList<MusicDirectory.Child>() {
abstract var closeness: Int
abstract var isVideo: Boolean
}
// TODO: Rename to Track
@Entity
data class Entry(
@PrimaryKey override var id: String,
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, Child() {
fun setDuration(duration: Long) {
this.duration = duration.toInt()
}
companion object {
private const val serialVersionUID = -3339106650010798108L
}
fun compareTo(other: Entry): Int {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as Entry)
}
data class Album(
@PrimaryKey override var id: String,
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,
) : Child() {
override var isDirectory = true
override var isVideo = false
}
}

View File

@ -1,22 +1,13 @@
/*
* 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
import androidx.room.PrimaryKey
/**
* Represents a top level directory in which music or other media is stored.
*/
@Entity(tableName = "music_folders", primaryKeys = ["id", "serverId"])
@Entity(tableName = "music_folders")
data class MusicFolder(
override val id: String,
override val name: String,
@ColumnInfo(defaultValue = "-1")
var serverId: Int
@PrimaryKey override val id: String,
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

@ -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,13 @@
package org.moire.ultrasonic.domain
import org.moire.ultrasonic.domain.MusicDirectory.Album
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 songs: List<Entry> = listOf()
)

View File

@ -1,6 +1,7 @@
package org.moire.ultrasonic.domain
import java.io.Serializable
import org.moire.ultrasonic.domain.MusicDirectory.Entry
data class Share(
override var id: String,
@ -11,7 +12,7 @@ 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() {
@ -21,12 +22,12 @@ data class Share(
return null
}
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 {
when {
this.closeness == other.closeness -> {
return 0
}
this.closeness > other.closeness -> {
return -1
}
else -> {
return 1
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as Track)
}

View File

@ -20,3 +20,11 @@ dependencies {
testImplementation libs.mockWebServer
testImplementation libs.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

@ -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
@ -69,24 +68,12 @@ class SubsonicAPIClient(
.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)
@ -122,7 +109,7 @@ class SubsonicAPIClient(
private fun OkHttpClient.Builder.addLogging() {
val loggingInterceptor = HttpLoggingInterceptor(okLogger)
loggingInterceptor.level = HttpLoggingInterceptor.Level.HEADERS
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
this.addInterceptor(loggingInterceptor)
}

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

@ -19,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

@ -1,26 +1,68 @@
<?xml version='1.0' encoding='UTF-8'?>
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ImplicitDefaultLocale:EditServerFragment.kt$EditServerFragment.&lt;no name provided>$String.format( "%s %s", resources.getString(R.string.settings_connection_failure), getErrorMessage(error) )</ID>
<ID>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>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>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: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:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean)</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>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: Array&lt;ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> Unit) )</ID>
<ID>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:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</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$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: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>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: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:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
<ID>TooGenericExceptionThrown:Downloader.kt$Downloader.DownloadTask$throw RuntimeException( String.format( Locale.ROOT, "Download of '%s' was cancelled", downloadFile.track ) )</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:SongView.kt$SongView$e: Exception</ID>
<ID>TooGenericExceptionThrown:DownloadFile.kt$DownloadFile.DownloadTask$throw Exception(String.format("Download of '%s' was cancelled", song))</ID>
<ID>TooManyFunctions:MediaPlayerService.kt$MediaPlayerService : Service</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
<ID>TooManyFunctions:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
<ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</CurrentIssues>
</SmellBaseline>

View File

@ -64,10 +64,13 @@ style:
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', '5', '10', '100', '256', '512', '1000', '1024']
ignoreEnums: true
ignorePropertyDeclaration: true
UnnecessaryAbstractClass:

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
Bug fixes
- #673: Disabling "Browse Using ID3 Tags" causes artist search result content to mismatch.
- #679: Keyboard should be hidden when navigating away from Search.
- #686: In landscape mode, the scroll bar is missing in the about text.
- #691: TrackCollectionFragment: fix play all button.
- #698: Track based context menus do not function correctly in most fragments.
Features
- #669: Option to change language.
Enhancements
- #654: Update OkHttp.
- #694: Reword alert for better help.
- #702: Show Downloads on Play.

View File

@ -0,0 +1 @@
Refactor and redesign artist list

View File

@ -0,0 +1 @@
Fall backs to path when comparing tracks and fixes #369

View File

@ -0,0 +1 @@
Refactored the application main menu

View File

@ -0,0 +1 @@
Refactored the application main menu

View File

@ -9,5 +9,7 @@ Enhancements
- #568: Rework Downloader.
- #567: Use semantically correct API endpoint when streaming/downloading.
- #572: Moved drag handle to the left in the Now Playing list.
- #585: Added setting to disable Now Playing List sending for incompatible bluetooth devices.
- #596: Added option whether to create a share on the server when sharing songs.
- #585: Added setting to disable Now Playing List sending for incompatible
bluetooth devices.
- #596: Added option whether to create a share on the server when sharing
songs.

View File

@ -1,3 +1,4 @@
Otros
- #671: Actualizado versions.mockito de 4.1.0 a 4.3.1.
- Traducciones actualizadas.

View File

@ -1,3 +1,4 @@
Mejoras
- #683: Reescribir el acerca de y eliminar el webview.
- #685: Posibilidad de seleccionar el color del servidor.

View File

@ -1,2 +0,0 @@
Corrección de errores
- #688: Fallo de conexión.

View File

@ -1,14 +0,0 @@
Corrección de errores
- #673: La desactivación de la opción "Examinar mediante etiquetas ID3" hace que el contenido de los resultados de la búsqueda de artistas no coincida.
- #679: El teclado debería estar oculto cuando se navega fuera de la Búsqueda.
- #686: En modo apaisado, falta la barra de desplazamiento en el texto de acerca de.
- #691: TrackCollectionFragment: arreglar el botón de reproducir todo.
- #698: Los menús contextuales basados en pistas no funcionan correctamente en la mayoría de los fragmentos.
Características
- #669: Opción de cambio de idioma.
Mejoras
- #654: Actualización de OkHttp.
- #694: Reformular la alerta para mejorar la ayuda.
- #702: Mostrar descargas al reproducir.

View File

@ -0,0 +1 @@
Refactorizada y rediseñada la lista de artistas

View File

@ -0,0 +1 @@
Cuando un fichero no tiene etiquetas de número de pista lo ordena por orden alfabético. Además hemos arreglado el bug #369

View File

@ -0,0 +1 @@
Refactorizado el menú principal de la aplicación

View File

@ -0,0 +1 @@
Refactorizado el menú principal de la aplicación

View File

@ -1,5 +1,6 @@
Correción de errores
- #594: Agregado un intent de PlaybackComplete cuando se completa la reproducción de una canción.
- #594: Agregado un intent de PlaybackComplete cuando se completa la
reproducción de una canción.
- #593: Corregidas las listas de álbumes.
- #602: NPE corregido.
@ -7,7 +8,11 @@ Mejoras
- #558: La llamada a video puede ser estática.
- #559: Agregado un mejor soporte sin conexión.
- #568: Se ha reescrito el downloader.
- #567: Se utiliza el endpoint semánticamente correcto al realizar streaming o descargar.
- #572: Se ha movido el botón de arrastre de canción hacia la izquierda en la lista de reproducción.
- #585: Agregada una configuración para deshabilitar el envío de la Lista de reproducción en curso para dispositivos Bluetooth incompatibles.
- #596: Se agregó la opción de crear un recurso compartido en el servidor al compartir canciones.
- #567: Se utiliza el endpoint semánticamente correcto al realizar streaming
o descargar.
- #572: Se ha movido el botón de arrastre de canción hacia la izquierda en
la lista de reproducción.
- #585: Agregada una configuración para deshabilitar el envío de la Lista de
reproducción en curso para dispositivos Bluetooth incompatibles.
- #596: Se agregó la opción de crear un recurso compartido en el servidor al
compartir canciones.

View File

@ -2,13 +2,16 @@ Corrección de errores
- #609: Comportamiento extraño de scrobbling (offset).
Mejoras
- #599: Se ha movido el selector de servidor y la configuración al menú de navegación.
- #600: Migración de la utilidad de permisos a Kotlin, aumento del SDK mínimo a 17.
- #599: Se ha movido el selector de servidor y la configuración al menú de
navegación.
- #600: Migración de la utilidad de permisos a Kotlin, aumento del SDK
mínimo a 17.
- #604: Implementar una vista de Descarga.
- #613: targetSdkVersion debe ser 30 o superior.
- #622: Refactorización de eventos.
- #641: Eliminar el almacenamiento de funciones.
- #642: Eliminar MergeAdapter y SackOfViewsAdapter.
- #649: Unificar el manejo del diálogo de error.
- #652: Manejo de ubicación de caché personalizado actualizado para eliminar isUri.
- #652: Manejo de ubicación de caché personalizado actualizado para eliminar
isUri.
- #662: Mejorar las migraciones de bases de datos.

View File

@ -1,31 +1,30 @@
[versions]
# You need to run ./gradlew wrapper after updating the version
gradle = "7.3.3"
gradle = "7.3.2"
navigation = "2.3.5"
gradlePlugin = "7.2.1"
gradlePlugin = "7.0.4"
androidxcore = "1.6.0"
ktlint = "0.43.2"
ktlintGradle = "10.2.0"
detekt = "1.19.0"
jacoco = "0.8.7"
preferences = "1.1.1"
media = "1.3.1"
media3 = "1.0.0-beta01"
androidSupport = "1.4.0"
androidSupport = "28.0.0"
androidLegacySupport = "1.0.0"
androidSupportDesign = "1.6.1"
androidSupportDesign = "1.4.0"
constraintLayout = "2.1.1"
multidex = "2.0.1"
room = "2.4.2"
room = "2.4.0"
kotlin = "1.6.10"
kotlinxCoroutines = "1.6.0-native-mt"
kotlinxGuava = "1.6.0"
viewModelKtx = "2.4.1"
viewModelKtx = "2.3.0"
retrofit = "2.9.0"
jackson = "2.10.1"
okhttp = "4.9.1"
retrofit = "2.6.4"
jackson = "2.9.5"
okhttp = "3.12.13"
koin = "3.0.2"
picasso = "2.71828"
@ -48,11 +47,12 @@ gradle = { module = "com.android.tools.build:gradle", version.r
kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" }
detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
jacoco = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" }
core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" }
support = { module = "androidx.legacy:legacy-support-v4", version.ref = "androidLegacySupport" }
design = { module = "com.google.android.material:material", version.ref = "androidSupportDesign" }
annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" }
annotations = { module = "com.android.support:support-annotations", version.ref = "androidSupport" }
multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }
constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintLayout" }
room = { module = "androidx.room:room-compiler", version.ref = "room" }
@ -66,14 +66,10 @@ navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", ve
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
media = { module = "androidx.media:media", version.ref = "media" }
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3session = { module = "androidx.media3:media3-session", version.ref = "media3" }
kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxGuava"}
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
gsonConverter = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" }
@ -101,3 +97,4 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }

View File

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

View File

@ -1,6 +1,5 @@
#Fri Jun 17 23:13:49 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -3,6 +3,7 @@
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
android {
@ -47,6 +48,10 @@ android {
tasks.withType(Test) {
useJUnitPlatform()
jacoco {
includeNoLocationClasses = true
excludes += jacocoExclude
}
}
dependencies {
@ -56,4 +61,11 @@ dependencies {
testRuntimeOnly libs.junitVintage
}
jacoco {
toolVersion(libs.versions.jacoco.get())
}
ext {
jacocoExclude = ['jdk.internal.*']
}

View File

@ -0,0 +1,92 @@
apply plugin: 'jacoco'
jacoco {
toolVersion(libs.versions.jacoco.get())
}
def mergedJacocoExec = file("${project.buildDir}/jacoco/jacocoMerged.exec")
def merge = tasks.register('jacocoMergeReports', JacocoMerge) {
group = "Reporting"
description = "Merge all jacoco reports from projects into one."
ListProperty<File> jacocoFiles = project.objects.listProperty(File.class)
project.subprojects { subproject ->
subproject.plugins.withId("jacoco") {
project.logger.info("${subproject.name} has Jacoco plugin applied")
subproject.tasks.withType(Test) { task ->
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
if (destFile.exists() && !task.name.contains("Release")) {
jacocoFiles.add(destFile)
}
}
}
}
executionData(jacocoFiles)
destinationFile(mergedJacocoExec)
}
tasks.register('jacocoFullReport', JacocoReport) {
dependsOn merge
group = "Reporting"
description = "Generate full Jacoco coverage report including all modules."
getClassDirectories().setFrom(files())
getSourceDirectories().setFrom(files())
getExecutionData().setFrom(files())
reports {
xml.enabled = true
html.enabled = true
csv.enabled = false
}
// Always run merging, as all input calculation is done in doFirst {}
outputs.upToDateWhen { false }
// Task will run anyway even if initial inputs are empty
onlyIf = { true }
project.subprojects { subproject ->
subproject.plugins.withId("jacoco") {
project.logger.info("${subproject.name} has Jacoco plugin applied")
subproject.plugins.withId("kotlin-android") {
project.logger.info("${subproject.name} is android project")
def mainSources = subproject.extensions.findByName("android").sourceSets['main']
project.logger.info("Android sources: ${mainSources.java.srcDirs}")
mainSources.java.srcDirs.forEach {
additionalSourceDirs(it)
}
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
additionalClassDirs(fileTree(
dir: "${subproject.buildDir}/tmp/kotlin-classes/debug",
excludes: subproject.jacocoExclude
))
}
subproject.plugins.withId("kotlin") { plugin ->
project.logger.info("${subproject.name} is common kotlin project")
SourceDirectorySet mainSources = subproject.extensions.getByName("kotlin")
.sourceSets[SourceSet.MAIN_SOURCE_SET_NAME]
.kotlin
mainSources.srcDirs.forEach {
project.logger.debug("Adding sources: $it")
additionalSourceDirs(it)
}
project.logger.info("Subproject exclude: ${subproject.jacocoExclude}")
additionalClassDirs(fileTree(
dir: "${subproject.buildDir}/classes/kotlin/main",
excludes: subproject.jacocoExclude
))
}
subproject.tasks.withType(Test) { task ->
File destFile = task.extensions.getByType(JacocoTaskExtension.class).destinationFile
if (destFile.exists() && !task.name.contains("Release")) {
project.logger.info("Adding execution data: $destFile")
executionData(destFile)
}
}
}
}
}

View File

@ -3,6 +3,7 @@
*/
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
sourceSets {
@ -20,8 +21,36 @@ dependencies {
testRuntimeOnly libs.junitVintage
}
jacoco {
toolVersion(libs.versions.jacoco.get())
}
ext {
// override it in the module
jacocoExclude = ['jdk.internal.*']
}
jacocoTestReport {
reports {
html.required = true
xml.required = false
csv.required = false
}
afterEvaluate {
getClassDirectories().setFrom(files(classDirectories.files.collect {
fileTree(dir: it, excludes: jacocoExclude)
}))
}
}
tasks.named("test").configure {
useJUnitPlatform()
jacoco {
excludes += jacocoExclude
includeNoLocationClasses = true
}
finalizedBy jacocoTestReport
}
tasks.register("ciTest") {

View File

@ -1,6 +1,7 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply from: "../gradle_scripts/code_quality.gradle"
android {
@ -8,15 +9,14 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 103
versionName "3.2.0"
versionCode 101
versionName "3.1.0"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
resConfigs 'cs', 'de', 'en', 'es', 'fr', 'hu', 'it', 'nl', 'pl', 'pt', 'pt-rBR', 'ru', 'zh-rCN', 'zh-rTW'
}
bundle.language.enableSplit = false
resConfigs "cs", "de", "en", "es", "fr", "hu", "it", "nl", "pl", "pt", "pt-rBR", "ru", "zh-rCN", "zh-rTW"
}
buildTypes {
release {
@ -40,12 +40,20 @@ android {
main.java.srcDirs += "${projectDir}/src/main/kotlin"
test.java.srcDirs += "${projectDir}/src/test/kotlin"
}
packagingOptions {
resources {
excludes += ['META-INF/LICENSE']
}
exclude 'META-INF/LICENSE'
}
lintOptions {
baselineFile file("lint-baseline.xml")
ignore 'MissingTranslation'
ignore 'UnusedQuantity'
warning 'ImpliedQuantity'
disable 'IconMissingDensityFolder', "VectorPath"
abortOnError true
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "1.8"
@ -63,18 +71,9 @@ android {
kapt {
arguments {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
arg("room.schemaLocation", "$buildDir/schemas".toString())
}
}
lint {
baseline = file("lint-baseline.xml")
abortOnError true
warningsAsErrors true
disable 'IconMissingDensityFolder', 'VectorPath'
ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity'
disable 'ObsoleteLintCustomCheck'
}
}
@ -100,9 +99,6 @@ dependencies {
implementation libs.constraintLayout
implementation libs.preferences
implementation libs.media
implementation libs.media3exoplayer
implementation libs.media3session
implementation libs.media3okhttp
implementation libs.navigationFragment
implementation libs.navigationUi
@ -112,7 +108,6 @@ dependencies {
implementation libs.kotlinStdlib
implementation libs.kotlinxCoroutines
implementation libs.kotlinxGuava
implementation libs.koinAndroid
implementation libs.okhttpLogging
implementation libs.fastScroll
@ -135,3 +130,36 @@ dependencies {
implementation libs.timber
}
jacoco {
toolVersion(libs.versions.jacoco.get())
}
// Excluding all java classes and stuff that should not be covered
ext {
jacocoExclude = [
'**/activity/**',
'**/audiofx/**',
'**/fragment/**',
'**/provider/**',
'**/receiver/**',
'**/service/**',
'**/Test/**',
'**/util/**',
'**/view/**',
'**/R$*.class',
'**/R.class',
'**/BuildConfig.class',
'**/di/**',
'jdk.internal.*'
]
}
jacoco {
toolVersion(libs.versions.jacoco.get())
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
jacoco.excludes += jacocoExclude
}

View File

@ -1,15 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.2.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.2.1)" variant="all" version="7.2.1">
<issues format="6" by="lint 7.0.4" type="baseline" client="gradle" name="AGP (7.0.4)" variant="all" version="7.0.4">
<issue
id="InflateParams"
message="Avoid passing `null` as the view root (needed to resolve layout parameters on the inflated layout&apos;s root element)"
errorLine1=" val view = inflater.inflate(R.layout.jukebox_volume, null)"
errorLine2=" ~~~~">
errorLine1=" View view = inflater.inflate(R.layout.jukebox_volume, null);"
errorLine2=" ~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt"
line="331"
column="66"/>
file="src/main/java/org/moire/ultrasonic/service/JukeboxMediaPlayer.java"
line="477"
column="58"/>
</issue>
<issue
id="Typos"
message="&quot;lizensiert&quot; is a common misspelling; did you mean &quot;lizenziert&quot; ?"
errorLine1=" &lt;string name=&quot;settings.testing_unlicensed&quot;>Verbindung OK, Server nicht lizensiert.&lt;/string>"
errorLine2=" ^">
<location
file="src/main/res/values-de/strings.xml"
line="289"
column="76"/>
</issue>
<issue
id="PluralsCandidate"
message="Formatting %d followed by words (&quot;Artists&quot;): This should probably be a plural rather than a string"
errorLine1=" &lt;string name=&quot;parser.artist_count&quot;>Got %d Artists.&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="134"
column="5"/>
</issue>
<issue
@ -19,7 +41,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="154"
line="151"
column="5"/>
</issue>
@ -34,6 +56,17 @@
column="73"/>
</issue>
<issue
id="SetJavaScriptEnabled"
message="Using `setJavaScriptEnabled` can introduce XSS vulnerabilities into your application, review carefully"
errorLine1=" webView.getSettings().setJavaScriptEnabled(true);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/org/moire/ultrasonic/fragment/AboutFragment.java"
line="51"
column="9"/>
</issue>
<issue
id="TrustAllX509TrustManager"
message="`checkClientTrusted` is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers">
@ -55,32 +88,197 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="155"
line="146"
column="10"/>
</issue>
<issue
id="ExportedReceiver"
message="Exported receiver does not require permission"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;"
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;>"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="79"
line="81"
column="10"/>
</issue>
<issue
id="ExportedService"
message="Exported service does not require permission"
errorLine1=" &lt;service android:name=&quot;.playback.PlaybackService&quot;"
errorLine2=" ~~~~~~~">
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise. For launcher activities, this should be set to `true`."
errorLine1=" &lt;activity android:name=&quot;.activity.NavigationActivity&quot;"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="68"
line="41"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.MediaButtonIntentReceiver&quot;>"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="76"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.UltrasonicIntentReceiver&quot;>"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="81"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver android:name=&quot;.receiver.BluetoothIntentReceiver&quot;>"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="93"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="101"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="112"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="123"
column="10"/>
</issue>
<issue
id="IntentFilterExportedReceiver"
message="As of Android 12, `android:exported` must be set; use `true` to make the activity \&#xA;available to other apps, and `false` otherwise."
errorLine1=" &lt;receiver"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="134"
column="10"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" return PendingIntent.getActivity(this, 0, intent, flags)"
errorLine2=" ~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt"
line="708"
column="59"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" PendingIntent.FLAG_CANCEL_CURRENT"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt"
line="323"
column="13"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT);"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
line="198"
column="80"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0);"
errorLine2=" ~">
<location
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
line="206"
column="67"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0);"
errorLine2=" ~">
<location
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
line="212"
column="67"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0);"
errorLine2=" ~">
<location
file="src/main/java/org/moire/ultrasonic/provider/UltrasonicAppWidgetProvider.java"
line="218"
column="67"/>
</issue>
<issue
id="UnspecifiedImmutableFlag"
message="Missing `PendingIntent` mutability flag"
errorLine1=" return PendingIntent.getBroadcast(context, requestCode, intent, flags)"
errorLine2=" ~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/util/Util.kt"
line="891"
column="73"/>
</issue>
<issue
id="NotifyDataSetChanged"
message="It will always be more efficient to use more specific change events if you can. Rely on `notifyDataSetChanged` as a last resort."
errorLine1=" viewAdapter.notifyDataSetChanged()"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt"
line="908"
column="21"/>
</issue>
<issue
id="ObsoleteLayoutParam"
message="Invalid layout param in a `LinearLayout`: `layout_above`"
@ -147,17 +345,6 @@
column="5"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_baseline_close` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_baseline_close.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_menu_arrow` appears to be unused"
@ -171,59 +358,191 @@
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_pause` appears to be unused"
errorLine1="&lt;vector android:height=&quot;48dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_pause.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_play` appears to be unused"
errorLine1="&lt;vector android:height=&quot;48dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_play.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_seek_to_next` appears to be unused"
errorLine1="&lt;vector android:height=&quot;32dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_seek_to_next.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_seek_to_previous` appears to be unused"
errorLine1="&lt;vector android:height=&quot;32dp&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_seek_to_previous.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.media3_notification_small_icon` appears to be unused"
message="The resource `R.drawable.menu_arrow` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/media3_notification_small_icon.xml"
file="src/main/res/drawable/menu_arrow.xml"
line="1"
column="1"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.main_shuffle` appears to be unused"
errorLine1=" &lt;string name=&quot;main.shuffle&quot;>Shuffle Play&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="114"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.menu_navigation` appears to be unused"
errorLine1=" &lt;string name=&quot;menu.navigation&quot;>Navigation&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="128"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.music_service_retry` appears to be unused"
errorLine1=" &lt;string name=&quot;music_service.retry&quot;>A network error occurred. Retrying %1$d of %2$d.&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="133"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.parser_artist_count` appears to be unused"
errorLine1=" &lt;string name=&quot;parser.artist_count&quot;>Got %d Artists.&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="134"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.parser_reading` appears to be unused"
errorLine1=" &lt;string name=&quot;parser.reading&quot;>Reading from server.&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="135"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.parser_reading_done` appears to be unused"
errorLine1=" &lt;string name=&quot;parser.reading_done&quot;>Reading from server. Done!&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="136"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.progress_wait` appears to be unused"
errorLine1=" &lt;string name=&quot;progress.wait&quot;>Please wait&amp;#8230;&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="141"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.search_search` appears to be unused"
errorLine1=" &lt;string name=&quot;search.search&quot;>Click to search&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="147"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.service_connecting` appears to be unused"
errorLine1=" &lt;string name=&quot;service.connecting&quot;>Contacting server, please wait.&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="159"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_allow_self_signed_certificate` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.allow_self_signed_certificate&quot; translatable=&quot;false&quot;>allowSelfSignedCertificate&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="160"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_enable_ldap_user_support` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.enable_ldap_user_support&quot; translatable=&quot;false&quot;>enableLdapUserSupport&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="161"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_invalid_username` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.invalid_username&quot;>Please specify a valid username (no trailing spaces).&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="230"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_server_remove_server` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.server_remove_server&quot;>Remove Server&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="299"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_server_unused` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.server_unused&quot;>Unused&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="302"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.string.settings_server_address_unset` appears to be unused"
errorLine1=" &lt;string name=&quot;settings.server_address_unset&quot; translatable=&quot;false&quot;>http://example.com&lt;/string>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="387"
column="13"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.plurals.select_album_donate_dialog_n_trial_days_left` appears to be unused"
errorLine1=" &lt;plurals name=&quot;select_album_donate_dialog_n_trial_days_left&quot;>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="447"
column="14"/>
</issue>
<issue
id="IconDuplicates"
message="The following unrelated icon files have identical contents: list_pressed_holo_dark.9.png, list_pressed_holo_light.9.png">
@ -519,6 +838,39 @@
column="6"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView a:id=&quot;@+id/help_back&quot;"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/help.xml"
line="12"
column="10"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView a:id=&quot;@+id/help_stop&quot;"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/help.xml"
line="18"
column="10"/>
</issue>
<issue
id="ContentDescription"
message="Missing `contentDescription` attribute on image"
errorLine1=" &lt;ImageView a:id=&quot;@+id/help_forward&quot;"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/res/layout/help.xml"
line="24"
column="10"/>
</issue>
<issue
id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"
@ -673,6 +1025,17 @@
column="13"/>
</issue>
<issue
id="RelativeOverlap"
message="`LinearLayout-3` can overlap `LinearLayout-1` if LinearLayout-1, LinearLayout-3 grow due to localized text expansion"
errorLine1=" &lt;LinearLayout"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/layout/player_media_info.xml"
line="52"
column="6"/>
</issue>
<issue
id="RelativeOverlap"
message="`@id/current_playing_duration` can overlap `@id/current_playing_position` if @string/util.no_time, @string/util.no_time grow due to localized text expansion"

View File

@ -1,124 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "4cea788a99b9bc28500948b1cd92e537",
"entities": [
{
"tableName": "ServerSetting",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `index` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `color` INTEGER, `userName` TEXT NOT NULL, `password` TEXT NOT NULL, `jukeboxByDefault` INTEGER NOT NULL, `allowSelfSignedCertificate` INTEGER NOT NULL, `ldapSupport` INTEGER NOT NULL, `musicFolderId` TEXT, `minimumApiVersion` TEXT, `chatSupport` INTEGER, `bookmarkSupport` INTEGER, `shareSupport` INTEGER, `podcastSupport` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "userName",
"columnName": "userName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "jukeboxByDefault",
"columnName": "jukeboxByDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "allowSelfSignedCertificate",
"columnName": "allowSelfSignedCertificate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ldapSupport",
"columnName": "ldapSupport",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "minimumApiVersion",
"columnName": "minimumApiVersion",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatSupport",
"columnName": "chatSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bookmarkSupport",
"columnName": "bookmarkSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "shareSupport",
"columnName": "shareSupport",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "podcastSupport",
"columnName": "podcastSupport",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4cea788a99b9bc28500948b1cd92e537')"
]
}
}

View File

@ -1,146 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "0580217b1e87b02d2edaf9b008891cbc",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0580217b1e87b02d2edaf9b008891cbc')"
]
}
}

View File

@ -1,474 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "b6ac795e7857eac4fed2dbbd01f80fb8",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "suffix",
"columnName": "suffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedContentType",
"columnName": "transcodedContentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedSuffix",
"columnName": "transcodedSuffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bitRate",
"columnName": "bitRate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarkPosition",
"columnName": "bookmarkPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userRating",
"columnName": "userRating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "averageRating",
"columnName": "averageRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6ac795e7857eac4fed2dbbd01f80fb8')"
]
}
}

View File

@ -1,514 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "95e83d6663a862c03ac46f9567453ded",
"entities": [
{
"tableName": "artists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "albums",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDirectory",
"columnName": "isDirectory",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "album",
"columnName": "album",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumId",
"columnName": "albumId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "artistId",
"columnName": "artistId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "year",
"columnName": "year",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "genre",
"columnName": "genre",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "contentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "suffix",
"columnName": "suffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedContentType",
"columnName": "transcodedContentType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "transcodedSuffix",
"columnName": "transcodedSuffix",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "size",
"columnName": "size",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "songCount",
"columnName": "songCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bitRate",
"columnName": "bitRate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isVideo",
"columnName": "isVideo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "discNumber",
"columnName": "discNumber",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "created",
"columnName": "created",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarkPosition",
"columnName": "bookmarkPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userRating",
"columnName": "userRating",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "averageRating",
"columnName": "averageRating",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "indexes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "index",
"columnName": "index",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "coverArt",
"columnName": "coverArt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "albumCount",
"columnName": "albumCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "closeness",
"columnName": "closeness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "musicFolderId",
"columnName": "musicFolderId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "music_folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT NOT NULL, PRIMARY KEY(`id`, `serverId`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"serverId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '95e83d6663a862c03ac46f9567453ded')"
]
}
}

View File

@ -21,8 +21,6 @@
<application
android:allowBackup="false"
android:fullBackupContent="@xml/backup_descriptor"
android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/NoActionBar"
@ -42,8 +40,8 @@
<activity android:name=".activity.NavigationActivity"
android:configChanges="orientation|keyboardHidden"
android:launchMode="singleTask"
android:exported="true">
android:label="@string/common.appname"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH"/>
@ -59,25 +57,28 @@
</activity>
<service
android:name=".service.DownloadService"
android:name=".service.MediaPlayerService"
android:label="Ultrasonic Media Player Service"
android:exported="false">
</service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".playback.PlaybackService"
<service
tools:ignore="ExportedService"
android:name=".service.AutoMediaBrowserService"
android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name=".receiver.UltrasonicIntentReceiver"
android:exported="true">
<receiver android:name=".receiver.MediaButtonIntentReceiver">
<intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<receiver android:name=".receiver.UltrasonicIntentReceiver">
<intent-filter>
<action android:name="org.moire.ultrasonic.CMD_TOGGLEPAUSE"/>
<action android:name="org.moire.ultrasonic.CMD_PLAY"/>
@ -89,8 +90,7 @@
<action android:name="org.moire.ultrasonic.CMD_PROCESS_KEYCODE"/>
</intent-filter>
</receiver>
<receiver android:name=".receiver.BluetoothIntentReceiver"
android:exported="true">
<receiver android:name=".receiver.BluetoothIntentReceiver">
<intent-filter>
<action android:name="android.bluetooth.device.action.ACL_CONNECTED"/>
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED"/>
@ -100,8 +100,7 @@
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider4X1"
android:label="Ultrasonic (4x1)"
android:exported="false">
android:label="Ultrasonic (4x1)">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
@ -112,8 +111,7 @@
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider4X2"
android:label="Ultrasonic (4x2)"
android:exported="false">
android:label="Ultrasonic (4x2)">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
@ -124,8 +122,7 @@
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider4X3"
android:label="Ultrasonic (4x3)"
android:exported="false">
android:label="Ultrasonic (4x3)">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
@ -136,8 +133,7 @@
</receiver>
<receiver
android:name=".provider.UltrasonicAppWidgetProvider4X4"
android:label="Ultrasonic (4x4)"
android:exported="false">
android:label="Ultrasonic (4x4)">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
@ -146,17 +142,18 @@
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_4x4"/>
</receiver>
<receiver android:name=".receiver.MediaButtonIntentReceiver"
android:exported="true">
<intent-filter android:priority="2147483647">
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<provider
android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
android:exported="true" />
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
<receiver
android:name=".receiver.A2dpIntentReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.android.music.playstatusrequest"/>
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,221 @@
package org.moire.ultrasonic.provider;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.os.Environment;
import android.view.KeyEvent;
import android.widget.RemoteViews;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.activity.NavigationActivity;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.imageloader.BitmapUtils;
import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver;
import org.moire.ultrasonic.service.MediaPlayerController;
import org.moire.ultrasonic.util.Constants;
import timber.log.Timber;
/**
* Widget Provider for the Ultrasonic Widgets
*/
public class UltrasonicAppWidgetProvider extends AppWidgetProvider
{
protected int layoutId;
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds)
{
defaultAppWidget(context, appWidgetIds);
}
/**
* Initialize given widgets to default state, where we launch Ultrasonic on default click
* and hide actions if service not running.
*/
private void defaultAppWidget(Context context, int[] appWidgetIds)
{
final Resources res = context.getResources();
final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId);
views.setTextViewText(R.id.title, null);
views.setTextViewText(R.id.album, null);
views.setTextViewText(R.id.artist, res.getText(R.string.widget_initial_text));
linkButtons(context, views, false);
pushUpdate(context, appWidgetIds, views);
}
private void pushUpdate(Context context, int[] appWidgetIds, RemoteViews views)
{
// Update specific list of appWidgetIds if given, otherwise default to all
final AppWidgetManager manager = AppWidgetManager.getInstance(context);
if (manager != null)
{
if (appWidgetIds != null)
{
manager.updateAppWidget(appWidgetIds, views);
}
else
{
manager.updateAppWidget(new ComponentName(context, this.getClass()), views);
}
}
}
/**
* Handle a change notification coming over from {@link MediaPlayerController}
*/
public void notifyChange(Context context, MusicDirectory.Entry currentSong, boolean playing, boolean setAlbum)
{
if (hasInstances(context))
{
performUpdate(context, currentSong, playing, setAlbum);
}
}
/**
* Check against {@link AppWidgetManager} if there are any instances of this widget.
*/
private boolean hasInstances(Context context)
{
AppWidgetManager manager = AppWidgetManager.getInstance(context);
if (manager != null)
{
int[] appWidgetIds = manager.getAppWidgetIds(new ComponentName(context, getClass()));
return (appWidgetIds.length > 0);
}
return false;
}
/**
* Update all active widget instances by pushing changes
*/
private void performUpdate(Context context, MusicDirectory.Entry currentSong, boolean playing, boolean setAlbum)
{
final Resources res = context.getResources();
final RemoteViews views = new RemoteViews(context.getPackageName(), this.layoutId);
String title = currentSong == null ? null : currentSong.getTitle();
String artist = currentSong == null ? null : currentSong.getArtist();
String album = currentSong == null ? null : currentSong.getAlbum();
CharSequence errorState = null;
// Show error message?
String status = Environment.getExternalStorageState();
if (status.equals(Environment.MEDIA_SHARED) || status.equals(Environment.MEDIA_UNMOUNTED))
{
errorState = res.getText(R.string.widget_sdcard_busy);
}
else if (status.equals(Environment.MEDIA_REMOVED))
{
errorState = res.getText(R.string.widget_sdcard_missing);
}
else if (currentSong == null)
{
errorState = res.getText(R.string.widget_initial_text);
}
if (errorState != null)
{
// Show error state to user
views.setTextViewText(R.id.title, null);
views.setTextViewText(R.id.artist, errorState);
if (setAlbum)
{
views.setTextViewText(R.id.album, null);
}
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
}
else
{
// No error, so show normal titles
views.setTextViewText(R.id.title, title);
views.setTextViewText(R.id.artist, artist);
if (setAlbum)
{
views.setTextViewText(R.id.album, album);
}
}
// Set correct drawable for pause state
if (playing)
{
views.setImageViewResource(R.id.control_play, R.drawable.media_pause_normal_dark);
}
else
{
views.setImageViewResource(R.id.control_play, R.drawable.media_start_normal_dark);
}
// Set the cover art
try
{
Bitmap bitmap = currentSong == null ? null : BitmapUtils.Companion.getAlbumArtBitmapFromDisk(currentSong, 240);
if (bitmap == null)
{
// Set default cover art
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
}
else
{
views.setImageViewBitmap(R.id.appwidget_coverart, bitmap);
}
}
catch (Exception x)
{
Timber.e(x, "Failed to load cover art");
views.setImageViewResource(R.id.appwidget_coverart, R.drawable.unknown_album);
}
// Link actions buttons to intents
linkButtons(context, views, currentSong != null);
pushUpdate(context, null, views);
}
/**
* Link up various button actions using {@link PendingIntent}.
*/
private static void linkButtons(Context context, RemoteViews views, boolean playerActive)
{
Intent intent = new Intent(context, NavigationActivity.class).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
if (playerActive)
intent.putExtra(Constants.INTENT_SHOW_PLAYER, true);
intent.setAction("android.intent.action.MAIN");
intent.addCategory("android.intent.category.LAUNCHER");
PendingIntent pendingIntent = PendingIntent.getActivity(context, 10, intent, PendingIntent.FLAG_UPDATE_CURRENT);
views.setOnClickPendingIntent(R.id.appwidget_coverart, pendingIntent);
views.setOnClickPendingIntent(R.id.appwidget_top, pendingIntent);
// Emulate media button clicks.
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
pendingIntent = PendingIntent.getBroadcast(context, 11, intent, 0);
views.setOnClickPendingIntent(R.id.control_play, pendingIntent);
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT));
pendingIntent = PendingIntent.getBroadcast(context, 12, intent, 0);
views.setOnClickPendingIntent(R.id.control_next, pendingIntent);
intent = new Intent(Constants.CMD_PROCESS_KEYCODE);
intent.setComponent(new ComponentName(context, MediaButtonIntentReceiver.class));
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS));
pendingIntent = PendingIntent.getBroadcast(context, 13, intent, 0);
views.setOnClickPendingIntent(R.id.control_previous, pendingIntent);
}
}

View File

@ -0,0 +1,42 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.provider;
import org.moire.ultrasonic.R;
public class UltrasonicAppWidgetProvider4X1 extends UltrasonicAppWidgetProvider
{
public UltrasonicAppWidgetProvider4X1()
{
super();
this.layoutId = R.layout.appwidget4x1;
}
private static UltrasonicAppWidgetProvider4X1 instance;
public static synchronized UltrasonicAppWidgetProvider4X1 getInstance()
{
if (instance == null)
{
instance = new UltrasonicAppWidgetProvider4X1();
}
return instance;
}
}

View File

@ -0,0 +1,42 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.provider;
import org.moire.ultrasonic.R;
public class UltrasonicAppWidgetProvider4X2 extends UltrasonicAppWidgetProvider
{
public UltrasonicAppWidgetProvider4X2()
{
super();
this.layoutId = R.layout.appwidget4x2;
}
private static UltrasonicAppWidgetProvider4X2 instance;
public static synchronized UltrasonicAppWidgetProvider4X2 getInstance()
{
if (instance == null)
{
instance = new UltrasonicAppWidgetProvider4X2();
}
return instance;
}
}

View File

@ -0,0 +1,42 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.provider;
import org.moire.ultrasonic.R;
public class UltrasonicAppWidgetProvider4X3 extends UltrasonicAppWidgetProvider
{
public UltrasonicAppWidgetProvider4X3()
{
super();
this.layoutId = R.layout.appwidget4x3;
}
private static UltrasonicAppWidgetProvider4X3 instance;
public static synchronized UltrasonicAppWidgetProvider4X3 getInstance()
{
if (instance == null)
{
instance = new UltrasonicAppWidgetProvider4X3();
}
return instance;
}
}

View File

@ -0,0 +1,42 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.provider;
import org.moire.ultrasonic.R;
public class UltrasonicAppWidgetProvider4X4 extends UltrasonicAppWidgetProvider
{
public UltrasonicAppWidgetProvider4X4()
{
super();
this.layoutId = R.layout.appwidget4x4;
}
private static UltrasonicAppWidgetProvider4X4 instance;
public static synchronized UltrasonicAppWidgetProvider4X4 getInstance()
{
if (instance == null)
{
instance = new UltrasonicAppWidgetProvider4X4();
}
return instance;
}
}

View File

@ -0,0 +1,57 @@
package org.moire.ultrasonic.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.moire.ultrasonic.domain.MusicDirectory.Entry;
import org.moire.ultrasonic.service.MediaPlayerController;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
public class A2dpIntentReceiver extends BroadcastReceiver
{
private static final String PLAYSTATUS_RESPONSE = "com.android.music.playstatusresponse";
private Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
@Override
public void onReceive(Context context, Intent intent)
{
if (mediaPlayerControllerLazy.getValue().getCurrentPlaying() == null) return;
Entry song = mediaPlayerControllerLazy.getValue().getCurrentPlaying().getSong();
if (song == null) return;
Intent avrcpIntent = new Intent(PLAYSTATUS_RESPONSE);
Integer duration = song.getDuration();
int playerPosition = mediaPlayerControllerLazy.getValue().getPlayerPosition();
int listSize = mediaPlayerControllerLazy.getValue().getPlaylistSize();
if (duration != null)
{
avrcpIntent.putExtra("duration", (long) duration);
}
avrcpIntent.putExtra("position", (long) playerPosition);
avrcpIntent.putExtra("ListSize", (long) listSize);
switch (mediaPlayerControllerLazy.getValue().getPlayerState())
{
case STARTED:
avrcpIntent.putExtra("playing", true);
break;
case STOPPED:
case PAUSED:
case COMPLETED:
avrcpIntent.putExtra("playing", false);
break;
default:
return;
}
context.sendBroadcast(avrcpIntent);
}
}

View File

@ -0,0 +1,83 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 (C) Sindre Mehus
*/
package org.moire.ultrasonic.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcelable;
import timber.log.Timber;
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport;
import org.moire.ultrasonic.util.Constants;
import org.moire.ultrasonic.util.Settings;
import org.moire.ultrasonic.util.Util;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
/**
* @author Sindre Mehus
*/
public class MediaButtonIntentReceiver extends BroadcastReceiver
{
private Lazy<MediaPlayerLifecycleSupport> lifecycleSupport = inject(MediaPlayerLifecycleSupport.class);
@Override
public void onReceive(Context context, Intent intent)
{
String intentAction = intent.getAction();
// If media button are turned off and we received a media button, exit
if (!Settings.getMediaButtonsEnabled() && Intent.ACTION_MEDIA_BUTTON.equals(intentAction))
return;
// Only process media buttons and CMD_PROCESS_KEYCODE, which is received from the widgets
if (!Intent.ACTION_MEDIA_BUTTON.equals(intentAction) &&
!Constants.CMD_PROCESS_KEYCODE.equals(intentAction)) return;
Bundle extras = intent.getExtras();
if (extras == null)
{
return;
}
Parcelable event = (Parcelable) extras.get(Intent.EXTRA_KEY_EVENT);
Timber.i("Got MEDIA_BUTTON key event: %s", event);
try
{
Intent serviceIntent = new Intent(Constants.CMD_PROCESS_KEYCODE);
serviceIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
lifecycleSupport.getValue().receiveIntent(serviceIntent);
if (isOrderedBroadcast())
{
abortBroadcast();
}
}
catch (Exception x)
{
// Ignored.
}
}
}

View File

@ -0,0 +1,489 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.service;
import android.content.Context;
import android.os.Handler;
import timber.log.Timber;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;
import org.jetbrains.annotations.NotNull;
import org.moire.ultrasonic.R;
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException;
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException;
import org.moire.ultrasonic.app.UApp;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.JukeboxStatus;
import org.moire.ultrasonic.domain.PlayerState;
import org.moire.ultrasonic.util.Util;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import kotlin.Lazy;
import static org.koin.java.KoinJavaComponent.inject;
/**
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
*
* @author Sindre Mehus
* @version $Id$
*/
public class JukeboxMediaPlayer
{
private static final long STATUS_UPDATE_INTERVAL_SECONDS = 5L;
private final TaskQueue tasks = new TaskQueue();
private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture<?> statusUpdateFuture;
private final AtomicLong timeOfLastUpdate = new AtomicLong();
private JukeboxStatus jukeboxStatus;
private float gain = 0.5f;
private VolumeToast volumeToast;
private final AtomicBoolean running = new AtomicBoolean();
private Thread serviceThread;
private boolean enabled = false;
// TODO: These create circular references, try to refactor
private final Lazy<MediaPlayerController> mediaPlayerControllerLazy = inject(MediaPlayerController.class);
private final Downloader downloader;
// TODO: Report warning if queue fills up.
// TODO: Create shutdown method?
// TODO: Disable repeat.
// TODO: Persist RC state?
// TODO: Minimize status updates.
public JukeboxMediaPlayer(Downloader downloader)
{
this.downloader = downloader;
}
public void startJukeboxService()
{
if (running.get())
{
return;
}
running.set(true);
startProcessTasks();
Timber.d("Started Jukebox Service");
}
public void stopJukeboxService()
{
running.set(false);
Util.sleepQuietly(1000);
if (serviceThread != null)
{
serviceThread.interrupt();
}
Timber.d("Stopped Jukebox Service");
}
private void startProcessTasks()
{
serviceThread = new Thread()
{
@Override
public void run()
{
processTasks();
}
};
serviceThread.start();
}
private synchronized void startStatusUpdate()
{
stopStatusUpdate();
Runnable updateTask = new Runnable()
{
@Override
public void run()
{
tasks.remove(GetStatus.class);
tasks.add(new GetStatus());
}
};
statusUpdateFuture = executorService.scheduleWithFixedDelay(updateTask, STATUS_UPDATE_INTERVAL_SECONDS, STATUS_UPDATE_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
private synchronized void stopStatusUpdate()
{
if (statusUpdateFuture != null)
{
statusUpdateFuture.cancel(false);
statusUpdateFuture = null;
}
}
private void processTasks()
{
while (running.get())
{
JukeboxTask task = null;
try
{
if (!ActiveServerProvider.Companion.isOffline())
{
task = tasks.take();
JukeboxStatus status = task.execute();
onStatusUpdate(status);
}
}
catch (InterruptedException ignored)
{
}
catch (Throwable x)
{
onError(task, x);
}
Util.sleepQuietly(1);
}
}
private void onStatusUpdate(JukeboxStatus jukeboxStatus)
{
timeOfLastUpdate.set(System.currentTimeMillis());
this.jukeboxStatus = jukeboxStatus;
// Track change?
Integer index = jukeboxStatus.getCurrentPlayingIndex();
if (index != null && index != -1 && index != downloader.getCurrentPlayingIndex())
{
mediaPlayerControllerLazy.getValue().setCurrentPlaying(index);
}
}
private void onError(JukeboxTask task, Throwable x)
{
if (x instanceof ApiNotSupportedException && !(task instanceof Stop))
{
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old);
}
else if (x instanceof OfflineException && !(task instanceof Stop))
{
disableJukeboxOnError(x, R.string.download_jukebox_offline);
}
else if (x instanceof SubsonicRESTException && ((SubsonicRESTException) x).getCode() == 50 && !(task instanceof Stop))
{
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized);
}
else
{
Timber.e(x, "Failed to process jukebox task");
}
}
private void disableJukeboxOnError(Throwable x, final int resourceId)
{
Timber.w(x.toString());
Context context = UApp.Companion.applicationContext();
new Handler().post(() -> Util.toast(context, resourceId, false));
mediaPlayerControllerLazy.getValue().setJukeboxEnabled(false);
}
public void updatePlaylist()
{
if (!enabled) return;
tasks.remove(Skip.class);
tasks.remove(Stop.class);
tasks.remove(Start.class);
List<String> ids = new ArrayList<>();
for (DownloadFile file : downloader.getAll())
{
ids.add(file.getSong().getId());
}
tasks.add(new SetPlaylist(ids));
}
public void skip(final int index, final int offsetSeconds)
{
tasks.remove(Skip.class);
tasks.remove(Stop.class);
tasks.remove(Start.class);
startStatusUpdate();
if (jukeboxStatus != null)
{
jukeboxStatus.setPositionSeconds(offsetSeconds);
}
tasks.add(new Skip(index, offsetSeconds));
mediaPlayerControllerLazy.getValue().setPlayerState(PlayerState.STARTED);
}
public void stop()
{
tasks.remove(Stop.class);
tasks.remove(Start.class);
stopStatusUpdate();
tasks.add(new Stop());
}
public void start()
{
tasks.remove(Stop.class);
tasks.remove(Start.class);
startStatusUpdate();
tasks.add(new Start());
}
public synchronized void adjustVolume(boolean up)
{
float delta = up ? 0.05f : -0.05f;
gain += delta;
gain = Math.max(gain, 0.0f);
gain = Math.min(gain, 1.0f);
tasks.remove(SetGain.class);
tasks.add(new SetGain(gain));
Context context = UApp.Companion.applicationContext();
if (volumeToast == null) volumeToast = new VolumeToast(context);
volumeToast.setVolume(gain);
}
private MusicService getMusicService()
{
return MusicServiceFactory.getMusicService();
}
public int getPositionSeconds()
{
if (jukeboxStatus == null || jukeboxStatus.getPositionSeconds() == null || timeOfLastUpdate.get() == 0)
{
return 0;
}
if (jukeboxStatus.isPlaying())
{
int secondsSinceLastUpdate = (int) ((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L);
return jukeboxStatus.getPositionSeconds() + secondsSinceLastUpdate;
}
return jukeboxStatus.getPositionSeconds();
}
public void setEnabled(boolean enabled)
{
Timber.d("Jukebox Service setting enabled to %b", enabled);
this.enabled = enabled;
tasks.clear();
if (enabled)
{
updatePlaylist();
}
stop();
}
public boolean isEnabled()
{
return enabled;
}
private static class TaskQueue
{
private final LinkedBlockingQueue<JukeboxTask> queue = new LinkedBlockingQueue<>();
void add(JukeboxTask jukeboxTask)
{
queue.add(jukeboxTask);
}
JukeboxTask take() throws InterruptedException
{
return queue.take();
}
void remove(Class<? extends JukeboxTask> taskClass)
{
try
{
Iterator<JukeboxTask> iterator = queue.iterator();
while (iterator.hasNext())
{
JukeboxTask task = iterator.next();
if (taskClass.equals(task.getClass()))
{
iterator.remove();
}
}
}
catch (Throwable x)
{
Timber.w(x, "Failed to clean-up task queue.");
}
}
void clear()
{
queue.clear();
}
}
private abstract static class JukeboxTask
{
abstract JukeboxStatus execute() throws Exception;
@NotNull
@Override
public String toString()
{
return getClass().getSimpleName();
}
}
private class GetStatus extends JukeboxTask
{
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().getJukeboxStatus();
}
}
private class SetPlaylist extends JukeboxTask
{
private final List<String> ids;
SetPlaylist(List<String> ids)
{
this.ids = ids;
}
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().updateJukeboxPlaylist(ids);
}
}
private class Skip extends JukeboxTask
{
private final int index;
private final int offsetSeconds;
Skip(int index, int offsetSeconds)
{
this.index = index;
this.offsetSeconds = offsetSeconds;
}
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().skipJukebox(index, offsetSeconds);
}
}
private class Stop extends JukeboxTask
{
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().stopJukebox();
}
}
private class Start extends JukeboxTask
{
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().startJukebox();
}
}
private class SetGain extends JukeboxTask
{
private final float gain;
private SetGain(float gain)
{
this.gain = gain;
}
@Override
JukeboxStatus execute() throws Exception
{
return getMusicService().setJukeboxGain(gain);
}
}
private static class VolumeToast extends Toast
{
private final ProgressBar progressBar;
public VolumeToast(Context context)
{
super(context);
setDuration(Toast.LENGTH_SHORT);
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.jukebox_volume, null);
progressBar = (ProgressBar) view.findViewById(R.id.jukebox_volume_progress_bar);
setView(view);
setGravity(Gravity.TOP, 0, 0);
}
public void setVolume(float volume)
{
progressBar.setProgress(Math.round(100 * volume));
show();
}
}
}

View File

@ -18,7 +18,7 @@ public class Scrobbler
{
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
final String id = song.getTrack().getId();
final String id = song.getSong().getId();
if (id == null) return;
// Avoid duplicate registrations.

View File

@ -1,6 +1,6 @@
package org.moire.ultrasonic.service;
import org.moire.ultrasonic.domain.Track;
import org.moire.ultrasonic.domain.MusicDirectory;
import java.io.Serializable;
import java.util.ArrayList;
@ -13,7 +13,7 @@ public class State implements Serializable
{
public static final long serialVersionUID = -6346438781062572270L;
public List<Track> songs = new ArrayList<>();
public List<MusicDirectory.Entry> songs = new ArrayList<>();
public int currentPlayingIndex;
public int currentPlayingPosition;
}

View File

@ -1,6 +1,6 @@
package org.moire.ultrasonic.util;
import org.moire.ultrasonic.domain.Track;
import org.moire.ultrasonic.domain.MusicDirectory;
import java.util.List;
@ -12,5 +12,5 @@ public class ShareDetails
public String Description;
public boolean ShareOnServer;
public long Expiration;
public List<Track> Entries;
public List<MusicDirectory.Entry> Entries;
}

View File

@ -0,0 +1,124 @@
/*
This file is part of Subsonic.
Subsonic is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Subsonic is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
Copyright 2009 (C) Sindre Mehus
*/
package org.moire.ultrasonic.util;
import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.MusicService;
import org.moire.ultrasonic.service.MusicServiceFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import timber.log.Timber;
/**
* @author Sindre Mehus
* @version $Id$
*/
public class ShufflePlayBuffer
{
private static final int CAPACITY = 50;
private static final int REFILL_THRESHOLD = 40;
private final List<MusicDirectory.Entry> buffer = new ArrayList<>();
private ScheduledExecutorService executorService;
private int currentServer;
public boolean isEnabled = false;
public ShufflePlayBuffer()
{
}
public void onCreate()
{
executorService = Executors.newSingleThreadScheduledExecutor();
Runnable runnable = this::refill;
executorService.scheduleWithFixedDelay(runnable, 1, 10, TimeUnit.SECONDS);
Timber.i("ShufflePlayBuffer created");
}
public void onDestroy()
{
executorService.shutdown();
Timber.i("ShufflePlayBuffer destroyed");
}
public List<MusicDirectory.Entry> get(int size)
{
clearBufferIfNecessary();
List<MusicDirectory.Entry> result = new ArrayList<>(size);
synchronized (buffer)
{
while (!buffer.isEmpty() && result.size() < size)
{
result.add(buffer.remove(buffer.size() - 1));
}
}
Timber.i("Taking %d songs from shuffle play buffer. %d remaining.", result.size(), buffer.size());
return result;
}
private void refill()
{
if (!isEnabled) return;
// Check if active server has changed.
clearBufferIfNecessary();
if (buffer.size() > REFILL_THRESHOLD || (!Util.isNetworkConnected() && !ActiveServerProvider.Companion.isOffline()))
{
return;
}
try
{
MusicService service = MusicServiceFactory.getMusicService();
int n = CAPACITY - buffer.size();
MusicDirectory songs = service.getRandomSongs(n);
synchronized (buffer)
{
buffer.addAll(songs.getTracks());
Timber.i("Refilled shuffle play buffer with %d songs.", songs.getTracks().size());
}
}
catch (Exception x)
{
Timber.w(x, "Failed to refill shuffle play buffer.");
}
}
private void clearBufferIfNecessary()
{
synchronized (buffer)
{
if (currentServer != ActiveServerProvider.Companion.getActiveServerId())
{
currentServer = ActiveServerProvider.Companion.getActiveServerId();
buffer.clear();
}
}
}
}

View File

@ -0,0 +1,290 @@
package org.moire.ultrasonic.util;
import org.moire.ultrasonic.domain.MusicDirectory;
import org.moire.ultrasonic.service.DownloadFile;
import org.moire.ultrasonic.service.Supplier;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.URLDecoder;
import java.net.UnknownHostException;
import java.util.StringTokenizer;
import timber.log.Timber;
public class StreamProxy implements Runnable
{
private Thread thread;
private boolean isRunning;
private ServerSocket socket;
private int port;
private Supplier<DownloadFile> currentPlaying;
public StreamProxy(Supplier<DownloadFile> currentPlaying)
{
// Create listening socket
try
{
socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
socket.setSoTimeout(5000);
port = socket.getLocalPort();
this.currentPlaying = currentPlaying;
}
catch (UnknownHostException e)
{ // impossible
}
catch (IOException e)
{
Timber.e(e, "IOException initializing server");
}
}
public int getPort()
{
return port;
}
public void start()
{
thread = new Thread(this);
thread.start();
}
public void stop()
{
isRunning = false;
thread.interrupt();
}
@Override
public void run()
{
isRunning = true;
while (isRunning)
{
try
{
Socket client = socket.accept();
if (client == null)
{
continue;
}
Timber.i("Client connected");
StreamToMediaPlayerTask task = new StreamToMediaPlayerTask(client);
if (task.processRequest())
{
new Thread(task).start();
}
}
catch (SocketTimeoutException e)
{
// Do nothing
}
catch (IOException e)
{
Timber.e(e, "Error connecting to client");
}
}
Timber.i("Proxy interrupted. Shutting down.");
}
private class StreamToMediaPlayerTask implements Runnable {
String localPath;
Socket client;
int cbSkip;
StreamToMediaPlayerTask(Socket client) {
this.client = client;
}
private String readRequest() {
InputStream is;
String firstLine;
try {
is = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is), 8192);
firstLine = reader.readLine();
} catch (IOException e) {
Timber.e(e, "Error parsing request");
return null;
}
if (firstLine == null) {
Timber.i("Proxy client closed connection without a request.");
return null;
}
StringTokenizer st = new StringTokenizer(firstLine);
st.nextToken(); // method
String uri = st.nextToken();
String realUri = uri.substring(1);
Timber.i(realUri);
return realUri;
}
boolean processRequest() {
final String uri = readRequest();
if (uri == null || uri.isEmpty()) {
return false;
}
// Read HTTP headers
Timber.i("Processing request: %s", uri);
try {
localPath = URLDecoder.decode(uri, Constants.UTF_8);
} catch (UnsupportedEncodingException e) {
Timber.e(e, "Unsupported encoding");
return false;
}
Timber.i("Processing request for file %s", localPath);
if (Storage.INSTANCE.isPathExists(localPath)) return true;
// Usually the .partial file will be requested here, but sometimes it has already
// been renamed, so check if it is completed since
String saveFileName = FileUtil.INSTANCE.getSaveFile(localPath);
String completeFileName = FileUtil.INSTANCE.getCompleteFile(saveFileName);
if (Storage.INSTANCE.isPathExists(saveFileName)) {
localPath = saveFileName;
return true;
}
if (Storage.INSTANCE.isPathExists(completeFileName)) {
localPath = completeFileName;
return true;
}
Timber.e("File %s does not exist", localPath);
return false;
}
@Override
public void run()
{
Timber.i("Streaming song in background");
DownloadFile downloadFile = currentPlaying == null? null : currentPlaying.get();
MusicDirectory.Entry song = downloadFile.getSong();
long fileSize = downloadFile.getBitRate() * ((song.getDuration() != null) ? song.getDuration() : 0) * 1000 / 8;
Timber.i("Streaming fileSize: %d", fileSize);
// Create HTTP header
String headers = "HTTP/1.0 200 OK\r\n";
headers += "Content-Type: application/octet-stream\r\n";
headers += "Connection: close\r\n";
headers += "\r\n";
long cbToSend = fileSize - cbSkip;
OutputStream output = null;
byte[] buff = new byte[64 * 1024];
try
{
output = new BufferedOutputStream(client.getOutputStream(), 32 * 1024);
output.write(headers.getBytes());
if (!downloadFile.isWorkDone())
{
// Loop as long as there's stuff to send
while (isRunning && !client.isClosed())
{
// See if there's more to send
String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
int cbSentThisBatch = 0;
AbstractFile storageFile = Storage.INSTANCE.getFromPath(file);
if (storageFile != null)
{
InputStream input = storageFile.getFileInputStream();
try
{
long skip = input.skip(cbSkip);
int cbToSendThisBatch = input.available();
while (cbToSendThisBatch > 0)
{
int cbToRead = Math.min(cbToSendThisBatch, buff.length);
int cbRead = input.read(buff, 0, cbToRead);
if (cbRead == -1)
{
break;
}
cbToSendThisBatch -= cbRead;
cbToSend -= cbRead;
output.write(buff, 0, cbRead);
output.flush();
cbSkip += cbRead;
cbSentThisBatch += cbRead;
}
}
finally
{
input.close();
}
// Done regardless of whether or not it thinks it is
if (downloadFile.isWorkDone() && cbSkip >= file.length())
{
break;
}
}
// If we did nothing this batch, block for a second
if (cbSentThisBatch == 0)
{
Timber.d("Blocking until more data appears (%d)", cbToSend);
Util.sleepQuietly(1000L);
}
}
}
else
{
Timber.w("Requesting data for completely downloaded file");
}
}
catch (SocketException socketException)
{
Timber.e("SocketException() thrown, proxy client has probably closed. This can exit harmlessly");
}
catch (Exception e)
{
Timber.e("Exception thrown from streaming task:");
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
}
// Cleanup
try
{
if (output != null)
{
output.close();
}
client.close();
}
catch (IOException e)
{
Timber.e("IOException while cleaning up streaming task:");
Timber.e("%s : %s", e.getClass().getName(), e.getLocalizedMessage());
}
}
}
}

View File

@ -18,8 +18,6 @@
*/
package org.moire.ultrasonic.view;
import static org.koin.java.KoinJavaComponent.inject;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
@ -31,11 +29,14 @@ import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.Observer;
import org.moire.ultrasonic.audiofx.VisualizerController;
import org.moire.ultrasonic.domain.PlayerState;
import org.moire.ultrasonic.service.MediaPlayerController;
import kotlin.Lazy;
import timber.log.Timber;
import static org.koin.java.KoinJavaComponent.inject;
/**
* A simple class that draws waveform data received from a
* {@link Visualizer.OnDataCaptureListener#onWaveFormDataCapture}
@ -129,7 +130,7 @@ public class VisualizerView extends View
return;
}
if (!mediaPlayerControllerLazy.getValue().isPlaying())
if (mediaPlayerControllerLazy.getValue().getPlayerState() != PlayerState.STARTED)
{
return;
}

View File

@ -1,14 +1,12 @@
/*
* NavigationActivity.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.activity
import android.app.SearchManager
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources
@ -27,9 +25,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentContainerView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
@ -41,24 +36,23 @@ import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.navigation.NavigationView
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSettingDao
import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.fragment.OnBackPressedHandler
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.LocaleHelper
import org.moire.ultrasonic.util.ServerColor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Storage
@ -67,7 +61,7 @@ import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* The main (and only) Activity of Ultrasonic which loads all other screens as Fragments
* The main Activity of Ultrasonic which loads all other screens as Fragments
*/
@Suppress("TooManyFunctions")
class NavigationActivity : AppCompatActivity() {
@ -84,8 +78,8 @@ class NavigationActivity : AppCompatActivity() {
private var headerBackgroundImage: ImageView? = null
private lateinit var appBarConfiguration: AppBarConfiguration
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
private var themeChangedEventSubscription: Disposable? = null
private var playerStateSubscription: Disposable? = null
private val serverSettingsModel: ServerSettingsModel by viewModel()
private val lifecycleSupport: MediaPlayerLifecycleSupport by inject()
@ -99,16 +93,6 @@ class NavigationActivity : AppCompatActivity() {
private var cachedServerCount: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
Timber.d("onCreate called")
// First check if Koin has been started
if (UApp.instance != null && !UApp.instance!!.initiated) {
Timber.d("Starting Koin")
UApp.instance!!.startKoin()
} else {
Timber.d("No need to start Koin")
}
setUncaughtExceptionHandler()
Util.applyTheme(this)
@ -170,8 +154,9 @@ class NavigationActivity : AppCompatActivity() {
setMenuForServerCapabilities()
}
// Determine if this is a first run
val showWelcomeScreen = Util.isFirstRun()
// Determine first run and migrate server settings to DB as early as possible
var showWelcomeScreen = Util.isFirstRun()
val areServersMigrated: Boolean = serverSettingsModel.migrateFromPreferences()
// Migrate Feature storage if needed
// TODO: Remove in December 2022
@ -179,6 +164,9 @@ class NavigationActivity : AppCompatActivity() {
Settings.migrateFeatureStorage()
}
// If there are any servers in the DB, do not show the welcome screen
showWelcomeScreen = showWelcomeScreen and !areServersMigrated
loadSettings()
// This is a first run with only the demo entry inside the database
@ -192,25 +180,25 @@ class NavigationActivity : AppCompatActivity() {
hideNowPlaying()
}
rxBusSubscription += RxBus.playerStateObservable.subscribe {
if (it.state == STATE_READY)
playerStateSubscription = RxBus.playerStateObservable.subscribe {
if (it.state === PlayerState.STARTED || it.state === PlayerState.PAUSED)
showNowPlaying()
else
hideNowPlaying()
}
rxBusSubscription += RxBus.themeChangedEventObservable.subscribe {
themeChangedEventSubscription = RxBus.themeChangedEventObservable.subscribe {
recreate()
}
rxBusSubscription += RxBus.activeServerChangeObservable.subscribe {
updateNavigationHeaderForServer()
}
serverRepository.liveServerCount().observe(this) { count ->
cachedServerCount = count ?: 0
updateNavigationHeaderForServer()
}
serverRepository.liveServerCount().observe(
this,
{ count ->
cachedServerCount = count ?: 0
updateNavigationHeaderForServer()
}
)
ActiveServerProvider.liveActiveServerId.observe(this, { updateNavigationHeaderForServer() })
}
private fun updateNavigationHeaderForServer() {
@ -236,7 +224,6 @@ class NavigationActivity : AppCompatActivity() {
}
override fun onResume() {
Timber.d("onResume called")
super.onResume()
Storage.reset()
@ -250,11 +237,10 @@ class NavigationActivity : AppCompatActivity() {
}
override fun onDestroy() {
Timber.d("onDestroy called")
rxBusSubscription.dispose()
imageLoaderProvider.clearImageLoader()
UApp.instance!!.shutdownKoin()
super.onDestroy()
themeChangedEventSubscription?.dispose()
playerStateSubscription?.dispose()
imageLoaderProvider.clearImageLoader()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
@ -365,35 +351,19 @@ class NavigationActivity : AppCompatActivity() {
}
}
/**
* Apply the customized language settings if needed
*/
override fun attachBaseContext(newBase: Context?) {
val locale = Settings.overrideLanguage
val localeUpdatedContext: ContextWrapper = LocaleHelper.wrap(newBase, locale)
super.attachBaseContext(localeUpdatedContext)
}
private fun loadSettings() {
PreferenceManager.setDefaultValues(this, R.xml.settings, false)
}
private fun exit() {
Timber.d("User choose to exit the app")
// Broadcast that the service is being shutdown
RxBus.stopCommandPublisher.onNext(Unit)
lifecycleSupport.onDestroy()
finishAndRemoveTask()
finish()
}
private fun showWelcomeDialog() {
if (!infoDialogDisplayed) {
infoDialogDisplayed = true
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
InfoDialog.Builder(this)
.setTitle(R.string.main_welcome_title)
.setMessage(R.string.main_welcome_text_demo)
@ -436,10 +406,10 @@ class NavigationActivity : AppCompatActivity() {
}
if (nowPlayingView != null) {
val playerState: Int = mediaPlayerController.playbackState
if (playerState == STATE_BUFFERING || playerState == STATE_READY) {
val item: MediaItem? = mediaPlayerController.currentMediaItem
if (item != null) {
val playerState: PlayerState = mediaPlayerController.playerState
if (playerState == PlayerState.PAUSED || playerState == PlayerState.STARTED) {
val file: DownloadFile? = mediaPlayerController.currentPlaying
if (file != null) {
nowPlayingView?.visibility = View.VISIBLE
}
} else {

View File

@ -20,7 +20,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
@ -31,11 +31,11 @@ import timber.log.Timber
* Creates a Row in a RecyclerView which contains the details of an Album
*/
class AlbumRowBinder(
val onItemClick: (Album) -> Unit,
val onContextMenuClick: (MenuItem, Album) -> Boolean,
val onItemClick: (MusicDirectory.Album) -> Unit,
val onContextMenuClick: (MenuItem, MusicDirectory.Album) -> Boolean,
private val imageLoader: ImageLoader,
context: Context
) : ItemViewBinder<Album, AlbumRowBinder.ViewHolder>(), KoinComponent {
) : ItemViewBinder<MusicDirectory.Album, AlbumRowBinder.ViewHolder>(), KoinComponent {
private val starDrawable: Drawable =
Util.getDrawableFromAttribute(context, R.attr.star_full)
@ -46,7 +46,7 @@ class AlbumRowBinder(
val layout = R.layout.list_item_album
val contextMenuLayout = R.menu.context_menu_artist
override fun onBindViewHolder(holder: ViewHolder, item: Album) {
override fun onBindViewHolder(holder: ViewHolder, item: MusicDirectory.Album) {
holder.album.text = item.title
holder.artist.text = item.artist
holder.details.setOnClickListener { onItemClick(item) }
@ -86,7 +86,7 @@ class AlbumRowBinder(
/**
* Handles the star / unstar action for an album
*/
private fun onStarClick(entry: Album, star: ImageView) {
private fun onStarClick(entry: MusicDirectory.Album, star: ImageView) {
entry.starred = !entry.starred
star.setImageDrawable(if (entry.starred) starDrawable else starHollowDrawable)
val musicService = getMusicService()

View File

@ -1,6 +1,6 @@
/*
* ArtistRowBinder.kt
* Copyright (C) 2009-2022 Ultrasonic developers
* ArtistRowAdapter.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
@ -19,7 +19,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.imageloader.ImageLoader
@ -58,7 +57,7 @@ class ArtistRowBinder(
holder.coverArtId = item.coverArt
if (showArtistPicture()) {
if (Settings.shouldShowArtistPicture) {
holder.coverArt.visibility = View.VISIBLE
val key = FileUtil.getArtistArtKey(item.name, false)
imageLoader.loadImage(
@ -103,16 +102,11 @@ class ArtistRowBinder(
}
private fun getSectionFromName(name: String): String {
if (name.isEmpty()) return SECTION_KEY_DEFAULT
val section = name.first().uppercaseChar()
if (!section.isLetter()) return SECTION_KEY_DEFAULT
var section = name.first().uppercaseChar()
if (!section.isLetter()) section = '#'
return section.toString()
}
private fun showArtistPicture(): Boolean {
return ActiveServerProvider.isID3Enabled() && Settings.shouldShowArtistPicture
}
/**
* Creates an instance of our ViewHolder class
*/
@ -129,8 +123,4 @@ class ArtistRowBinder(
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false))
}
companion object {
const val SECTION_KEY_DEFAULT = "#"
}
}

View File

@ -59,7 +59,7 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
throw IllegalAccessException("You must use submitList() to add data to the Adapter")
}
private var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
var mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(diffCallback).build()
)
@ -182,11 +182,12 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
// Select them all
getCurrentList().mapNotNullTo(
selectedSet
) { entry ->
// Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L }
}
selectedSet,
{ entry ->
// Exclude any -1 ids, eg. headers and other UI elements
entry.longId.takeIf { it != -1L }
}
)
return selectedSet.count()
}

View File

@ -1,4 +1,4 @@
package org.moire.ultrasonic.adapters
package org.moire.ultrasonic.fragment
import android.content.Context
import android.graphics.drawable.Drawable
@ -30,7 +30,7 @@ import org.moire.ultrasonic.util.Util
*/
internal class ServerRowAdapter(
private var context: Context,
passedData: Array<ServerSetting>,
private var data: Array<ServerSetting>,
private val model: ServerSettingsModel,
private val activeServerProvider: ActiveServerProvider,
private val manageMode: Boolean,
@ -38,12 +38,6 @@ internal class ServerRowAdapter(
private val serverEditRequestedCallback: ((Int) -> Unit)
) : BaseAdapter() {
private var data: MutableList<ServerSetting> = mutableListOf()
init {
setData(passedData)
}
companion object {
private const val MENU_ID_EDIT = 1
private const val MENU_ID_DELETE = 2
@ -55,19 +49,12 @@ internal class ServerRowAdapter(
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
fun setData(data: Array<ServerSetting>) {
this.data.clear()
// In read mode show the offline server as well
if (!manageMode) {
this.data.add(ActiveServerProvider.OFFLINE_DB)
}
this.data.addAll(data)
this.data = data
notifyDataSetChanged()
}
override fun getCount(): Int {
return data.size
return if (manageMode) data.size else data.size + 1
}
override fun getItem(position: Int): Any {
@ -82,11 +69,11 @@ internal class ServerRowAdapter(
* Creates the Row representation of a Server Setting
*/
@Suppress("LongMethod")
override fun getView(pos: Int, convertView: View?, parent: ViewGroup?): View? {
var position = pos
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View? {
var index = position
// Skip "Offline" in manage mode
if (manageMode) position++
if (manageMode) index++
var vi: View? = convertView
if (vi == null) vi = inflater.inflate(R.layout.server_row, parent, false)
@ -96,17 +83,22 @@ internal class ServerRowAdapter(
val layout = vi?.findViewById<ConstraintLayout>(R.id.server_layout)
val image = vi?.findViewById<ImageView>(R.id.server_image)
val serverMenu = vi?.findViewById<ImageButton>(R.id.server_menu)
val setting = data.singleOrNull { t -> t.index == position }
val setting = data.singleOrNull { t -> t.index == index }
text?.text = setting?.name ?: ""
description?.text = setting?.url ?: ""
if (setting == null) serverMenu?.visibility = View.INVISIBLE
if (index == 0) {
text?.text = context.getString(R.string.main_offline)
description?.text = ""
} else {
text?.text = setting?.name ?: ""
description?.text = setting?.url ?: ""
if (setting == null) serverMenu?.visibility = View.INVISIBLE
}
val icon: Drawable?
val background: Drawable?
// Configure icons for the row
if (setting?.id == ActiveServerProvider.OFFLINE_DB_ID) {
if (index == 0) {
serverMenu?.visibility = View.INVISIBLE
icon = Util.getDrawableFromAttribute(context, R.attr.screen_on_off)
background = ContextCompat.getDrawable(context, R.drawable.circle)
@ -124,7 +116,7 @@ internal class ServerRowAdapter(
image?.background = background
// Highlight the Active Server's row by changing its background
if (position == activeServerProvider.getActiveServer().index) {
if (index == activeServerProvider.getActiveServer().index) {
layout?.background = ContextCompat.getDrawable(context, R.drawable.select_ripple)
} else {
layout?.background = ContextCompat.getDrawable(context, R.drawable.default_ripple)
@ -136,7 +128,7 @@ internal class ServerRowAdapter(
R.drawable.select_ripple_circle
)
serverMenu?.setOnClickListener { view -> serverMenuClick(view, position) }
serverMenu?.setOnClickListener { view -> serverMenuClick(view, index) }
return vi
}
@ -200,8 +192,7 @@ internal class ServerRowAdapter(
return true
}
MENU_ID_DELETE -> {
val server = getItem(position) as ServerSetting
serverDeletedCallback.invoke(server.id)
serverDeletedCallback.invoke(position)
return true
}
MENU_ID_UP -> {

View File

@ -12,12 +12,12 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader
class TrackViewBinder(
val onItemClick: (DownloadFile, Int) -> Unit,
val onItemClick: (DownloadFile) -> Unit,
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
val checkable: Boolean,
val draggable: Boolean,
@ -29,7 +29,7 @@ class TrackViewBinder(
// Set our layout files
val layout = R.layout.list_item_track
private val contextMenuLayout = R.menu.context_menu_track
val contextMenuLayout = R.menu.context_menu_track
private val downloader: Downloader by inject()
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
@ -41,14 +41,15 @@ class TrackViewBinder(
@SuppressLint("ClickableViewAccessibility")
@Suppress("LongMethod")
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
val downloadFile: DownloadFile?
val diffAdapter = adapter as BaseAdapter<*>
val downloadFile: DownloadFile = when (item) {
is Track -> {
downloader.getDownloadFileForSong(item)
when (item) {
is MusicDirectory.Entry -> {
downloadFile = downloader.getDownloadFileForSong(item)
}
is DownloadFile -> {
item
downloadFile = item
}
else -> {
return
@ -76,7 +77,7 @@ class TrackViewBinder(
}
} else {
// Minimize or maximize the Text view (if song title is very long)
if (!downloadFile.track.isDirectory) {
if (!downloadFile.song.isDirectory) {
holder.maximizeOrMinimize()
}
}
@ -85,11 +86,11 @@ class TrackViewBinder(
}
holder.itemView.setOnClickListener {
if (checkable && !downloadFile.track.isVideo) {
if (checkable && !downloadFile.song.isVideo) {
val nowChecked = !holder.check.isChecked
holder.isChecked = nowChecked
} else {
onItemClick(downloadFile, holder.bindingAdapterPosition)
onItemClick(downloadFile)
}
}
@ -102,37 +103,41 @@ class TrackViewBinder(
// Notify the adapter of selection changes
holder.observableChecked.observe(
lifecycleOwner
) { isCheckedNow ->
if (isCheckedNow) {
diffAdapter.notifySelected(holder.entry!!.longId)
} else {
diffAdapter.notifyUnselected(holder.entry!!.longId)
lifecycleOwner,
{ isCheckedNow ->
if (isCheckedNow) {
diffAdapter.notifySelected(holder.entry!!.longId)
} else {
diffAdapter.notifyUnselected(holder.entry!!.longId)
}
}
}
)
// Listen to changes in selection status and update ourselves
diffAdapter.selectionRevision.observe(
lifecycleOwner
) {
val newStatus = diffAdapter.isSelected(item.longId)
lifecycleOwner,
{
val newStatus = diffAdapter.isSelected(item.longId)
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
}
)
// Observe download status
downloadFile.status.observe(
lifecycleOwner
) {
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
lifecycleOwner,
{
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
)
downloadFile.progress.observe(
lifecycleOwner
) {
holder.updateProgress(it)
}
lifecycleOwner,
{
holder.updateProgress(it)
}
)
}
override fun onViewRecycled(holder: TrackViewHolder) {

View File

@ -15,7 +15,7 @@ import io.reactivex.rxjava3.disposables.Disposable
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.DownloadStatus
import org.moire.ultrasonic.service.MusicServiceFactory
@ -44,7 +44,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
var duration: TextView = view.findViewById(R.id.song_duration)
var progress: TextView = view.findViewById(R.id.song_status)
var entry: Track? = null
var entry: MusicDirectory.Entry? = null
private set
var downloadFile: DownloadFile? = null
private set
@ -67,7 +67,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
isSelected: Boolean = false
) {
val useFiveStarRating = Settings.useFiveStarRating
val song = file.track
val song = file.song
downloadFile = file
entry = song
@ -109,7 +109,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
}
rxSubscription = RxBus.playerStateObservable.subscribe {
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile)
setPlayIcon(it.track == downloadFile)
}
}
@ -131,7 +131,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
}
}
private fun setupStarButtons(song: Track, useFiveStarRating: Boolean) {
private fun setupStarButtons(song: MusicDirectory.Entry, useFiveStarRating: Boolean) {
if (useFiveStarRating) {
// Hide single star
star.isVisible = false
@ -153,8 +153,6 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
star.setImageDrawable(imageHelper.starHollowDrawable)
song.starred = false
}
// Should this be done here ?
Thread {
val musicService = MusicServiceFactory.getMusicService()
try {

View File

@ -2,12 +2,8 @@ package org.moire.ultrasonic.app
import android.content.Context
import androidx.multidex.MultiDexApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.core.logger.Level
import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.di.appPermanentStorage
@ -27,39 +23,22 @@ import timber.log.Timber.DebugTree
class UApp : MultiDexApplication() {
private var ioScope = CoroutineScope(Dispatchers.IO)
init {
instance = this
// if (BuildConfig.DEBUG)
// StrictMode.enableDefaults()
}
var initiated = false
override fun onCreate() {
initiated = true
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(DebugTree())
}
Timber.d("onCreate called")
// In general we should not access the settings from the main thread to avoid blocking...
ioScope.launch {
if (Settings.debugLogToFile) {
FileLoggerTree.plantToTimberForest()
}
if (Settings.debugLogToFile) {
FileLoggerTree.plantToTimberForest()
}
startKoin()
}
internal fun startKoin() {
startKoin {
// TODO Currently there is a bug in Koin which makes necessary to set the log level to ERROR
// TODO Currently there is a bug in Koin which makes necessary to set the loglevel to ERROR
logger(TimberKoinLogger(Level.ERROR))
// logger(TimberKoinLogger(Level.INFO))
@ -76,13 +55,8 @@ class UApp : MultiDexApplication() {
}
}
internal fun shutdownKoin() {
stopKoin()
initiated = false
}
companion object {
var instance: UApp? = null
private var instance: UApp? = null
fun applicationContext(): Context {
return instance!!.applicationContext

View File

@ -1,12 +1,6 @@
/*
* ActiveServerProvider.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data
import androidx.lifecycle.MutableLiveData
import androidx.room.Room
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -17,7 +11,6 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.di.DB_FILENAME
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
@ -26,6 +19,8 @@ import timber.log.Timber
/**
* This class can be used to retrieve the properties of the Active Server
* It caches the settings read up from the DB to improve performance.
*
* TODO: There seems to be some confusion whether offline id is 0 or -1. Clean this up (carefully!)
*/
class ActiveServerProvider(
private val repository: ServerSettingDao
@ -40,7 +35,7 @@ class ActiveServerProvider(
*/
@JvmOverloads
fun getActiveServer(serverId: Int = getActiveServerId()): ServerSetting {
if (serverId > OFFLINE_DB_ID) {
if (serverId > 0) {
if (cachedServer != null && cachedServer!!.id == serverId) return cachedServer!!
// Ideally this is the only call where we block the thread while using the repository
@ -58,31 +53,22 @@ class ActiveServerProvider(
return cachedServer!!
}
// Fallback to Offline
setActiveServerById(OFFLINE_DB_ID)
setActiveServerId(0)
}
return OFFLINE_DB
}
/**
* Resolves the index (sort order) of a server to its id (unique)
* @param index: The index of the server in the server selector
* @return id: The unique id of the server
*/
fun getServerIdFromIndex(index: Int): Int {
if (index <= OFFLINE_DB_INDEX) {
// Offline mode is selected
return OFFLINE_DB_ID
}
var id: Int
runBlocking {
id = repository.findByIndex(index)?.id ?: 0
}
return id
return ServerSetting(
id = -1,
index = 0,
name = UApp.applicationContext().getString(R.string.main_offline),
url = "http://localhost",
userName = "",
password = "",
jukeboxByDefault = false,
allowSelfSignedCertificate = false,
ldapSupport = false,
musicFolderId = "",
minimumApiVersion = null
)
}
/**
@ -91,15 +77,15 @@ class ActiveServerProvider(
*/
fun setActiveServerByIndex(index: Int) {
Timber.d("setActiveServerByIndex $index")
if (index <= OFFLINE_DB_INDEX) {
if (index < 1) {
// Offline mode is selected
setActiveServerById(OFFLINE_DB_ID)
setActiveServerId(0)
return
}
launch {
val serverId = repository.findByIndex(index)?.id ?: 0
setActiveServerById(serverId)
setActiveServerId(serverId)
}
}
@ -117,22 +103,21 @@ class ActiveServerProvider(
Timber.i("Switching to new database, id:$activeServer")
cachedServerId = activeServer
cachedDatabase = initDatabase(activeServer)
return cachedDatabase!!
}
val offlineMetaDatabase: MetaDatabase by lazy {
initDatabase(0)
}
private fun initDatabase(serverId: Int): MetaDatabase {
return Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + serverId
METADATA_DB + cachedServerId
)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
val offlineMetaDatabase: MetaDatabase by lazy {
Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + 0
)
.addMigrations(META_MIGRATION_2_3)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
}
@ -192,30 +177,16 @@ class ActiveServerProvider(
}
companion object {
const val METADATA_DB = "$DB_FILENAME-meta-"
const val OFFLINE_DB_ID = -1
const val OFFLINE_DB_INDEX = 0
val OFFLINE_DB = ServerSetting(
id = OFFLINE_DB_ID,
index = OFFLINE_DB_INDEX,
name = UApp.applicationContext().getString(R.string.main_offline),
url = "http://localhost",
userName = "",
password = "",
jukeboxByDefault = false,
allowSelfSignedCertificate = false,
ldapSupport = false,
musicFolderId = "",
minimumApiVersion = null
)
const val METADATA_DB = "$DB_FILENAME-meta-"
val liveActiveServerId: MutableLiveData<Int> = MutableLiveData(getActiveServerId())
/**
* Queries if the Active Server is the "Offline" mode of Ultrasonic
* @return True, if the "Offline" mode is selected
*/
fun isOffline(): Boolean {
return getActiveServerId() == OFFLINE_DB_ID
return getActiveServerId() < 1
}
/**
@ -226,16 +197,13 @@ class ActiveServerProvider(
}
/**
* Sets the Active Server by its unique id
* @param serverId: The id of the desired server
* Sets the Id of the Active Server
*/
fun setActiveServerById(serverId: Int) {
fun setActiveServerId(serverId: Int) {
resetMusicService()
Settings.activeServer = serverId
Timber.i("setActiveServerById done, new id: %s", serverId)
RxBus.activeServerChangePublisher.onNext(serverId)
liveActiveServerId.postValue(serverId)
}
/**
@ -249,13 +217,6 @@ class ActiveServerProvider(
return preferences.getBoolean(Constants.PREFERENCES_KEY_SCROBBLE, false)
}
/**
* Queries if ID3 tags should be used
*/
fun isID3Enabled(): Boolean {
return Settings.shouldUseId3Tags && (!isOffline() || Settings.useId3TagsOffline)
}
/**
* Queries if Server Scaling is enabled
*/

View File

@ -1,80 +0,0 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import org.moire.ultrasonic.domain.Album
@Dao
interface AlbumDao : GenericDao<Album> {
/**
* Clear the whole database
*/
@Query("DELETE FROM albums")
fun clear()
/**
* Get all albums
*/
@Query("SELECT * FROM albums")
fun get(): List<Album>
/**
* Get all albums in a specific range
*/
@Query("SELECT * FROM albums LIMIT :offset,:size")
fun get(size: Int, offset: Int = 0): List<Album>
/**
* Get album by id
*/
@Query("SELECT * FROM albums where id LIKE :albumId LIMIT 1")
fun get(albumId: String): Album
/**
* Get albums by artist
*/
@Query("SELECT * FROM albums WHERE artistId LIKE :id")
fun byArtist(id: String): List<Album>
/**
* Clear albums by artist
*/
@Query("DELETE FROM albums WHERE artistId LIKE :id")
fun clearByArtist(id: String)
/**
* TODO: Make generic
* Upserts (insert or update) an object to the database
*
* @param obj the object to upsert
*/
@Transaction
@JvmSuppressWildcards
fun upsert(obj: Album) {
val id = insertIgnoring(obj)
if (id == -1L) {
update(obj)
}
}
/**
* Upserts (insert or update) a list of objects
*
* @param objList the object to be upserted
*/
@Transaction
@JvmSuppressWildcards
fun upsert(objList: List<Album>) {
val insertResult = insertIgnoring(objList)
val updateList: MutableList<Album> = ArrayList()
for (i in insertResult.indices) {
if (insertResult[i] == -1L) {
updateList.add(objList[i])
}
}
if (updateList.isNotEmpty()) {
update(updateList)
}
}
}

View File

@ -9,11 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* Room Database to be used to store global data for the whole app.
* This could be settings or data that are not specific to any remote music database
*/
@Database(
entities = [ServerSetting::class],
version = 5,
exportSchema = true
)
@Database(entities = [ServerSetting::class], version = 4)
abstract class AppDatabase : RoomDatabase() {
/**
@ -179,89 +175,3 @@ val MIGRATION_4_3: Migration = object : Migration(4, 3) {
)
}
}
val MIGRATION_4_5: Migration = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `_new_ServerSetting` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`index` INTEGER NOT NULL,
`name` TEXT NOT NULL,
`url` TEXT NOT NULL,
`color` INTEGER,
`userName` TEXT NOT NULL,
`password` TEXT NOT NULL,
`jukeboxByDefault` INTEGER NOT NULL,
`allowSelfSignedCertificate` INTEGER NOT NULL,
`ldapSupport` INTEGER NOT NULL,
`musicFolderId` TEXT,
`minimumApiVersion` TEXT,
`chatSupport` INTEGER,
`bookmarkSupport` INTEGER,
`shareSupport` INTEGER,
`podcastSupport` INTEGER
)
""".trimIndent()
)
database.execSQL(
"""
INSERT INTO `_new_ServerSetting` (
`ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,
`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,
`podcastSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport`
)
SELECT `ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,
`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,
`bookmarkSupport`,`name`,`podcastSupport`,`id`,`allowSelfSignedCertificate`,
`chatSupport`
FROM `ServerSetting`
""".trimIndent()
)
database.execSQL("DROP TABLE `ServerSetting`")
database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
}
}
val MIGRATION_5_4: Migration = object : Migration(5, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `_new_ServerSetting` (
`id` INTEGER PRIMARY KEY NOT NULL,
`index` INTEGER NOT NULL,
`name` TEXT NOT NULL,
`url` TEXT NOT NULL,
`color` INTEGER,
`userName` TEXT NOT NULL,
`password` TEXT NOT NULL,
`jukeboxByDefault` INTEGER NOT NULL,
`allowSelfSignedCertificate` INTEGER NOT NULL,
`ldapSupport` INTEGER NOT NULL,
`musicFolderId` TEXT,
`minimumApiVersion` TEXT,
`chatSupport` INTEGER,
`bookmarkSupport` INTEGER,
`shareSupport` INTEGER,
`podcastSupport` INTEGER
)
""".trimIndent()
)
database.execSQL(
"""
INSERT INTO `_new_ServerSetting` (
`ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,
`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,
`podcastSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport`
)
SELECT `ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,
`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,
`bookmarkSupport`,`name`,`podcastSupport`,`id`,`allowSelfSignedCertificate`,
`chatSupport`
FROM `ServerSetting`
""".trimIndent()
)
database.execSQL("DROP TABLE `ServerSetting`")
database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
}
}

View File

@ -7,7 +7,7 @@ import androidx.room.Query
import org.moire.ultrasonic.domain.Artist
@Dao
interface ArtistDao {
interface ArtistsDao {
/**
* Insert a list in the database. If the item already exists, replace it.
*
@ -43,5 +43,5 @@ interface ArtistDao {
* Get artist by id
*/
@Query("SELECT * FROM artists WHERE id LIKE :id")
fun get(id: String): Artist?
fun get(id: String): Artist
}

View File

@ -53,7 +53,6 @@ interface IndexDao : GenericDao<Index> {
fun get(musicFolderId: String): List<Index>
/**
* TODO: Make generic
* Upserts (insert or update) an object to the database
*
* @param obj the object to upsert

View File

@ -1,85 +1,23 @@
/*
* MetaDatabase.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import java.util.Date
import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
import org.moire.ultrasonic.domain.Track
/**
* This database is used to store and cache the ID3 metadata
*/
@Database(
entities = [
Artist::class,
Album::class,
Track::class,
Index::class,
MusicFolder::class
],
autoMigrations = [
AutoMigration(
from = 1,
to = 2
),
],
exportSchema = true,
version = 3
entities = [Artist::class, Index::class, MusicFolder::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class MetaDatabase : RoomDatabase() {
abstract fun artistDao(): ArtistDao
abstract fun albumDao(): AlbumDao
abstract fun trackDao(): TrackDao
abstract fun artistsDao(): ArtistsDao
abstract fun musicFoldersDao(): MusicFoldersDao
abstract fun indexDao(): IndexDao
}
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}
/* ktlint-disable max-line-length */
val META_MIGRATION_2_3: Migration = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP TABLE `albums`")
database.execSQL("DROP TABLE `indexes`")
database.execSQL("DROP TABLE `artists`")
database.execSQL("DROP TABLE `tracks`")
database.execSQL("DROP TABLE `music_folders`")
database.execSQL("CREATE TABLE IF NOT EXISTS `albums` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `album` TEXT, `title` TEXT, `name` TEXT, `discNumber` INTEGER, `coverArt` TEXT, `songCount` INTEGER, `created` INTEGER, `artist` TEXT, `artistId` TEXT, `duration` INTEGER, `year` INTEGER, `genre` TEXT, `starred` INTEGER NOT NULL, `path` TEXT, `closeness` INTEGER NOT NULL, `isDirectory` INTEGER NOT NULL, `isVideo` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `indexes` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, `musicFolderId` TEXT, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `artists` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `name` TEXT, `index` TEXT, `coverArt` TEXT, `albumCount` INTEGER, `closeness` INTEGER NOT NULL, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `music_folders` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`id`, `serverId`))")
database.execSQL("CREATE TABLE IF NOT EXISTS `tracks` (`id` TEXT NOT NULL, `serverId` INTEGER NOT NULL DEFAULT -1, `parent` TEXT, `isDirectory` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `albumId` TEXT, `artist` TEXT, `artistId` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `contentType` TEXT, `suffix` TEXT, `transcodedContentType` TEXT, `transcodedSuffix` TEXT, `coverArt` TEXT, `size` INTEGER, `songCount` INTEGER, `duration` INTEGER, `bitRate` INTEGER, `path` TEXT, `isVideo` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `discNumber` INTEGER, `type` TEXT, `created` INTEGER, `closeness` INTEGER NOT NULL, `bookmarkPosition` INTEGER NOT NULL, `userRating` INTEGER, `averageRating` REAL, `name` TEXT, PRIMARY KEY(`id`, `serverId`))")
}
}
/* ktlint-enable max-line-length */

View File

@ -19,8 +19,7 @@ import androidx.room.PrimaryKey
*/
@Entity
data class ServerSetting(
// Default ID is 0, which will trigger SQLite to generate a unique ID.
@PrimaryKey(autoGenerate = true) var id: Int = 0,
@PrimaryKey var id: Int,
@ColumnInfo(name = "index") var index: Int,
@ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "url") var url: String,
@ -38,6 +37,9 @@ data class ServerSetting(
@ColumnInfo(name = "podcastSupport") var podcastSupport: Boolean? = null
) {
constructor() : this (
0, 0, "", "", null, "", "", false, false, false, null, null
-1, 0, "", "", null, "", "", false, false, false, null, null
)
constructor(name: String, url: String) : this(
-1, 0, name, url, null, "", "", false, false, false, null, null
)
}

View File

@ -69,6 +69,12 @@ interface ServerSettingDao {
@Query("SELECT COUNT(*) FROM serverSetting")
fun liveServerCount(): LiveData<Int?>
/**
* Retrieves the greatest value of the Id column in the table
*/
@Query("SELECT MAX([id]) FROM serverSetting")
suspend fun getMaxId(): Int?
/**
* Retrieves the greatest value of the Index column in the table
*/

View File

@ -1,34 +0,0 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Query
import org.moire.ultrasonic.domain.Track
@Dao
@Entity(tableName = "tracks")
interface TrackDao : GenericDao<Track> {
/**
* Clear the whole database
*/
@Query("DELETE FROM tracks")
fun clear()
/**
* Get all albums
*/
@Query("SELECT * FROM tracks")
fun get(): List<Track>
/**
* Get albums by artist
*/
@Query("SELECT * FROM tracks WHERE albumId LIKE :id")
fun byAlbum(id: String): List<Track>
/**
* Get albums by artist
*/
@Query("SELECT * FROM tracks WHERE artistId LIKE :id")
fun byArtist(id: String): List<Track>
}

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