Compare commits

..

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

270 changed files with 5067 additions and 5454 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@ -1,5 +1,5 @@
default: default:
image: registry.gitlab.com/ultrasonic/ci-android:1.2.0 image: registry.gitlab.com/ultrasonic/ci-android:1.1.0
cache: &global_cache cache: &global_cache
key: key:
files: files:
@ -138,7 +138,7 @@ RoboTest:
script: script:
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash - curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
- gcloud auth activate-service-account --key-file .secure_files/firebase-key.json - gcloud auth activate-service-account --key-file .secure_files/firebase-key.json
- gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape - gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape --device model=Pixel5,version=30,locale=zh,orientation=portrait
rules: rules:
# Run when releasing a new tag # Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID - if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID

View File

@ -1,6 +1,11 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JetCodeStyleSettings> <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" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">

2
.idea/compiler.xml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,5 @@
plugins {
alias libs.plugins.ksp
}
apply from: bootstrap.kotlinModule apply from: bootstrap.kotlinModule
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
dependencies { dependencies {
api libs.retrofit api libs.retrofit
api libs.jacksonConverter api libs.jacksonConverter

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,46 +1,44 @@
[versions] [versions]
# You need to run ./gradlew wrapper after updating the version # You need to run ./gradlew wrapper after updating the version
gradle = "8.13" gradle = "8.1.1"
navigation = "2.8.9" navigation = "2.6.0"
gradlePlugin = "8.9.1" gradlePlugin = "8.0.2"
androidxcar = "1.4.0" androidxcore = "1.10.1"
androidxcore = "1.16.0" ktlint = "0.43.2"
ktlint = "1.0.1" ktlintGradle = "11.4.2"
ktlintGradle = "12.2.0" detekt = "1.23.0"
detekt = "1.23.8" preferences = "1.2.0"
preferences = "1.2.1" media3 = "1.0.2"
media3 = "1.6.1"
androidSupport = "1.9.1" androidSupport = "1.6.0"
materialDesign = "1.12.0" materialDesign = "1.9.0"
constraintLayout = "2.2.1" constraintLayout = "2.1.4"
activity = "1.10.1"
multidex = "2.0.1" multidex = "2.0.1"
room = "2.7.0" room = "2.5.2"
kotlin = "2.1.20" kotlin = "1.8.22"
ksp = "2.1.20-2.0.0" kotlinxCoroutines = "1.7.1"
kotlinxCoroutines = "1.10.2" viewModelKtx = "2.6.1"
viewModelKtx = "2.8.7"
swipeRefresh = "1.1.0" swipeRefresh = "1.1.0"
retrofit = "2.11.0" retrofit = "2.9.0"
jackson = "2.18.3" ## KEEP ON 2.13 branch (https://github.com/FasterXML/jackson-databind/issues/3658#issuecomment-1312633064) for compatibility with API 24
okhttp = "4.12.0" jackson = "2.13.5"
koin = "4.0.4" okhttp = "4.11.0"
koin = "3.3.2"
picasso = "2.8" picasso = "2.8"
junit4 = "4.13.2" junit4 = "4.13.2"
junit5 = "5.12.2" junit5 = "5.9.3"
mockito = "5.17.0" mockito = "5.4.0"
mockitoKotlin = "5.4.0" mockitoKotlin = "5.0.0"
kluent = "1.73" kluent = "1.73"
apacheCodecs = "1.18.0" apacheCodecs = "1.16.0"
robolectric = "4.14.1" robolectric = "4.10.3"
timber = "5.0.1" timber = "5.0.1"
fastScroll = "2.0.1" fastScroll = "2.0.1"
colorPicker = "2.3.0" colorPicker = "2.2.4"
rxJava = "3.1.10" rxJava = "3.1.6"
rxAndroid = "3.0.2" rxAndroid = "3.0.2"
multiType = "4.3.0" multiType = "4.3.0"
@ -50,7 +48,6 @@ kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin"
ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" } ktlintGradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlintGradle" }
detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
car = { module = "androidx.car.app:app", version.ref = "androidxcar" }
core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" } core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" }
design = { module = "com.google.android.material:material", version.ref = "materialDesign" } design = { module = "com.google.android.material:material", version.ref = "materialDesign" }
annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" } annotations = { module = "androidx.annotation:annotation", version.ref = "androidSupport" }
@ -66,7 +63,6 @@ navigationFragmentKtx = { module = "androidx.navigation:navigation-fragment-kt
navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } navigationUiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" } navigationFeature = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigation" }
navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"} navigationSafeArgs = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation"}
activity = { module = "androidx.activity:activity-ktx", version.ref = "activity" }
preferences = { module = "androidx.preference:preference", version.ref = "preferences" } preferences = { module = "androidx.preference:preference", version.ref = "preferences" }
media3common = { module = "androidx.media3:media3-common", version.ref = "media3" } media3common = { module = "androidx.media3:media3-common", version.ref = "media3" }
media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
@ -104,6 +100,3 @@ kluentAndroid = { module = "org.amshove.kluent:kluent-android", versio
mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } mockWebServer = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" } apacheCodecs = { module = "commons-codec:commons-codec", version.ref = "apacheCodecs" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

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

Binary file not shown.

View File

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

View File

@ -4,7 +4,7 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.jetbrains.kotlin.android'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle" apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
apply plugin: 'com.google.devtools.ksp' apply plugin: 'org.jetbrains.kotlin.kapt'
android { android {
compileSdkVersion versions.compileSdk compileSdkVersion versions.compileSdk

View File

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

26
gradlew vendored
View File

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

22
gradlew.bat vendored
View File

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

View File

@ -1,9 +1,6 @@
plugins {
alias libs.plugins.ksp
}
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "androidx.navigation.safeargs.kotlin"
apply from: "../gradle_scripts/code_quality.gradle" apply from: "../gradle_scripts/code_quality.gradle"
@ -12,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 130 versionCode 124
versionName "4.8.0" versionName "4.6.1"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk
@ -34,10 +31,10 @@ android {
'minify/proguard-kotlin.pro' 'minify/proguard-kotlin.pro'
} }
debug { debug {
minifyEnabled = false minifyEnabled false
multiDexEnabled = true multiDexEnabled true
testCoverageEnabled = true testCoverageEnabled true
applicationIdSuffix = '.debug' applicationIdSuffix '.debug'
} }
} }
@ -53,43 +50,42 @@ android {
} }
kotlinOptions { kotlinOptions {
jvmTarget = "21" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
viewBinding = true viewBinding true
dataBinding = true dataBinding true
buildConfig = true buildConfig true
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_21 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_17
} }
ksp { kapt {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas") arguments {
arg("room.schemaLocation", "$rootDir/ultrasonic/schemas".toString())
}
} }
lint { lint {
baseline = file("lint-baseline.xml") baseline = file("lint-baseline.xml")
abortOnError = true abortOnError true
warningsAsErrors = true warningsAsErrors true
warning 'ImpliedQuantity'
disable 'IconMissingDensityFolder', 'VectorPath' disable 'IconMissingDensityFolder', 'VectorPath'
disable 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity' ignore 'MissingTranslation', 'UnusedQuantity', 'MissingQuantity'
warning 'ImpliedQuantity'
disable 'ObsoleteLintCustomCheck' disable 'ObsoleteLintCustomCheck'
// We manage dependencies on Gitlab with RenovateBot textReport true
disable 'GradleDependency' checkDependencies true
disable 'AndroidGradlePluginVersion'
textReport = true
checkDependencies = true
} }
namespace = 'org.moire.ultrasonic' namespace 'org.moire.ultrasonic'
} }
tasks.withType(Test).configureEach { tasks.withType(Test) {
useJUnitPlatform() useJUnitPlatform()
} }
@ -101,7 +97,6 @@ dependencies {
exclude group: "com.android.support" exclude group: "com.android.support"
} }
implementation libs.car
implementation libs.core implementation libs.core
implementation libs.design implementation libs.design
implementation libs.multidex implementation libs.multidex
@ -134,7 +129,7 @@ dependencies {
implementation libs.rxAndroid implementation libs.rxAndroid
implementation libs.multiType implementation libs.multiType
ksp libs.room kapt libs.room
testImplementation libs.kotlinReflect testImplementation libs.kotlinReflect
testImplementation libs.junit testImplementation libs.junit
@ -146,5 +141,6 @@ dependencies {
testImplementation libs.robolectric testImplementation libs.robolectric
implementation libs.timber implementation libs.timber
} }

View File

@ -1,7 +1,9 @@
<?xml version="1.0" ?> <?xml version="1.0" ?>
<SmellBaseline> <SmellBaseline>
<ManuallySuppressedIssues></ManuallySuppressedIssues> <ManuallySuppressedIssues>
<CurrentIssues> <ID>TooManyFunctions:PlaybackService.kt$PlaybackService : MediaLibraryServiceKoinComponentCoroutineScope</ID>
<ID>UnusedPrivateMember:UApp.kt$private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder</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("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("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:FileLoggerTree.kt$FileLoggerTree$String.format("Logging into file %s", file?.name)</ID>
@ -9,11 +11,19 @@
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID> <ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID> <ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID> <ID>LongMethod:PlaylistsFragment.kt$PlaylistsFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken, additionalId: String? )</ID>
<ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID> <ID>LongMethod:SharesFragment.kt$SharesFragment$override fun onContextItemSelected(menuItem: MenuItem): Boolean</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, passedData: 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:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$0.05f</ID>
<ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID> <ID>MagicNumber:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$50</ID>
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID> <ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
<ID>NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean )</ID>
<ID>TooGenericExceptionCaught:FileLoggerTree.kt$FileLoggerTree$x: Throwable</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer$x: Throwable</ID>
<ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID> <ID>TooGenericExceptionCaught:JukeboxMediaPlayer.kt$JukeboxMediaPlayer.TaskQueue$x: Throwable</ID>
<ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID> <ID>TooManyFunctions:RESTMusicService.kt$RESTMusicService : MusicService</ID>
</CurrentIssues> <ID>UtilityClassWithPublicConstructor:FragmentTitle.kt$FragmentTitle</ID>
</ManuallySuppressedIssues>
<CurrentIssues/>
</SmellBaseline> </SmellBaseline>

View File

@ -70,6 +70,50 @@
column="1"/> column="1"/>
</issue> </issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/chat.xml"
line="33"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/save_playlist.xml"
line="9"
column="6"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="29"
column="10"/>
</issue>
<issue
id="Autofill"
message="Missing `autofillHints` attribute"
errorLine1=" &lt;EditText"
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/time_span_dialog.xml"
line="28"
column="10"/>
</issue>
<issue <issue
id="LabelFor" id="LabelFor"
message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`" message="Missing accessibility label: provide either a view with an `android:labelFor` that references this view or provide an `android:hint`"

View File

@ -12,8 +12,6 @@
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-sdk tools:overrideLibrary="androidx.car.app" />
<supports-screens <supports-screens
android:anyDensity="true" android:anyDensity="true"
android:largeScreens="true" android:largeScreens="true"
@ -22,22 +20,20 @@
android:xlargeScreens="true"/> android:xlargeScreens="true"/>
<application <application
android:name=".app.UApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_descriptor" android:fullBackupContent="@xml/backup_descriptor"
android:hasFragileUserData="true" android:hasFragileUserData="true" tools:targetApi="q"
android:dataExtractionRules="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/common.appname"
android:networkSecurityConfig="@xml/network_security_config"
android:preserveLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/Theme.Material3.DynamicColors.Dark" android:theme="@style/Theme.Material3.DynamicColors.Dark"
android:name=".app.UApp"
android:label="@string/common.appname"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute" android:supportsRtl="false"
tools:targetApi="q"> android:preserveLegacyExternalStorage="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute">
<!-- Add for API 34 android:enableOnBackInvokedCallBack="true" --> <!-- Add for API 34 android:enableOnBackInvokedCallBack="true" -->
<meta-data android:name="com.google.android.gms.car.application" <meta-data android:name="com.google.android.gms.car.application"
@ -74,7 +70,7 @@
</service> </service>
<!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md --> <!-- Needs to be exported: https://android.googlesource.com/platform/developers/build/+/4de32d4/prebuilts/gradle/MediaBrowserService/README.md -->
<service android:name=".service.PlaybackService" <service android:name=".playback.PlaybackService"
android:label="@string/common.appname" android:label="@string/common.appname"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:exported="true" android:exported="true"
@ -109,12 +105,6 @@
<action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/> <action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<receiver <receiver
android:name=".provider.UltrasonicAppWidgetProvider" android:name=".provider.UltrasonicAppWidgetProvider"
android:label="Ultrasonic" android:label="Ultrasonic"
@ -127,6 +117,12 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info"/> android:resource="@xml/appwidget_info"/>
</receiver> </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 <provider
android:name=".provider.SearchSuggestionProvider" android:name=".provider.SearchSuggestionProvider"
android:authorities="${applicationId}.provider.SearchSuggestionProvider" android:authorities="${applicationId}.provider.SearchSuggestionProvider"
@ -139,4 +135,5 @@
android:exported="true" android:exported="true"
tools:ignore="ExportedContentProvider" /> tools:ignore="ExportedContentProvider" />
</application> </application>
</manifest> </manifest>

View File

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

View File

@ -1,6 +1,6 @@
/* /*
* BluetoothIntentReceiver.kt * BluetoothIntentReceiver.kt
* Copyright (C) 2009-2023 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -10,6 +10,9 @@ package org.moire.ultrasonic.receiver
import android.Manifest import android.Manifest
import android.bluetooth.BluetoothA2dp import android.bluetooth.BluetoothA2dp
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.ACTION_ACL_CONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECTED
import android.bluetooth.BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@ -19,6 +22,9 @@ import android.os.Build
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_A2DP
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_ALL
import org.moire.ultrasonic.util.Constants.PREFERENCE_VALUE_DISABLED
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import timber.log.Timber import timber.log.Timber
@ -36,28 +42,27 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
Timber.d("Bluetooth device: $name; State: $state; Action: $action") Timber.d("Bluetooth device: $name; State: $state; Action: $action")
// In these flags we store what kind of device (any or a2dp) has (dis)connected // In these flags we store what kind of device (any or a2dp) has (dis)connected
var connectionStatus = Constants.PREFERENCE_VALUE_DISABLED var connectionStatus = PREFERENCE_VALUE_DISABLED
var disconnectionStatus = Constants.PREFERENCE_VALUE_DISABLED var disconnectionStatus = PREFERENCE_VALUE_DISABLED
// First check for general devices // First check for general devices
when (action) { when (action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> { ACTION_ACL_CONNECTED -> {
connectionStatus = Constants.PREFERENCE_VALUE_ALL connectionStatus = PREFERENCE_VALUE_ALL
} }
BluetoothDevice.ACTION_ACL_DISCONNECTED, ACTION_ACL_DISCONNECTED,
BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED ACTION_ACL_DISCONNECT_REQUESTED -> {
-> { disconnectionStatus = PREFERENCE_VALUE_ALL
disconnectionStatus = Constants.PREFERENCE_VALUE_ALL
} }
} }
// Then check for A2DP devices // Then check for A2DP devices
when (state) { when (state) {
BluetoothA2dp.STATE_CONNECTED -> { BluetoothA2dp.STATE_CONNECTED -> {
connectionStatus = Constants.PREFERENCE_VALUE_A2DP connectionStatus = PREFERENCE_VALUE_A2DP
} }
BluetoothA2dp.STATE_DISCONNECTED -> { BluetoothA2dp.STATE_DISCONNECTED -> {
disconnectionStatus = Constants.PREFERENCE_VALUE_A2DP disconnectionStatus = PREFERENCE_VALUE_A2DP
} }
} }
@ -67,20 +72,20 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
// Now check the settings and set the appropriate flags // Now check the settings and set the appropriate flags
when (Settings.resumeOnBluetoothDevice) { when (Settings.resumeOnBluetoothDevice) {
Constants.PREFERENCE_VALUE_ALL -> { PREFERENCE_VALUE_ALL -> {
shouldResume = (connectionStatus != Constants.PREFERENCE_VALUE_DISABLED) shouldResume = (connectionStatus != PREFERENCE_VALUE_DISABLED)
} }
Constants.PREFERENCE_VALUE_A2DP -> { PREFERENCE_VALUE_A2DP -> {
shouldResume = (connectionStatus == Constants.PREFERENCE_VALUE_A2DP) shouldResume = (connectionStatus == PREFERENCE_VALUE_A2DP)
} }
} }
when (Settings.pauseOnBluetoothDevice) { when (Settings.pauseOnBluetoothDevice) {
Constants.PREFERENCE_VALUE_ALL -> { PREFERENCE_VALUE_ALL -> {
shouldPause = (disconnectionStatus != Constants.PREFERENCE_VALUE_DISABLED) shouldPause = (disconnectionStatus != PREFERENCE_VALUE_DISABLED)
} }
Constants.PREFERENCE_VALUE_A2DP -> { PREFERENCE_VALUE_A2DP -> {
shouldPause = (disconnectionStatus == Constants.PREFERENCE_VALUE_A2DP) shouldPause = (disconnectionStatus == PREFERENCE_VALUE_A2DP)
} }
} }
@ -100,24 +105,24 @@ class BluetoothIntentReceiver : BroadcastReceiver() {
) )
} }
} }
}
private fun BluetoothDevice?.getNameSafely(): String? { private fun BluetoothDevice?.getNameSafely(): String? {
val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && val logBluetoothName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
( (
ActivityCompat.checkSelfPermission( ActivityCompat.checkSelfPermission(
UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT UApp.applicationContext(), Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) )
return if (logBluetoothName) this?.name else "Unknown" return if (logBluetoothName) this?.name else "Unknown"
} }
private fun Intent.getBluetoothDevice(): BluetoothDevice? { private fun Intent.getBluetoothDevice(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
/* /*
* NavigationActivity.kt * NavigationActivity.kt
* Copyright (C) 2009-2023 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -13,6 +13,7 @@ import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources import android.content.res.Resources
import android.media.AudioManager import android.media.AudioManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.SearchRecentSuggestions import android.provider.SearchRecentSuggestions
@ -22,6 +23,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -37,7 +39,6 @@ import androidx.media3.common.Player.STATE_READY
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.onNavDestinationSelected import androidx.navigation.ui.onNavDestinationSelected
@ -49,7 +50,6 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeActivity
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
@ -63,6 +63,7 @@ import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.InfoDialog import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.LocaleHelper import org.moire.ultrasonic.util.LocaleHelper
@ -80,7 +81,7 @@ import timber.log.Timber
* onCreate/onResume/onDestroy methods... * onCreate/onResume/onDestroy methods...
*/ */
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
class NavigationActivity : ScopeActivity() { class NavigationActivity : AppCompatActivity() {
private var videoMenuItem: MenuItem? = null private var videoMenuItem: MenuItem? = null
private var chatMenuItem: MenuItem? = null private var chatMenuItem: MenuItem? = null
private var bookmarksMenuItem: MenuItem? = null private var bookmarksMenuItem: MenuItem? = null
@ -95,7 +96,6 @@ class NavigationActivity : ScopeActivity() {
private var drawerLayout: DrawerLayout? = null private var drawerLayout: DrawerLayout? = null
private var host: NavHostFragment? = null private var host: NavHostFragment? = null
private var selectServerButton: MaterialButton? = null private var selectServerButton: MaterialButton? = null
private var selectServerDropdownImage: ImageView? = null
private var headerBackgroundImage: ImageView? = null private var headerBackgroundImage: ImageView? = null
// We store the last search string in this variable. // We store the last search string in this variable.
@ -165,7 +165,7 @@ class NavigationActivity : ScopeActivity() {
drawerLayout drawerLayout
) )
setupActionBarWithNavController(navController, appBarConfiguration) setupActionBar(navController, appBarConfiguration)
setupNavigationMenu(navController) setupNavigationMenu(navController)
@ -204,11 +204,10 @@ class NavigationActivity : ScopeActivity() {
} }
rxBusSubscription += RxBus.playerStateObservable.subscribe { rxBusSubscription += RxBus.playerStateObservable.subscribe {
if (it.state == STATE_READY) { if (it.state == STATE_READY)
showNowPlaying() showNowPlaying()
} else { else
hideNowPlaying() hideNowPlaying()
}
} }
rxBusSubscription += RxBus.themeChangedEventObservable.subscribe { rxBusSubscription += RxBus.themeChangedEventObservable.subscribe {
@ -227,7 +226,7 @@ class NavigationActivity : ScopeActivity() {
// Setup app shortcuts on supported devices, but not on first start, when the server // Setup app shortcuts on supported devices, but not on first start, when the server
// is not configured yet. // is not configured yet.
if (!UApp.instance!!.isFirstRun) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && !UApp.instance!!.isFirstRun) {
ShortcutUtil.registerShortcuts(this) ShortcutUtil.registerShortcuts(this)
} }
@ -307,7 +306,7 @@ class NavigationActivity : ScopeActivity() {
Storage.reset() Storage.reset()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
Storage.checkForErrorsWithCustomRoot() Storage.ensureRootIsAvailable()
} }
setMenuForServerCapabilities() setMenuForServerCapabilities()
@ -315,11 +314,8 @@ class NavigationActivity : ScopeActivity() {
// Lifecycle support's constructor registers some event receivers so it should be created early // Lifecycle support's constructor registers some event receivers so it should be created early
lifecycleSupport.onCreate() lifecycleSupport.onCreate()
if (!nowPlayingHidden) { if (!nowPlayingHidden) showNowPlaying()
showNowPlaying() else hideNowPlaying()
} else {
hideNowPlaying()
}
} }
/* /*
@ -333,31 +329,35 @@ class NavigationActivity : ScopeActivity() {
} }
private fun updateNavigationHeaderForServer() { private fun updateNavigationHeaderForServer() {
// Only show the vector graphic on Android 11 or earlier
val showVectorBackground = (Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
val activeServer = activeServerProvider.getActiveServer() val activeServer = activeServerProvider.getActiveServer()
if (cachedServerCount == 0) { if (cachedServerCount == 0)
selectServerButton?.text = getString(R.string.main_setup_server, activeServer.name) selectServerButton?.text = getString(R.string.main_setup_server, activeServer.name)
} else { else selectServerButton?.text = activeServer.name
selectServerButton?.text = activeServer.name
}
val foregroundColor = val foregroundColor =
ServerColor.getForegroundColor(this, activeServer.color) ServerColor.getForegroundColor(this, activeServer.color, showVectorBackground)
val backgroundColor = val backgroundColor =
ServerColor.getBackgroundColor(this, activeServer.color) ServerColor.getBackgroundColor(this, activeServer.color)
if (activeServer.index == 0) { if (activeServer.index == 0)
selectServerButton?.icon = selectServerButton?.icon =
ContextCompat.getDrawable(this, R.drawable.ic_menu_screen_on_off) ContextCompat.getDrawable(this, R.drawable.ic_menu_screen_on_off)
} else { else
selectServerButton?.icon = selectServerButton?.icon =
ContextCompat.getDrawable(this, R.drawable.ic_menu_select_server) ContextCompat.getDrawable(this, R.drawable.ic_menu_select_server)
}
selectServerButton?.iconTint = ColorStateList.valueOf(foregroundColor) selectServerButton?.iconTint = ColorStateList.valueOf(foregroundColor)
selectServerButton?.setTextColor(foregroundColor) selectServerButton?.setTextColor(foregroundColor)
selectServerDropdownImage?.imageTintList = ColorStateList.valueOf(foregroundColor)
headerBackgroundImage?.setBackgroundColor(backgroundColor) headerBackgroundImage?.setBackgroundColor(backgroundColor)
// Hide the vector graphic on Android 12 or later
if (!showVectorBackground) {
headerBackgroundImage?.setImageDrawable(null)
}
} }
private fun setupNavigationMenu(navController: NavController) { private fun setupNavigationMenu(navController: NavController) {
@ -402,23 +402,26 @@ class NavigationActivity : ScopeActivity() {
selectServerButton = selectServerButton =
navigationView?.getHeaderView(0)?.findViewById(R.id.header_select_server) navigationView?.getHeaderView(0)?.findViewById(R.id.header_select_server)
selectServerDropdownImage = val dropDownButton: ImageView? =
navigationView?.getHeaderView(0)?.findViewById(R.id.edit_server_button) navigationView?.getHeaderView(0)?.findViewById(R.id.edit_server_button)
val onClick: (View) -> Unit = { val onClick: (View) -> Unit = {
if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true) { if (drawerLayout?.isDrawerVisible(GravityCompat.START) == true)
this.drawerLayout?.closeDrawer(GravityCompat.START) this.drawerLayout?.closeDrawer(GravityCompat.START)
}
navController.navigate(R.id.serverSelectorFragment) navController.navigate(R.id.serverSelectorFragment)
} }
selectServerButton?.setOnClickListener(onClick) selectServerButton?.setOnClickListener(onClick)
selectServerDropdownImage?.setOnClickListener(onClick) dropDownButton?.setOnClickListener(onClick)
headerBackgroundImage = headerBackgroundImage =
navigationView?.getHeaderView(0)?.findViewById(R.id.img_header_bg) navigationView?.getHeaderView(0)?.findViewById(R.id.img_header_bg)
} }
private fun setupActionBar(navController: NavController, appBarConfig: AppBarConfiguration) {
setupActionBarWithNavController(navController, appBarConfig)
}
private val closeNavigationDrawerOnBack = object : OnBackPressedCallback(true) { private val closeNavigationDrawerOnBack = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
drawerLayout?.closeDrawer(GravityCompat.START) drawerLayout?.closeDrawer(GravityCompat.START)
@ -435,30 +438,19 @@ class NavigationActivity : ScopeActivity() {
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
val navController = findNavController(R.id.nav_host_fragment) return item.onNavDestinationSelected(findNavController(R.id.nav_host_fragment)) ||
// Check if this item ID exists in the nav graph
val destinationExists = navController.graph.findNode(item.itemId) != null
return if (destinationExists) {
item.onNavDestinationSelected(navController) || super.onOptionsItemSelected(item)
} else {
// Let the fragments handle their own menu items
super.onOptionsItemSelected(item) super.onOptionsItemSelected(item)
}
} }
// TODO: Why is this needed? Shouldn't it just work by default?
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
// This override is required by design when using setupActionBarWithNavController() return findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration)
// with an AppBarConfiguration. It ensures that the Up button behavior is correctly
// delegated — either navigating "up" in the back stack, or opening the drawer if
// we're at a top-level destination.
return findNavController(R.id.nav_host_fragment).navigateUp(appBarConfiguration) ||
super.onSupportNavigateUp()
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
when (intent.action) { when (intent?.action) {
Constants.INTENT_PLAY_RANDOM_SONGS -> { Constants.INTENT_PLAY_RANDOM_SONGS -> {
playRandomSongs() playRandomSongs()
} }
@ -481,8 +473,7 @@ class NavigationActivity : ScopeActivity() {
private fun handleSearchIntent(query: String?, autoPlay: Boolean) { private fun handleSearchIntent(query: String?, autoPlay: Boolean) {
val suggestions = SearchRecentSuggestions( val suggestions = SearchRecentSuggestions(
this, this,
SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.AUTHORITY, SearchSuggestionProvider.MODE
SearchSuggestionProvider.MODE
) )
suggestions.saveRecentQuery(query, null) suggestions.saveRecentQuery(query, null)
@ -494,19 +485,16 @@ class NavigationActivity : ScopeActivity() {
val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return val currentFragment = host?.childFragmentManager?.fragments?.last() ?: return
val service = MusicServiceFactory.getMusicService() val service = MusicServiceFactory.getMusicService()
val musicDirectory = service.getRandomSongs(Settings.maxSongs) val musicDirectory = service.getRandomSongs(Settings.maxSongs)
val downloadHandler: DownloadHandler by inject()
mediaPlayerManager.addToPlaylist( downloadHandler.addTracksToMediaController(
songs = musicDirectory.getTracks(), songs = musicDirectory.getTracks(),
append = false,
playNext = false,
autoPlay = true, autoPlay = true,
shuffle = false, shuffle = false,
insertionMode = MediaPlayerManager.InsertionMode.CLEAR fragment = currentFragment,
playlistName = null
) )
if (Settings.shouldTransitionOnPlayback) {
currentFragment.findNavController().popBackStack(R.id.playerFragment, true)
currentFragment.findNavController().navigate(R.id.playerFragment)
}
return return
} }
@ -537,6 +525,7 @@ class NavigationActivity : ScopeActivity() {
private fun showWelcomeDialog() { private fun showWelcomeDialog() {
if (!UApp.instance!!.setupDialogDisplayed) { if (!UApp.instance!!.setupDialogDisplayed) {
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext()) Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
InfoDialog.Builder(this) InfoDialog.Builder(this)

View File

@ -32,11 +32,11 @@ import org.moire.ultrasonic.util.LayoutType
*/ */
open class AlbumRowDelegate( open class AlbumRowDelegate(
open val onItemClick: (Album) -> Unit, open val onItemClick: (Album) -> Unit,
open val onContextMenuClick: (MenuItem, Album) -> Boolean open val onContextMenuClick: (MenuItem, Album) -> Boolean,
) : ItemViewDelegate<Album, AlbumRowDelegate.ListViewHolder>(), KoinComponent { ) : ItemViewDelegate<Album, AlbumRowDelegate.ListViewHolder>(), KoinComponent {
private val starDrawable: Int = R.drawable.rating_star_full private val starDrawable: Int = R.drawable.ic_star_full
private val starHollowDrawable: Int = R.drawable.rating_star_hollow private val starHollowDrawable: Int = R.drawable.ic_star_hollow
open var layoutType = LayoutType.LIST open var layoutType = LayoutType.LIST
@ -61,11 +61,8 @@ open class AlbumRowDelegate(
val imageLoaderProvider: ImageLoaderProvider by inject() val imageLoaderProvider: ImageLoaderProvider by inject()
imageLoaderProvider.executeOn { imageLoaderProvider.executeOn {
it.loadImage( it.loadImage(
holder.coverArt, holder.coverArt, item,
item, false, 0, R.drawable.unknown_album
false,
0,
R.drawable.unknown_album
) )
} }
} }

View File

@ -17,10 +17,6 @@ import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder import com.drakeet.multitype.ItemViewBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
@ -67,21 +63,16 @@ class ArtistRowBinder(
if (showArtistPicture()) { if (showArtistPicture()) {
holder.coverArt.visibility = View.VISIBLE holder.coverArt.visibility = View.VISIBLE
CoroutineScope(Dispatchers.IO).launch { val key = FileUtil.getArtistArtKey(item.name, false)
val key = FileUtil.getArtistArtKey(item.name, false) imageLoaderProvider.executeOn {
it.loadImage(
withContext(Dispatchers.Main) { view = holder.coverArt,
imageLoaderProvider.executeOn { id = holder.coverArtId,
it.loadImage( key = key,
view = holder.coverArt, large = false,
id = holder.coverArtId, size = 0,
key = key, defaultResourceId = R.drawable.ic_contact_picture
large = false, )
size = 0,
defaultResourceId = R.drawable.ic_contact_picture
)
}
}
} }
} else { } else {
holder.coverArt.visibility = View.GONE holder.coverArt.visibility = View.GONE

View File

@ -23,7 +23,10 @@ class DividerBinder : ItemViewBinder<DividerBinder.Divider, DividerBinder.ViewHo
holder.textView.setText(item.stringId) holder.textView.setText(item.stringId)
} }
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(
inflater: LayoutInflater,
parent: ViewGroup
): ViewHolder {
return ViewHolder(inflater.inflate(layout, parent, false)) return ViewHolder(inflater.inflate(layout, parent, false))
} }

View File

@ -78,10 +78,7 @@ class FolderSelectorBinder(context: Context) :
val popup = PopupMenu(weakContext.get()!!, layout) val popup = PopupMenu(weakContext.get()!!, layout)
var menuItem = popup.menu.add( var menuItem = popup.menu.add(
MENU_GROUP_MUSIC_FOLDER, MENU_GROUP_MUSIC_FOLDER, -1, 0, R.string.select_artist_all_folders
-1,
0,
R.string.select_artist_all_folders
) )
if (selectedFolderId == null || selectedFolderId!!.isEmpty()) { if (selectedFolderId == null || selectedFolderId!!.isEmpty()) {

View File

@ -46,6 +46,7 @@ class HeaderViewBinder(
} }
override fun onBindViewHolder(holder: ViewHolder, item: AlbumHeader) { override fun onBindViewHolder(holder: ViewHolder, item: AlbumHeader) {
val context = weakContext.get() ?: return val context = weakContext.get() ?: return
val resources = context.resources val resources = context.resources
@ -97,8 +98,7 @@ class HeaderViewBinder(
holder.yearView.text = year holder.yearView.text = year
val songs = resources.getQuantityString( val songs = resources.getQuantityString(
R.plurals.n_songs, R.plurals.select_album_n_songs, item.childCount,
item.childCount,
item.childCount item.childCount
) )
holder.songCountView.text = songs holder.songCountView.text = songs

View File

@ -77,6 +77,7 @@ internal class ServerRowAdapter(
*/ */
@Suppress("LongMethod") @Suppress("LongMethod")
override fun getView(pos: Int, convertView: View?, parent: ViewGroup?): View? { override fun getView(pos: Int, convertView: View?, parent: ViewGroup?): View? {
var vi: View? = convertView var vi: View? = convertView
if (vi == null) vi = inflater.inflate(R.layout.server_row, parent, false) if (vi == null) vi = inflater.inflate(R.layout.server_row, parent, false)

View File

@ -1,18 +1,13 @@
package org.moire.ultrasonic.adapters package org.moire.ultrasonic.adapters
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.LayerDrawable
import android.os.Build
import android.view.MenuInflater
import android.view.View import android.view.View
import android.widget.Checkable import android.widget.Checkable
import android.widget.CheckedTextView import android.widget.CheckedTextView
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -36,7 +31,7 @@ import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.service.plusAssign
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.themeColor import timber.log.Timber
const val INDICATOR_THICKNESS_INDEFINITE = 5 const val INDICATOR_THICKNESS_INDEFINITE = 5
const val INDICATOR_THICKNESS_DEFINITE = 10 const val INDICATOR_THICKNESS_DEFINITE = 10
@ -56,12 +51,17 @@ class TrackViewHolder(val view: View) :
var entry: Track? = null var entry: Track? = null
private set private set
private var songLayout: LinearLayout = view.findViewById(R.id.song_layout) var songLayout: LinearLayout = view.findViewById(R.id.song_layout)
var check: CheckedTextView = view.findViewById(R.id.song_check) var check: CheckedTextView = view.findViewById(R.id.song_check)
var drag: ImageView = view.findViewById(R.id.song_drag) var drag: ImageView = view.findViewById(R.id.song_drag)
var observableChecked = MutableLiveData(false) var observableChecked = MutableLiveData(false)
private var rating: LinearLayout = view.findViewById(R.id.song_rating)
private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
private var fiveStar2: ImageView = view.findViewById(R.id.song_five_star_2)
private var fiveStar3: ImageView = view.findViewById(R.id.song_five_star_3)
private var fiveStar4: ImageView = view.findViewById(R.id.song_five_star_4)
private var fiveStar5: ImageView = view.findViewById(R.id.song_five_star_5)
private var star: ImageView = view.findViewById(R.id.song_star) private var star: ImageView = view.findViewById(R.id.song_star)
private var track: TextView = view.findViewById(R.id.song_track) private var track: TextView = view.findViewById(R.id.song_track)
private var title: TextView = view.findViewById(R.id.song_title) private var title: TextView = view.findViewById(R.id.song_title)
@ -79,35 +79,20 @@ class TrackViewHolder(val view: View) :
private var rxBusSubscription: CompositeDisposable? = null private var rxBusSubscription: CompositeDisposable? = null
init {
Timber.v("New ViewHolder created")
}
@Suppress("ComplexMethod") @Suppress("ComplexMethod")
fun setSong(song: Track, checkable: Boolean, draggable: Boolean, isSelected: Boolean = false) { fun setSong(
song: Track,
checkable: Boolean,
draggable: Boolean,
isSelected: Boolean = false
) {
val useFiveStarRating = Settings.useFiveStarRating
entry = song entry = song
// Create new Disposable for the new Subscriptions
rxBusSubscription = CompositeDisposable()
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition)
}
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
if (it.id != song.id) return@subscribe
updateStatus(it.state, it.progress)
}
// Listen for rating updates
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
launch(Dispatchers.Main) {
// Ignore updates which are not for the current song
if (it.id != song.id) return@launch
if (it.rating is HeartRating) {
updateRatingDisplay(song.userRating, it.rating.isHeart)
} else if (it.rating is StarRating) {
updateRatingDisplay(it.rating.starRating.toInt(), song.starred)
}
}
}
val entryDescription = Util.readableEntryDescription(song) val entryDescription = Util.readableEntryDescription(song)
artist.text = entryDescription.artist artist.text = entryDescription.artist
@ -128,7 +113,7 @@ class TrackViewHolder(val view: View) :
if (ActiveServerProvider.isOffline()) { if (ActiveServerProvider.isOffline()) {
star.isGone = true star.isGone = true
} else { } else {
setupRating(song) setupStarButtons(song, useFiveStarRating)
} }
// Instead of blocking the UI thread while looking up the current state, // Instead of blocking the UI thread while looking up the current state,
@ -140,12 +125,41 @@ class TrackViewHolder(val view: View) :
) )
} }
updateRatingDisplay(entry!!.userRating, entry!!.starred) if (useFiveStarRating) {
updateFiveStars(entry?.userRating ?: 0)
} else {
updateSingleStar(entry!!.starred)
}
if (song.isVideo) { if (song.isVideo) {
artist.isGone = true artist.isGone = true
progressIndicator.isGone = true progressIndicator.isGone = true
} }
// Create new Disposable for the new Subscriptions
rxBusSubscription = CompositeDisposable()
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
setPlayIcon(it.track?.id == song.id && it.index == bindingAdapterPosition)
}
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
if (it.id != song.id) return@subscribe
updateStatus(it.state, it.progress)
}
// Listen for rating updates
rxBusSubscription!! += RxBus.ratingPublishedObservable.subscribe {
launch(Dispatchers.Main) {
// Ignore updates which are not for the current song
if (it.id != song.id) return@launch
if (it.rating is HeartRating) {
updateSingleStar(it.rating.isHeart)
} else if (it.rating is StarRating) {
updateFiveStars(it.rating.starRating.toInt())
}
}
}
} }
// This is called when the Holder is recycled and receives a new Song // This is called when the Holder is recycled and receives a new Song
@ -162,10 +176,7 @@ class TrackViewHolder(val view: View) :
if (isPlaying && !isPlayingCached) { if (isPlaying && !isPlayingCached) {
isPlayingCached = true isPlayingCached = true
title.setCompoundDrawablesWithIntrinsicBounds( title.setCompoundDrawablesWithIntrinsicBounds(
playingIcon, playingIcon, null, null, null
null,
null,
null
) )
val color = MaterialColors.getColor(view, COLOR_HIGHLIGHT) val color = MaterialColors.getColor(view, COLOR_HIGHLIGHT)
songLayout.setBackgroundColor(color) songLayout.setBackgroundColor(color)
@ -173,98 +184,62 @@ class TrackViewHolder(val view: View) :
} else if (!isPlaying && isPlayingCached) { } else if (!isPlaying && isPlayingCached) {
isPlayingCached = false isPlayingCached = false
title.setCompoundDrawablesWithIntrinsicBounds( title.setCompoundDrawablesWithIntrinsicBounds(
0, 0, 0, 0, 0
0,
0,
0
) )
songLayout.setBackgroundColor(Color.TRANSPARENT) songLayout.setBackgroundColor(Color.TRANSPARENT)
songLayout.elevation = 0F songLayout.elevation = 0F
} }
} }
private fun setupRating(track: Track) { private fun setupStarButtons(track: Track, useFiveStarRating: Boolean) {
star.isVisible = true if (useFiveStarRating) {
updateRatingDisplay(track.userRating, track.starred) // Hide single star
star.isGone = true
rating.isVisible = true
val rating = if (track.userRating == null) 0 else track.userRating!!
updateFiveStars(rating)
star.setOnClickListener { toggleHeart(track) } // Five star rating has no click handler because in the
star.setOnLongClickListener { view -> showRatingPopup(view, track) } // track view theres not enough space
}
private fun toggleHeart(track: Track) {
track.starred = !track.starred
updateRatingDisplay(track.userRating, track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
@Suppress("MagicNumber")
private fun showRatingPopup(view: View, track: Track): Boolean {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
inflater.inflate(R.menu.rating, popup.menu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val rating = when (it.itemId) {
R.id.popup_rate_1 -> 1
R.id.popup_rate_2 -> 2
R.id.popup_rate_3 -> 3
R.id.popup_rate_4 -> 4
R.id.popup_rate_5 -> 5
else -> 0
}
track.userRating = rating
updateRatingDisplay(track.userRating, track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, StarRating(5, rating.toFloat()))
)
true
}
popup.show()
return true
}
@Suppress("MagicNumber")
private fun updateRatingDisplay(rating: Int?, starred: Boolean) {
val ratingDrawable = when (rating) {
1 -> R.drawable.rating_star_1
2 -> R.drawable.rating_star_2
3 -> R.drawable.rating_star_3
4 -> R.drawable.rating_star_4
5 -> R.drawable.rating_star_5
else -> {
R.drawable.rating_star_0
}
}
val layers = if (starred) {
arrayOf(
ResourcesCompat.getDrawable(view.resources, ratingDrawable, null)!!,
ResourcesCompat.getDrawable(
view.resources,
R.drawable.rating_heart_mini_overlay,
null
)!!
)
} else { } else {
arrayOf( star.isVisible = true
ResourcesCompat.getDrawable(view.resources, ratingDrawable, null)!! rating.isGone = true
) updateSingleStar(track.starred)
star.setOnClickListener {
track.starred = !track.starred
updateSingleStar(track.starred)
RxBus.ratingSubmitter.onNext(
RatingUpdate(track.id, HeartRating(track.starred))
)
}
} }
}
val ratingDisplay = LayerDrawable(layers) @Suppress("MagicNumber")
ratingDisplay.getDrawable(0).setTint( private fun updateFiveStars(rating: Int) {
view.context.themeColor(com.google.android.material.R.attr.colorOnBackground) fiveStar1.setImageResource(
if (rating > 0) R.drawable.ic_star_full else R.drawable.ic_star_hollow
) )
if (starred) { fiveStar2.setImageResource(
ratingDisplay.getDrawable(1).setTint( if (rating > 1) R.drawable.ic_star_full else R.drawable.ic_star_hollow
view.context.themeColor(com.google.android.material.R.attr.colorTertiary) )
) fiveStar3.setImageResource(
} if (rating > 2) R.drawable.ic_star_full else R.drawable.ic_star_hollow
)
fiveStar4.setImageResource(
if (rating > 3) R.drawable.ic_star_full else R.drawable.ic_star_hollow
)
fiveStar5.setImageResource(
if (rating > 4) R.drawable.ic_star_full else R.drawable.ic_star_hollow
)
}
star.setImageDrawable(ratingDisplay) private fun updateSingleStar(starred: Boolean) {
if (starred) {
star.setImageResource(R.drawable.ic_star_full)
} else {
star.setImageResource(R.drawable.ic_star_hollow)
}
} }
private fun updateStatus(status: DownloadState, progress: Int?) { private fun updateStatus(status: DownloadState, progress: Int?) {
@ -287,8 +262,7 @@ class TrackViewHolder(val view: View) :
showProgress() showProgress()
} }
DownloadState.RETRYING, DownloadState.RETRYING,
DownloadState.QUEUED DownloadState.QUEUED -> {
-> {
showIndefiniteProgress() showIndefiniteProgress()
} }
else -> { else -> {

View File

@ -114,8 +114,10 @@ private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder {
detectLeakedClosableObjects() detectLeakedClosableObjects()
detectLeakedRegistrationObjects() detectLeakedRegistrationObjects()
detectFileUriExposure() detectFileUriExposure()
detectContentUriWithoutPermission()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
detectContentUriWithoutPermission()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
detectCredentialProtectedWhileLocked() detectCredentialProtectedWhileLocked()
} }

View File

@ -50,8 +50,7 @@ class EqualizerController : CoroutineScope by CoroutineScope(Dispatchers.IO) {
launch { launch {
try { try {
val settings = deserialize<EqualizerSettings>( val settings = deserialize<EqualizerSettings>(
UApp.applicationContext(), UApp.applicationContext(), "equalizer.dat"
"equalizer.dat"
) )
settings?.apply(equalizer!!) settings?.apply(equalizer!!)
} catch (all: Throwable) { } catch (all: Throwable) {

View File

@ -7,6 +7,8 @@
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import android.os.Handler
import android.os.Looper
import androidx.room.Room import androidx.room.Room
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -51,8 +53,7 @@ class ActiveServerProvider(
} }
Timber.d( Timber.d(
"getActiveServer retrieved from DataBase, id: %s cachedServer: %s", "getActiveServer retrieved from DataBase, id: %s cachedServer: %s",
serverId, serverId, cachedServer
cachedServer
) )
} }
@ -105,30 +106,6 @@ class ActiveServerProvider(
} }
} }
/**
* Sets the Active Server by its unique id
* @param serverId: The id of the desired server
*/
fun setActiveServerById(serverId: Int) {
val oldServerId = Settings.activeServer
if (oldServerId == serverId) return
// Notify components about the change before actually resetting the MusicService
// so they can react by e.g. stopping playback on the old server
RxBus.activeServerChangingPublisher.onNext(oldServerId)
// Use a coroutine to post the server change to the end of the message queue
launch {
withContext(Dispatchers.Main) {
resetMusicService()
Settings.activeServer = serverId
RxBus.activeServerChangedPublisher.onNext(getActiveServer(serverId))
Timber.i("setActiveServerById done, new id: %s", serverId)
}
}
}
@Synchronized @Synchronized
fun getActiveMetaDatabase(): MetaDatabase { fun getActiveMetaDatabase(): MetaDatabase {
val activeServer = getActiveServerId() val activeServer = getActiveServerId()
@ -159,7 +136,7 @@ class ActiveServerProvider(
METADATA_DB + serverId METADATA_DB + serverId
) )
.addMigrations(META_MIGRATION_2_3) .addMigrations(META_MIGRATION_2_3)
.fallbackToDestructiveMigrationOnDowngrade(true) .fallbackToDestructiveMigrationOnDowngrade()
.build() .build()
} }
@ -257,6 +234,29 @@ class ActiveServerProvider(
return Settings.activeServer return Settings.activeServer
} }
/**
* Sets the Active Server by its unique id
* @param serverId: The id of the desired server
*/
fun setActiveServerById(serverId: Int) {
val oldServerId = Settings.activeServer
if (oldServerId == serverId) return
// Notify components about the change before actually resetting the MusicService
// so they can react by e.g. stopping playback on the old server
RxBus.activeServerChangingPublisher.onNext(oldServerId)
// Post the server change to the end of the message queue,
// so the cleanup have time to finish
Handler(Looper.getMainLooper()).post {
resetMusicService()
Settings.activeServer = serverId
RxBus.activeServerChangedPublisher.onNext(serverId)
Timber.i("setActiveServerById done, new id: %s", serverId)
}
}
/** /**
* Queries if Scrobbling is enabled * Queries if Scrobbling is enabled
*/ */

View File

@ -1,12 +1,3 @@
/*
* AppDatabase.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
@file:Suppress("ktlint:standard:max-line-length")
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.Database import androidx.room.Database
@ -40,8 +31,8 @@ val MIGRATION_1_2: Migration = object : Migration(1, 2) {
} }
val MIGRATION_2_1: Migration = object : Migration(2, 1) { val MIGRATION_2_1: Migration = object : Migration(2, 1) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS ServerSettingMigration ( CREATE TABLE IF NOT EXISTS ServerSettingMigration (
id INTEGER NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
@ -57,7 +48,7 @@ val MIGRATION_2_1: Migration = object : Migration(2, 1) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
""" """
INSERT INTO ServerSettingMigration ( INSERT INTO ServerSettingMigration (
id, [index], name, url, userName, password, jukeboxByDefault, id, [index], name, url, userName, password, jukeboxByDefault,
@ -69,10 +60,10 @@ val MIGRATION_2_1: Migration = object : Migration(2, 1) {
FROM ServerSetting FROM ServerSetting
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
"DROP TABLE ServerSetting" "DROP TABLE ServerSetting"
) )
db.execSQL( database.execSQL(
"ALTER TABLE ServerSettingMigration RENAME TO ServerSetting" "ALTER TABLE ServerSettingMigration RENAME TO ServerSetting"
) )
} }
@ -96,8 +87,8 @@ val MIGRATION_2_3: Migration = object : Migration(2, 3) {
} }
val MIGRATION_3_2: Migration = object : Migration(3, 2) { val MIGRATION_3_2: Migration = object : Migration(3, 2) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS ServerSettingMigration ( CREATE TABLE IF NOT EXISTS ServerSettingMigration (
id INTEGER NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
@ -114,7 +105,7 @@ val MIGRATION_3_2: Migration = object : Migration(3, 2) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
""" """
INSERT INTO ServerSettingMigration ( INSERT INTO ServerSettingMigration (
id, [index], name, url, userName, password, jukeboxByDefault, id, [index], name, url, userName, password, jukeboxByDefault,
@ -126,26 +117,26 @@ val MIGRATION_3_2: Migration = object : Migration(3, 2) {
FROM ServerSetting FROM ServerSetting
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
"DROP TABLE ServerSetting" "DROP TABLE ServerSetting"
) )
db.execSQL( database.execSQL(
"ALTER TABLE ServerSettingMigration RENAME TO ServerSetting" "ALTER TABLE ServerSettingMigration RENAME TO ServerSetting"
) )
} }
} }
val MIGRATION_3_4: Migration = object : Migration(3, 4) { val MIGRATION_3_4: Migration = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
"ALTER TABLE ServerSetting ADD COLUMN color INTEGER" "ALTER TABLE ServerSetting ADD COLUMN color INTEGER"
) )
} }
} }
val MIGRATION_4_3: Migration = object : Migration(4, 3) { val MIGRATION_4_3: Migration = object : Migration(4, 3) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS ServerSettingMigration ( CREATE TABLE IF NOT EXISTS ServerSettingMigration (
id INTEGER NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
@ -166,7 +157,7 @@ val MIGRATION_4_3: Migration = object : Migration(4, 3) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
""" """
INSERT INTO ServerSettingMigration ( INSERT INTO ServerSettingMigration (
id, [index], name, url, userName, password, jukeboxByDefault, id, [index], name, url, userName, password, jukeboxByDefault,
@ -180,18 +171,18 @@ val MIGRATION_4_3: Migration = object : Migration(4, 3) {
FROM ServerSetting FROM ServerSetting
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
"DROP TABLE ServerSetting" "DROP TABLE ServerSetting"
) )
db.execSQL( database.execSQL(
"ALTER TABLE ServerSettingMigration RENAME TO ServerSetting" "ALTER TABLE ServerSettingMigration RENAME TO ServerSetting"
) )
} }
} }
val MIGRATION_4_5: Migration = object : Migration(4, 5) { val MIGRATION_4_5: Migration = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `_new_ServerSetting` ( CREATE TABLE IF NOT EXISTS `_new_ServerSetting` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
@ -213,7 +204,7 @@ val MIGRATION_4_5: Migration = object : Migration(4, 5) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
""" """
INSERT INTO `_new_ServerSetting` ( INSERT INTO `_new_ServerSetting` (
`ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`, `ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,
@ -227,14 +218,14 @@ val MIGRATION_4_5: Migration = object : Migration(4, 5) {
FROM `ServerSetting` FROM `ServerSetting`
""".trimIndent() """.trimIndent()
) )
db.execSQL("DROP TABLE `ServerSetting`") database.execSQL("DROP TABLE `ServerSetting`")
db.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`") database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
} }
} }
val MIGRATION_5_4: Migration = object : Migration(5, 4) { val MIGRATION_5_4: Migration = object : Migration(5, 4) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( database.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `_new_ServerSetting` ( CREATE TABLE IF NOT EXISTS `_new_ServerSetting` (
`id` INTEGER PRIMARY KEY NOT NULL, `id` INTEGER PRIMARY KEY NOT NULL,
@ -256,7 +247,7 @@ val MIGRATION_5_4: Migration = object : Migration(5, 4) {
) )
""".trimIndent() """.trimIndent()
) )
db.execSQL( database.execSQL(
""" """
INSERT INTO `_new_ServerSetting` ( INSERT INTO `_new_ServerSetting` (
`ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`, `ldapSupport`,`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,
@ -270,31 +261,25 @@ val MIGRATION_5_4: Migration = object : Migration(5, 4) {
FROM `ServerSetting` FROM `ServerSetting`
""".trimIndent() """.trimIndent()
) )
db.execSQL("DROP TABLE `ServerSetting`") database.execSQL("DROP TABLE `ServerSetting`")
db.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`") database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
} }
} }
/* ktlint-disable max-line-length */
val MIGRATION_5_6: Migration = object : Migration(5, 6) { val MIGRATION_5_6: Migration = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( 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, `forcePlainTextPassword` INTEGER NOT NULL, `musicFolderId` TEXT, `minimumApiVersion` TEXT, `chatSupport` INTEGER, `bookmarkSupport` INTEGER, `shareSupport` INTEGER, `podcastSupport` INTEGER, `jukeboxSupport` INTEGER, `videoSupport` INTEGER)")
"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, `forcePlainTextPassword` INTEGER NOT NULL, `musicFolderId` TEXT, `minimumApiVersion` TEXT, `chatSupport` INTEGER, `bookmarkSupport` INTEGER, `shareSupport` INTEGER, `podcastSupport` INTEGER, `jukeboxSupport` INTEGER, `videoSupport` INTEGER)" database.execSQL("INSERT INTO `_new_ServerSetting` (`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`forcePlainTextPassword`,`id`,`allowSelfSignedCertificate`,`chatSupport`) SELECT `musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`ldapSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport` FROM `ServerSetting`")
) database.execSQL("DROP TABLE `ServerSetting`")
db.execSQL( database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
"INSERT INTO `_new_ServerSetting` (`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`forcePlainTextPassword`,`id`,`allowSelfSignedCertificate`,`chatSupport`) SELECT `musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`ldapSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport` FROM `ServerSetting`"
)
db.execSQL("DROP TABLE `ServerSetting`")
db.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
} }
} }
val MIGRATION_6_5: Migration = object : Migration(6, 5) { val MIGRATION_6_5: Migration = object : Migration(6, 5) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL( 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)")
"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)" database.execSQL("INSERT INTO `_new_ServerSetting` (`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`ldapSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport`) SELECT `musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`forcePlainTextPassword`,`id`,`allowSelfSignedCertificate`,`chatSupport` FROM `ServerSetting`")
) database.execSQL("DROP TABLE `ServerSetting`")
db.execSQL( database.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
"INSERT INTO `_new_ServerSetting` (`musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`ldapSupport`,`id`,`allowSelfSignedCertificate`,`chatSupport`) SELECT `musicFolderId`,`color`,`index`,`userName`,`minimumApiVersion`,`jukeboxByDefault`,`url`,`password`,`shareSupport`,`bookmarkSupport`,`name`,`podcastSupport`,`forcePlainTextPassword`,`id`,`allowSelfSignedCertificate`,`chatSupport` FROM `ServerSetting`"
)
db.execSQL("DROP TABLE `ServerSetting`")
db.execSQL("ALTER TABLE `_new_ServerSetting` RENAME TO `ServerSetting`")
} }
} }
/* ktlint-enable max-line-length */

View File

@ -1,10 +1,3 @@
/*
* BasicDaos.kt
* Copyright (C) 2009-2023 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.Dao import androidx.room.Dao
@ -98,7 +91,7 @@ interface GenericDao<T> {
*/ */
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
@JvmSuppressWildcards @JvmSuppressWildcards
fun insertIgnoring(obj: List<T>): List<Long> fun insertIgnoring(obj: List<T>?): List<Long>
/** /**
* Update an object from the database. * Update an object from the database.
@ -116,7 +109,7 @@ interface GenericDao<T> {
*/ */
@Update @Update
@JvmSuppressWildcards @JvmSuppressWildcards
fun update(obj: List<T>) fun update(obj: List<T>?)
/** /**
* Delete an object from the database * Delete an object from the database

View File

@ -1,12 +1,10 @@
/* /*
* MetaDatabase.kt * MetaDatabase.kt
* Copyright (C) 2009-2023 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@file:Suppress("ktlint:standard:max-line-length")
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.AutoMigration import androidx.room.AutoMigration
@ -39,7 +37,7 @@ import org.moire.ultrasonic.domain.Track
AutoMigration( AutoMigration(
from = 1, from = 1,
to = 2 to = 2
) ),
], ],
exportSchema = true, exportSchema = true,
version = 3 version = 3
@ -69,27 +67,19 @@ class Converters {
} }
} }
/* ktlint-disable max-line-length */
val META_MIGRATION_2_3: Migration = object : Migration(2, 3) { val META_MIGRATION_2_3: Migration = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE `albums`") database.execSQL("DROP TABLE `albums`")
db.execSQL("DROP TABLE `indexes`") database.execSQL("DROP TABLE `indexes`")
db.execSQL("DROP TABLE `artists`") database.execSQL("DROP TABLE `artists`")
db.execSQL("DROP TABLE `tracks`") database.execSQL("DROP TABLE `tracks`")
db.execSQL("DROP TABLE `music_folders`") database.execSQL("DROP TABLE `music_folders`")
db.execSQL( 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`))")
"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`))")
db.execSQL( 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`))")
"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 `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`))")
)
db.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`))"
)
db.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`))"
)
db.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

@ -2,7 +2,7 @@ package org.moire.ultrasonic.di
import androidx.room.Room import androidx.room.Room
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.data.AppDatabase import org.moire.ultrasonic.data.AppDatabase

View File

@ -1,15 +1,14 @@
package org.moire.ultrasonic.di package org.moire.ultrasonic.di
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
/** /**
* This Koin module contains the registration of general classes needed for Ultrasonic * This Koin module contains the registration of general classes needed for Ultrasonic
*/ */
val applicationModule = module { val applicationModule = module {
single { ActiveServerProvider(get()) } single { ActiveServerProvider(get()) }
single { ImageLoaderProvider() } single { ImageLoaderProvider(androidContext()) }
single { CacheCleaner() }
} }

View File

@ -5,21 +5,15 @@ import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
import org.moire.ultrasonic.service.MediaPlayerManager import org.moire.ultrasonic.service.MediaPlayerManager
import org.moire.ultrasonic.service.PlaybackStateSerializer import org.moire.ultrasonic.service.PlaybackStateSerializer
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
/** /**
* This Koin module contains the registration of classes related to the media player * This Koin module contains the registration of classes related to the media player
*/ */
val mediaPlayerModule = module { val mediaPlayerModule = module {
single { MediaPlayerLifecycleSupport() }
// These are dependency-free
single { PlaybackStateSerializer() } single { PlaybackStateSerializer() }
single { ExternalStorageMonitor() } single { ExternalStorageMonitor() }
single { NetworkAndStorageChecker() }
single { ShareHandler() }
// These MUST be singletons, for the media playback must work headless (without an activity) // TODO Ideally this can be cleaned up when all circular references are removed.
single { MediaPlayerManager(get(), get()) } single { MediaPlayerManager(get(), get(), get()) }
single { MediaPlayerLifecycleSupport(get(), get(), get(), get()) }
} }

View File

@ -1,9 +1,9 @@
@file:JvmName("MusicServiceModule") @file:JvmName("MusicServiceModule")
package org.moire.ultrasonic.di package org.moire.ultrasonic.di
import kotlin.math.abs import kotlin.math.abs
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.BuildConfig
@ -16,6 +16,9 @@ import org.moire.ultrasonic.service.CachedMusicService
import org.moire.ultrasonic.service.MusicService import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.OfflineMusicService import org.moire.ultrasonic.service.OfflineMusicService
import org.moire.ultrasonic.service.RESTMusicService import org.moire.ultrasonic.service.RESTMusicService
import org.moire.ultrasonic.subsonic.DownloadHandler
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
/** /**
@ -65,4 +68,8 @@ val musicServiceModule = module {
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) { single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {
OfflineMusicService() OfflineMusicService()
} }
single { DownloadHandler(get(), get()) }
single { NetworkAndStorageChecker(androidContext()) }
single { ShareHandler(androidContext()) }
} }

View File

@ -8,7 +8,6 @@
// Converts Album entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Album entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIAlbumConverter") @file:JvmName("APIAlbumConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Album import org.moire.ultrasonic.api.subsonic.models.Album

View File

@ -8,7 +8,6 @@
// Converts Artist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Artist entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIArtistConverter") @file:JvmName("APIArtistConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist

View File

@ -1,6 +1,5 @@
// Contains helper functions to convert from api ChatMessage entity to domain entity // Contains helper functions to convert from api ChatMessage entity to domain entity
@file:JvmName("APIChatMessageConverter") @file:JvmName("APIChatMessageConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.ChatMessage as ApiChatMessage import org.moire.ultrasonic.api.subsonic.models.ChatMessage as ApiChatMessage

View File

@ -1,6 +1,5 @@
// Collection of functions to convert api Genre entity to domain entity // Collection of functions to convert api Genre entity to domain entity
@file:JvmName("ApiGenreConverter") @file:JvmName("ApiGenreConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Genre as APIGenre import org.moire.ultrasonic.api.subsonic.models.Genre as APIGenre

View File

@ -1,6 +1,5 @@
// Collection of function to convert subsonic api jukebox responses to app entities // Collection of function to convert subsonic api jukebox responses to app entities
@file:JvmName("APIJukeboxConverter") @file:JvmName("APIJukeboxConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.JukeboxStatus as ApiJukeboxStatus import org.moire.ultrasonic.api.subsonic.models.JukeboxStatus as ApiJukeboxStatus

View File

@ -1,7 +1,6 @@
// Converts Lyrics entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts Lyrics entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APILyricsConverter") @file:JvmName("APILyricsConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Lyrics as APILyrics import org.moire.ultrasonic.api.subsonic.models.Lyrics as APILyrics

View File

@ -6,7 +6,6 @@
*/ */
@file:JvmName("APIMusicDirectoryConverter") @file:JvmName("APIMusicDirectoryConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import java.text.DateFormat import java.text.DateFormat
@ -36,7 +35,10 @@ fun MusicDirectoryChild.toAlbumEntity(serverId: Int): Album = Album(id, serverId
populateCommonProps(this, this@toAlbumEntity) populateCommonProps(this, this@toAlbumEntity)
} }
private fun populateCommonProps(entry: MusicDirectory.Child, source: MusicDirectoryChild) { private fun populateCommonProps(
entry: MusicDirectory.Child,
source: MusicDirectoryChild
) {
entry.parent = source.parent entry.parent = source.parent
entry.isDirectory = source.isDir entry.isDirectory = source.isDir
entry.title = source.title entry.title = source.title
@ -61,7 +63,10 @@ private fun populateCommonProps(entry: MusicDirectory.Child, source: MusicDirect
} }
} }
private fun populateTrackProps(track: Track, source: MusicDirectoryChild) { private fun populateTrackProps(
track: Track,
source: MusicDirectoryChild
) {
track.size = source.size track.size = source.size
track.contentType = source.contentType track.contentType = source.contentType
track.suffix = source.suffix track.suffix = source.suffix
@ -79,11 +84,10 @@ fun List<MusicDirectoryChild>.toDomainEntityList(serverId: Int): List<MusicDirec
val newList: MutableList<MusicDirectory.Child> = mutableListOf() val newList: MutableList<MusicDirectory.Child> = mutableListOf()
forEach { forEach {
if (it.isDir) { if (it.isDir)
newList.add(it.toAlbumEntity(serverId)) newList.add(it.toAlbumEntity(serverId))
} else { else
newList.add(it.toTrackEntity(serverId)) newList.add(it.toTrackEntity(serverId))
}
} }
return newList return newList

View File

@ -8,7 +8,6 @@
// Converts MusicFolder entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts MusicFolder entity from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIMusicFolderConverter") @file:JvmName("APIMusicFolderConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.MusicFolder as APIMusicFolder import org.moire.ultrasonic.api.subsonic.models.MusicFolder as APIMusicFolder
@ -19,8 +18,9 @@ fun APIMusicFolder.toDomainEntity(serverId: Int): MusicFolder = MusicFolder(
name = this.name name = this.name
) )
fun List<APIMusicFolder>.toDomainEntityList(serverId: Int): List<MusicFolder> = this.map { fun List<APIMusicFolder>.toDomainEntityList(serverId: Int): List<MusicFolder> =
val item = it.toDomainEntity(serverId) this.map {
item.serverId = serverId val item = it.toDomainEntity(serverId)
item item.serverId = serverId
} item
}

View File

@ -31,11 +31,8 @@ fun APIPlaylist.toMusicDirectoryDomainEntity(serverId: Int): MusicDirectory =
} }
fun APIPlaylist.toDomainEntity(): Playlist = Playlist( fun APIPlaylist.toDomainEntity(): Playlist = Playlist(
this.id, this.id, this.name, this.owner,
this.name, this.comment, this.songCount.toString(),
this.owner,
this.comment,
this.songCount.toString(),
this.created.ifNotNull { playlistDateFormat.format(it.time) } ?: "", this.created.ifNotNull { playlistDateFormat.format(it.time) } ?: "",
public public
) )

View File

@ -1,17 +1,12 @@
// Converts podcasts entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts podcasts entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APIPodcastConverter") @file:JvmName("APIPodcastConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.PodcastChannel import org.moire.ultrasonic.api.subsonic.models.PodcastChannel
fun PodcastChannel.toDomainEntity(): PodcastsChannel = PodcastsChannel( fun PodcastChannel.toDomainEntity(): PodcastsChannel = PodcastsChannel(
this.id, this.id, this.title, this.url, this.description, this.status
this.title,
this.url,
this.description,
this.status
) )
fun List<PodcastChannel>.toDomainEntitiesList(): List<PodcastsChannel> = this fun List<PodcastChannel>.toDomainEntitiesList(): List<PodcastsChannel> = this

View File

@ -8,7 +8,6 @@
// Converts SearchResult entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient] // Converts SearchResult entities from [org.moire.ultrasonic.api.subsonic.SubsonicAPIClient]
// to app domain entities. // to app domain entities.
@file:JvmName("APISearchConverter") @file:JvmName("APISearchConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.SearchResult as APISearchResult import org.moire.ultrasonic.api.subsonic.models.SearchResult as APISearchResult
@ -16,8 +15,7 @@ import org.moire.ultrasonic.api.subsonic.models.SearchThreeResult
import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult import org.moire.ultrasonic.api.subsonic.models.SearchTwoResult
fun APISearchResult.toDomainEntity(serverId: Int): SearchResult = SearchResult( fun APISearchResult.toDomainEntity(serverId: Int): SearchResult = SearchResult(
emptyList(), emptyList(), emptyList(),
emptyList(),
this.matchList.map { it.toTrackEntity(serverId) } this.matchList.map { it.toTrackEntity(serverId) }
) )

View File

@ -7,7 +7,6 @@
// Contains helper method to convert subsonic api share to domain model // Contains helper method to convert subsonic api share to domain model
@file:JvmName("APIShareConverter") @file:JvmName("APIShareConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import java.text.SimpleDateFormat import java.text.SimpleDateFormat

View File

@ -1,6 +1,5 @@
// Helper functions to convert User entity to domain entity // Helper functions to convert User entity to domain entity
@file:JvmName("APIUserConverter") @file:JvmName("APIUserConverter")
package org.moire.ultrasonic.domain package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.User import org.moire.ultrasonic.api.subsonic.models.User

View File

@ -1,6 +1,6 @@
/* /*
* AboutFragment.kt * AboutFragment.kt
* Copyright (C) 2009-2023 Ultrasonic developers * Copyright (C) 2009-2021 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -8,16 +8,17 @@
package org.moire.ultrasonic.fragment package org.moire.ultrasonic.fragment
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import android.widget.TextView import android.widget.TextView
import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import java.util.Locale import java.util.Locale
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.util.Util.applyTheme import org.moire.ultrasonic.util.Util.applyTheme
import org.moire.ultrasonic.util.Util.getVersionName import org.moire.ultrasonic.util.Util.getVersionName
@ -55,18 +56,18 @@ class AboutFragment : Fragment() {
versionName versionName
) )
FragmentTitle.setTitle(this@AboutFragment, getString(R.string.menu_about)) setTitle(this@AboutFragment, getString(R.string.menu_about))
titleText?.text = title titleText?.text = title
webPageButton?.setOnClickListener { webPageButton?.setOnClickListener {
startActivity( startActivity(
Intent(Intent.ACTION_VIEW, getString(R.string.about_webpage_url).toUri()) Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_webpage_url)))
) )
} }
reportBugButton?.setOnClickListener { reportBugButton?.setOnClickListener {
startActivity( startActivity(
Intent(Intent.ACTION_VIEW, getString(R.string.about_report_url).toUri()) Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.about_report_url)))
) )
} }
} }

View File

@ -1,10 +1,12 @@
/* /*
* AlbumListFragment.kt * AlbumListFragment.kt
* Copyright (C) 2009-2023 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@file:Suppress("NAME_SHADOWING")
package org.moire.ultrasonic.fragment package org.moire.ultrasonic.fragment
import android.os.Bundle import android.os.Bundle
@ -30,7 +32,6 @@ import org.moire.ultrasonic.domain.Album
import org.moire.ultrasonic.model.AlbumListModel import org.moire.ultrasonic.model.AlbumListModel
import org.moire.ultrasonic.util.LayoutType import org.moire.ultrasonic.util.LayoutType
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.toastingExceptionHandler
import org.moire.ultrasonic.view.FilterButtonBar import org.moire.ultrasonic.view.FilterButtonBar
import org.moire.ultrasonic.view.SortOrder import org.moire.ultrasonic.view.SortOrder
import org.moire.ultrasonic.view.ViewCapabilities import org.moire.ultrasonic.view.ViewCapabilities
@ -65,17 +66,19 @@ class AlbumListFragment(
/** /**
* The central function to pass a query to the model and return a LiveData object * The central function to pass a query to the model and return a LiveData object
*/ */
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<Album>> { override fun getLiveData(
refresh: Boolean,
append: Boolean
): LiveData<List<Album>> {
fetchAlbums(refresh) fetchAlbums(refresh)
return listModel.list return listModel.list
} }
private fun fetchAlbums(refresh: Boolean = navArgs.refresh, append: Boolean = navArgs.append) { private fun fetchAlbums(refresh: Boolean = navArgs.refresh, append: Boolean = navArgs.append) {
listModel.viewModelScope.launch(
toastingExceptionHandler() listModel.viewModelScope.launch(handler) {
) { refreshListView?.isRefreshing = true
swipeRefresh?.isRefreshing = true
if (navArgs.byArtist) { if (navArgs.byArtist) {
listModel.getAlbumsOfArtist( listModel.getAlbumsOfArtist(
@ -92,7 +95,7 @@ class AlbumListFragment(
refresh = refresh or append refresh = refresh or append
) )
} }
swipeRefresh?.isRefreshing = false refreshListView?.isRefreshing = false
} }
} }
@ -182,8 +185,8 @@ class AlbumListFragment(
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// Setup refresh handler // Setup refresh handler
swipeRefresh = view.findViewById(refreshListId) refreshListView = view.findViewById(refreshListId)
swipeRefresh?.setOnRefreshListener { refreshListView?.setOnRefreshListener {
fetchAlbums(refresh = true) fetchAlbums(refresh = true)
} }

View File

@ -17,6 +17,7 @@ import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.ArtistRowBinder import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Index import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.model.ArtistListModel import org.moire.ultrasonic.model.ArtistListModel
@ -42,7 +43,7 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
* The central function to pass a query to the model and return a LiveData object * The central function to pass a query to the model and return a LiveData object
*/ */
override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<ArtistOrIndex>> { override fun getLiveData(refresh: Boolean, append: Boolean): LiveData<List<ArtistOrIndex>> {
return listModel.getItems(navArgs.refresh || refresh, swipeRefresh!!) return listModel.getItems(navArgs.refresh || refresh, refreshListView!!)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -69,7 +70,7 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
id = item.id, id = item.id,
name = item.name, name = item.name,
parentId = item.id, parentId = item.id,
isArtist = false isArtist = (item is Artist)
) )
} else { } else {
NavigationGraphDirections.toAlbumList( NavigationGraphDirections.toAlbumList(

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