merge develop

This commit is contained in:
James Wells 2021-07-04 15:41:56 -04:00
commit 04de4544ee
No known key found for this signature in database
GPG Key ID: DB1528F6EED16127
62 changed files with 631 additions and 818 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,14 @@
apply from: bootstrap.kotlinModule
apply from: bootstrap.androidModule
apply plugin: 'kotlin-kapt'
ext {
jacocoExclude = [
'**/domain/**'
]
}
dependencies {
implementation androidSupport.roomRuntime
implementation androidSupport.roomKtx
kapt androidSupport.room
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.moire.ultrasonic.subsonic.domain">
</manifest>

View File

@ -1,18 +1,17 @@
package org.moire.ultrasonic.domain
import java.io.Serializable
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "artists")
data class Artist(
override var id: String? = null,
@PrimaryKey override var id: String,
override var name: String? = null,
var index: String? = null,
var coverArt: String? = null,
var albumCount: Long? = null,
var closeness: Int = 0
) : Serializable, GenericEntry(), Comparable<Artist> {
companion object {
private const val serialVersionUID = -5790532593784846982L
}
override var index: String? = null,
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0
) : ArtistOrIndex(id), Comparable<Artist> {
override fun compareTo(other: Artist): Int {
when {

View File

@ -0,0 +1,18 @@
package org.moire.ultrasonic.domain
import androidx.room.Ignore
open class ArtistOrIndex(
@Ignore
override var id: String,
@Ignore
override var name: String? = null,
@Ignore
open var index: String? = null,
@Ignore
open var coverArt: String? = null,
@Ignore
open var albumCount: Long? = null,
@Ignore
open var closeness: Int = 0
) : GenericEntry()

View File

@ -1,8 +1,12 @@
package org.moire.ultrasonic.domain
abstract class GenericEntry {
// TODO: Should be non-null!
abstract val id: String?
import androidx.room.Ignore
open class GenericEntry {
// TODO Should be non-null!
@Ignore
open val id: String? = null
@Ignore
open val name: String? = null
// These are just a formality and will never be called,

View File

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

View File

@ -0,0 +1,15 @@
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "indexes")
data class Index(
@PrimaryKey override var id: String,
override var name: String? = null,
override var index: String? = null,
override var coverArt: String? = null,
override var albumCount: Long? = null,
override var closeness: Int = 0,
var musicFolderId: String? = null
) : ArtistOrIndex(id)

View File

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

View File

@ -1,5 +1,7 @@
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
import java.util.Date
@ -35,8 +37,9 @@ class MusicDirectory {
return children.filter { it.isDirectory && includeDirs || !it.isDirectory && includeFiles }
}
@Entity
data class Entry(
override var id: String,
@PrimaryKey override var id: String,
var parent: String? = null,
var isDirectory: Boolean = false,
var title: String? = null,

View File

@ -1,9 +1,13 @@
package org.moire.ultrasonic.domain
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a top level directory in which music or other media is stored.
*/
@Entity(tableName = "music_folders")
data class MusicFolder(
override val id: String,
@PrimaryKey override val id: String,
override val name: String
) : GenericEntry()

View File

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

View File

@ -28,14 +28,13 @@ ext.versions = [
retrofit : "2.6.4",
jackson : "2.9.5",
okhttp : "3.12.13",
twitterSerial : "0.1.6",
koin : "3.0.2",
picasso : "2.71828",
sortListView : "1.0.1",
junit4 : "4.13.2",
junit5 : "5.7.1",
mockito : "3.11.0",
mockito : "3.11.2",
mockitoKotlin : "3.2.0",
kluent : "1.64",
apacheCodecs : "1.15",
@ -82,7 +81,6 @@ ext.other = [
jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit",
jacksonKotlin : "com.fasterxml.jackson.module:jackson-module-kotlin:$versions.jackson",
okhttpLogging : "com.squareup.okhttp3:logging-interceptor:$versions.okhttp",
twitterSerial : "com.twitter.serial:serial:$versions.twitterSerial",
koinCore : "io.insert-koin:koin-core:$versions.koin",
koinAndroid : "io.insert-koin:koin-android:$versions.koin",
koinViewModel : "io.insert-koin:koin-android-viewmodel:$versions.koin",

View File

@ -10,7 +10,6 @@
<ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>EmptyFunctionBlock:SongView.kt$SongView${}</ID>
<ID>FunctionNaming:ThemeChangedEventDistributor.kt$ThemeChangedEventDistributor$fun RaiseThemeChangedEvent()</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile$String.format("DownloadFile (%s)", song)</ID>
<ID>ImplicitDefaultLocale:DownloadFile.kt$DownloadFile.DownloadTask$String.format("Download of '%s' was cancelled", song)</ID>

View File

@ -45,6 +45,7 @@ complexity:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 30
LabeledExpression:
active: false

View File

@ -0,0 +1,12 @@
Bug fixes
- #368: Empty id in getCoverArt.
- #528: Saving playlists does not work.
Enhancements
- #514: Fix bugs in new image loader and make it default.
- #517: Cleaner separation of API result handling.
- #519: Better experience for new users.
- #520: Remove flash.
- #525: Properly generate the Video stream url, without actually making a request.
- #530: Use DiffUtil for better performance when refreshing the data.
- #532: Larger image cache.

View File

@ -0,0 +1,12 @@
Corrección de errores
- # 368: ID vacío en getCoverArt.
- # 528: Guardar listas de reproducción no funciona.
Mejoras
- # 514: Corregir errores en el nuevo cargador de imágenes y usarlo por defecto.
- # 517: Separación más limpia del manejo de resultados API.
- # 519: Mejor experiencia para nuevos usuarios.
- # 520: Quitar flash.
- # 525: Generar correctamente la URL de transmisión de video, sin realizar una nueva solicitud.
- # 530: Usar DiffUtil para un mejor rendimiento al actualizar los datos.
- # 532: Caché de imágenes más grande.

View File

@ -1,9 +1,11 @@
/**
* This module provides a base for for submodules which depend on the Android runtime
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"
android {
compileSdkVersion versions.compileSdk

View File

@ -1,5 +1,8 @@
apply plugin: 'java-library'
/**
* This module provides a base for for pure kotlin modules
*/
apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'
apply plugin: 'jacoco'
apply from: "${project.rootDir}/gradle_scripts/code_quality.gradle"

View File

@ -1,4 +1,3 @@
include ':core:domain'
include ':core:subsonic-api'
include ':core:cache'
include ':ultrasonic'

View File

@ -9,8 +9,8 @@ android {
defaultConfig {
applicationId "org.moire.ultrasonic"
versionCode 93
versionName "2.21.0"
versionCode 94
versionName "2.22.0"
minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk
@ -61,6 +61,13 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kapt {
arguments {
arg("room.schemaLocation", "$buildDir/schemas".toString())
}
}
}
tasks.withType(Test) {
@ -70,7 +77,6 @@ tasks.withType(Test) {
dependencies {
implementation project(':core:domain')
implementation project(':core:subsonic-api')
implementation project(':core:cache')
api(other.picasso) {
exclude group: "com.android.support"

View File

@ -451,13 +451,6 @@
column="9"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="This folder configuration (`v14`) is unnecessary; `minSdkVersion` is 14. Merge all the resources in this folder into `drawable-xhdpi`.">
<location
file="src/main/res/drawable-xhdpi-v14"/>
</issue>
<issue
id="StaticFieldLeak"
message="This `AsyncTask` class should be static or leaks might occur (org.moire.ultrasonic.service.DownloadQueueSerializer.SerializeTask)"
@ -480,17 +473,6 @@
column="19"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields; this is a memory leak"
errorLine1=" private static Context context;"
errorLine2=" ~~~~~~">
<location
file="src/main/java/org/moire/ultrasonic/view/UpdateView.java"
line="29"
column="10"/>
</issue>
<issue
id="UseCompoundDrawables"
message="This tag and its children can be replaced by one `&lt;TextView/>` and a compound drawable"

View File

@ -3,8 +3,6 @@
package="org.moire.ultrasonic"
android:installLocation="auto">
<uses-sdk android:minSdkVersion="20" android:targetSdkVersion="29" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>

View File

@ -3,7 +3,6 @@ package org.moire.ultrasonic.view;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import timber.log.Timber;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.LinearLayout;
@ -14,6 +13,8 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.WeakHashMap;
import timber.log.Timber;
/**
* A View that is periodically refreshed
* @deprecated
@ -26,12 +27,10 @@ public class UpdateView extends LinearLayout
private static Handler backgroundHandler;
private static Handler uiHandler;
private static Runnable updateRunnable;
private static Context context;
public UpdateView(Context context)
{
super(context);
UpdateView.context = context;
setLayoutParams(new AbsListView.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
INSTANCES.put(this, null);

View File

@ -9,7 +9,6 @@ import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.di.appPermanentStorage
import org.moire.ultrasonic.di.applicationModule
import org.moire.ultrasonic.di.baseNetworkModule
import org.moire.ultrasonic.di.directoriesModule
import org.moire.ultrasonic.di.featureFlagsModule
import org.moire.ultrasonic.di.mediaPlayerModule
import org.moire.ultrasonic.di.musicServiceModule
@ -46,7 +45,6 @@ class UApp : MultiDexApplication() {
// declare modules to use
modules(
applicationModule,
directoriesModule,
appPermanentStorage,
baseNetworkModule,
featureFlagsModule,

View File

@ -1,17 +0,0 @@
package org.moire.ultrasonic.cache
import android.content.Context
import java.io.File
/**
* Provides specific to Android implementation of [Directories].
*/
class AndroidDirectories(
private val context: Context
) : Directories {
override fun getInternalCacheDir(): File = context.cacheDir
override fun getInternalDataDir(): File = context.filesDir
override fun getExternalCacheDir(): File? = context.externalCacheDir
}

View File

@ -1,5 +1,6 @@
package org.moire.ultrasonic.data
import androidx.room.Room
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -7,6 +8,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.di.DB_FILENAME
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Util
@ -20,6 +22,8 @@ class ActiveServerProvider(
private val repository: ServerSettingDao
) {
private var cachedServer: ServerSetting? = null
private var cachedDatabase: MetaDatabase? = null
private var cachedServerId: Int? = null
/**
* Get the settings of the current Active Server
@ -82,6 +86,33 @@ class ActiveServerProvider(
}
}
@Synchronized
fun getActiveMetaDatabase(): MetaDatabase {
val activeServer = getActiveServerId()
if (activeServer == cachedServerId && cachedDatabase != null) {
return cachedDatabase!!
}
Timber.i("Switching to new database, id:$activeServer")
cachedServerId = activeServer
val db = Room.databaseBuilder(
UApp.applicationContext(),
MetaDatabase::class.java,
METADATA_DB + cachedServerId
)
.fallbackToDestructiveMigrationOnDowngrade()
.build()
return db
}
@Synchronized
fun deleteMetaDatabase(id: Int) {
cachedDatabase?.close()
UApp.applicationContext().deleteDatabase(METADATA_DB + id)
Timber.i("Deleted metadataBase, id:$id")
}
/**
* Sets the minimum Subsonic API version of the current server.
*/
@ -130,6 +161,9 @@ class ActiveServerProvider(
}
companion object {
const val METADATA_DB = "$DB_FILENAME-meta-"
/**
* Queries if the Active Server is the "Offline" mode of Ultrasonic
* @return True, if the "Offline" mode is selected

View File

@ -6,7 +6,8 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Room Database to be used to store data for Ultrasonic
* Room Database to be used to store global data for the whole app.
* This could be settings or data that are not specific to any remote music database
*/
@Database(entities = [ServerSetting::class], version = 3)
abstract class AppDatabase : RoomDatabase() {

View File

@ -0,0 +1,31 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.moire.ultrasonic.domain.Artist
@Dao
interface ArtistsDao {
/**
* Insert a list in the database. If the item already exists, replace it.
*
* @param objects the items to be inserted.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun set(objects: List<Artist>)
/**
* Clear the whole database
*/
@Query("DELETE FROM artists")
fun clear()
/**
* Get all artists
*/
@Query("SELECT * FROM artists")
fun get(): List<Artist>
}

View File

@ -0,0 +1,146 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
@Dao
interface MusicFoldersDao : GenericDao<MusicFolder> {
/**
* Clear the whole database
*/
@Query("DELETE FROM music_folders")
fun clear()
/**
* Get all folders
*/
@Query("SELECT * FROM music_folders")
fun get(): List<MusicFolder>
}
@Dao
interface IndexDao : GenericDao<Index> {
/**
* Clear the whole database
*/
@Query("DELETE FROM indexes")
fun clear()
/**
* Get all indexes
*/
@Query("SELECT * FROM indexes")
fun get(): List<Index>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(vararg indexes: Index)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertArray(arr: Array<Index>)
/**
* Get all indexes for a specific folder id
*/
@Query("SELECT * FROM indexes where musicFolderId LIKE :musicFolderId")
fun get(musicFolderId: String): List<Index>
/**
* Upserts (insert or update) an object to the database
*
* @param obj the object to upsert
*/
@Transaction
@JvmSuppressWildcards
fun upsert(obj: Index) {
val id = insertIgnoring(obj)
if (id == -1L) {
update(obj)
}
}
/**
* Upserts (insert or update) a list of objects
*
* @param objList the object to be upserted
*/
@Transaction
@JvmSuppressWildcards
fun upsert(objList: List<Index>) {
val insertResult = insertIgnoring(objList)
val updateList: MutableList<Index> = ArrayList()
for (i in insertResult.indices) {
if (insertResult[i] == -1L) {
updateList.add(objList[i])
}
}
if (updateList.isNotEmpty()) {
update(updateList)
}
}
}
interface GenericDao<T> {
/**
* Replaces the list with a new collection
*
* @param objects the items to be inserted.
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@JvmSuppressWildcards
fun set(objects: List<T>)
/**
* Insert an object in the database.
*
* @param obj the object to be inserted.
* @return The SQLite row id
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
@JvmSuppressWildcards
fun insertIgnoring(obj: T): Long
/**
* Insert an array of objects in the database.
*
* @param obj the objects to be inserted.
* @return The SQLite row ids
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
@JvmSuppressWildcards
fun insertIgnoring(obj: List<T>?): List<Long>
/**
* Update an object from the database.
*
* @param obj the object to be updated
*/
@Update
@JvmSuppressWildcards
fun update(obj: T)
/**
* Update an array of objects from the database.
*
* @param obj the object to be updated
*/
@Update
@JvmSuppressWildcards
fun update(obj: List<T>?)
/**
* Delete an object from the database
*
* @param obj the object to be deleted
*/
@Delete
@JvmSuppressWildcards
fun delete(obj: T)
}

View File

@ -0,0 +1,19 @@
package org.moire.ultrasonic.data
import androidx.room.Database
import androidx.room.RoomDatabase
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.MusicFolder
@Database(
entities = [Artist::class, Index::class, MusicFolder::class],
version = 1
)
abstract class MetaDatabase : RoomDatabase() {
abstract fun artistsDao(): ArtistsDao
abstract fun musicFoldersDao(): MusicFoldersDao
abstract fun indexDao(): IndexDao
}

View File

@ -1,13 +0,0 @@
package org.moire.ultrasonic.di
import org.koin.dsl.bind
import org.koin.dsl.module
import org.moire.ultrasonic.cache.AndroidDirectories
import org.moire.ultrasonic.cache.Directories
/**
* This Koin module contains the registration for Directories
*/
val directoriesModule = module {
single { AndroidDirectories(get()) } bind Directories::class
}

View File

@ -10,7 +10,6 @@ import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.log.TimberOkHttpLogger
@ -43,11 +42,6 @@ val musicServiceModule = module {
return@single abs("$serverUrl$serverInstance".hashCode()).toString()
}
single {
val serverId = get<String>(named("ServerID"))
return@single PermanentFileStorage(get(), serverId, BuildConfig.DEBUG)
}
single {
val server = get<ActiveServerProvider>().getActiveServer()
@ -71,7 +65,7 @@ val musicServiceModule = module {
single { SubsonicAPIClient(get(), get()) }
single<MusicService>(named(ONLINE_MUSIC_SERVICE)) {
CachedMusicService(RESTMusicService(get(), get(), get()))
CachedMusicService(RESTMusicService(get(), get()))
}
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {

View File

@ -5,12 +5,20 @@ package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Artist as APIArtist
// When we like to convert to an Artist
fun APIArtist.toDomainEntity(): Artist = Artist(
id = this@toDomainEntity.id,
coverArt = this@toDomainEntity.coverArt,
name = this@toDomainEntity.name
)
// When we like to convert to an index (eg. a single directory).
fun APIArtist.toIndexEntity(): Index = Index(
id = this@toIndexEntity.id,
coverArt = this@toIndexEntity.coverArt,
name = this@toIndexEntity.name
)
fun APIArtist.toMusicDirectoryDomainEntity(): MusicDirectory = MusicDirectory().apply {
name = this@toMusicDirectoryDomainEntity.name
addAll(this@toMusicDirectoryDomainEntity.albumsList.map { it.toDomainEntity() })

View File

@ -3,15 +3,51 @@
@file:JvmName("APIIndexesConverter")
package org.moire.ultrasonic.domain
import org.moire.ultrasonic.api.subsonic.models.Index
import org.moire.ultrasonic.api.subsonic.models.Index as APIIndex
import org.moire.ultrasonic.api.subsonic.models.Indexes as APIIndexes
fun APIIndexes.toDomainEntity(): Indexes = Indexes(
this.lastModified, this.ignoredArticles,
this.shortcutList.map { it.toDomainEntity() }.toMutableList(),
this.indexList.foldIndexToArtistList().toMutableList()
fun APIIndexes.toArtistList(): List<Artist> {
val shortcuts = this.shortcutList.map { it.toDomainEntity() }.toMutableList()
val indexes = this.indexList.foldIndexToArtistList()
indexes.forEach {
if (!shortcuts.contains(it)) {
shortcuts.add(it)
}
}
return shortcuts
}
fun APIIndexes.toIndexList(musicFolderId: String?): List<Index> {
val shortcuts = this.shortcutList.map { it.toIndexEntity() }.toMutableList()
val indexes = this.indexList.foldIndexToIndexList(musicFolderId)
indexes.forEach {
if (!shortcuts.contains(it)) {
shortcuts.add(it)
}
}
return shortcuts
}
private fun List<APIIndex>.foldIndexToArtistList(): List<Artist> = this.fold(
listOf(),
{ acc, index ->
acc + index.artists.map {
it.toDomainEntity()
}
}
)
private fun List<Index>.foldIndexToArtistList(): List<Artist> = this.fold(
listOf(), { acc, index -> acc + index.artists.map { it.toDomainEntity() } }
private fun List<APIIndex>.foldIndexToIndexList(musicFolderId: String?): List<Index> = this.fold(
listOf(),
{ acc, index ->
acc + index.artists.map {
val ret = it.toIndexEntity()
ret.musicFolderId = musicFolderId
ret
}
}
)

View File

@ -4,13 +4,13 @@ import android.os.Bundle
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.util.Constants
/**
* Displays the list of Artists from the media library
*/
class ArtistListFragment : GenericListFragment<Artist, ArtistRowAdapter>() {
class ArtistListFragment : GenericListFragment<ArtistOrIndex, ArtistRowAdapter>() {
/**
* The ViewModel to use to get the data
@ -41,7 +41,7 @@ class ArtistListFragment : GenericListFragment<Artist, ArtistRowAdapter>() {
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?): LiveData<List<Artist>> {
override fun getLiveData(args: Bundle?): LiveData<List<ArtistOrIndex>> {
val refresh = args?.getBoolean(Constants.INTENT_EXTRA_NAME_REFRESH) ?: false
return listModel.getItems(refresh, refreshListView!!)
}

View File

@ -23,19 +23,19 @@ import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.service.MusicService
/**
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(application: Application) : GenericListModel(application) {
val artists: MutableLiveData<List<Artist>> = MutableLiveData(listOf())
val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData(listOf())
/**
* Retrieves all available Artists in a LiveData
*/
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData<List<Artist>> {
fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData<List<ArtistOrIndex>> {
// Don't reload the data if navigating back to the view that was active before.
// This way, we keep the scroll position
if (artists.value!!.isEmpty() || refresh) {
@ -55,14 +55,14 @@ class ArtistListModel(application: Application) : GenericListModel(application)
val musicFolderId = activeServer.musicFolderId
val result = if (!isOffline && useId3Tags)
musicService.getArtists(refresh)
else musicService.getIndexes(musicFolderId, refresh)
val result: List<ArtistOrIndex>
val retrievedArtists: MutableList<Artist> =
ArrayList(result.shortcuts.size + result.artists.size)
retrievedArtists.addAll(result.shortcuts)
retrievedArtists.addAll(result.artists)
artists.postValue(retrievedArtists)
if (!isOffline && useId3Tags) {
result = musicService.getArtists(refresh)
} else {
result = musicService.getIndexes(musicFolderId, refresh)
}
artists.postValue(result.toMutableList())
}
}

View File

@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView.SectionedAdapter
import java.text.Collator
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.Util
@ -22,12 +22,12 @@ import org.moire.ultrasonic.util.Util
* Creates a Row in a RecyclerView which contains the details of an Artist
*/
class ArtistRowAdapter(
artistList: List<Artist>,
onItemClick: (Artist) -> Unit,
onContextMenuClick: (MenuItem, Artist) -> Boolean,
artistList: List<ArtistOrIndex>,
onItemClick: (ArtistOrIndex) -> Unit,
onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val imageLoader: ImageLoader,
onMusicFolderUpdate: (String?) -> Unit
) : GenericRowAdapter<Artist>(
) : GenericRowAdapter<ArtistOrIndex>(
onItemClick,
onContextMenuClick,
onMusicFolderUpdate
@ -43,7 +43,7 @@ class ArtistRowAdapter(
/**
* Sets the data to be displayed in the RecyclerView
*/
override fun setData(data: List<Artist>) {
override fun setData(data: List<ArtistOrIndex>) {
itemList = data.sortedWith(compareBy(Collator.getInstance()) { t -> t.name })
super.notifyDataSetChanged()
}

View File

@ -45,7 +45,7 @@ open class GenericListModel(application: Application) :
return true
}
internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData()
internal val musicFolders: MutableLiveData<List<MusicFolder>> = MutableLiveData(listOf())
/**
* Helper function to check online status

View File

@ -8,7 +8,6 @@ import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ListView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
@ -104,7 +103,7 @@ class ServerSelectorFragment : Fragment() {
val serverList = serverSettingsModel.getServerList()
serverList.observe(
this,
Observer { t ->
{ t ->
serverRowAdapter!!.setData(t.toTypedArray())
}
)
@ -141,10 +140,16 @@ class ServerSelectorFragment : Fragment() {
dialog.dismiss()
val activeServerIndex = activeServerProvider.getActiveServer().index
val id = ActiveServerProvider.getActiveServerId()
// If the currently active server is deleted, go offline
if (index == activeServerIndex) setActiveServer(-1)
serverSettingsModel.deleteItem(index)
// Clear the metadata cache
activeServerProvider.deleteMetaDatabase(id)
Timber.i("Server deleted: $index")
}
.setNegativeButton(R.string.common_cancel) { dialog, _ ->

View File

@ -1,9 +1,13 @@
package org.moire.ultrasonic.imageloader
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.text.TextUtils
import android.view.View
import android.widget.ImageView
import androidx.core.content.ContextCompat
import com.squareup.picasso.LruCache
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import java.io.File
@ -35,6 +39,7 @@ class ImageLoader(
private val picasso = Picasso.Builder(context)
.addRequestHandler(CoverArtRequestHandler(apiClient))
.addRequestHandler(AvatarRequestHandler(apiClient))
.memoryCache(LruCache(calculateMemoryCacheSize(context)))
.build().apply {
setIndicatorsEnabled(BuildConfig.DEBUG)
}
@ -179,6 +184,18 @@ class ImageLoader(
return requested
}
}
private fun calculateMemoryCacheSize(context: Context): Int {
val am = ContextCompat.getSystemService(
context,
ActivityManager::class.java
)
val largeHeap = context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP != 0
val memoryClass = if (largeHeap) am!!.largeMemoryClass else am!!.memoryClass
// Target 25% of the available heap.
@Suppress("MagicNumber")
return (1024L * 1024L * memoryClass / 4).toInt()
}
}
/**

View File

@ -11,10 +11,12 @@ import java.util.concurrent.TimeUnit
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory
@ -33,19 +35,24 @@ import org.moire.ultrasonic.util.Util
@Suppress("TooManyFunctions")
class CachedMusicService(private val musicService: MusicService) : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
// Old style TimeLimitedCache
private val cachedMusicDirectories: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedAlbum: LRUCache<String, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(120, TimeUnit.SECONDS)
private val cachedPlaylists = TimeLimitedCache<List<Playlist>?>(3600, TimeUnit.SECONDS)
private val cachedPodcastsChannels =
TimeLimitedCache<List<PodcastsChannel>?>(3600, TimeUnit.SECONDS)
private val cachedGenres = TimeLimitedCache<List<Genre>>(10 * 3600, TimeUnit.SECONDS)
// New Room Database
private var cachedArtists = metaDatabase.artistsDao()
private var cachedIndexes = metaDatabase.indexDao()
private val cachedMusicFolders = metaDatabase.musicFoldersDao()
private val cachedMusicDirectories: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedArtist: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedAlbum: LRUCache<String?, TimeLimitedCache<MusicDirectory?>>
private val cachedUserInfo: LRUCache<String?, TimeLimitedCache<UserInfo?>>
private val cachedLicenseValid = TimeLimitedCache<Boolean>(expiresAfter = 10, TimeUnit.MINUTES)
private val cachedIndexes = TimeLimitedCache<Indexes?>()
private val cachedArtists = TimeLimitedCache<Indexes?>()
private val cachedPlaylists = TimeLimitedCache<List<Playlist>?>()
private val cachedPodcastsChannels = TimeLimitedCache<List<PodcastsChannel>>()
private val cachedMusicFolders =
TimeLimitedCache<List<MusicFolder>?>(10, TimeUnit.HOURS)
private val cachedGenres = TimeLimitedCache<List<Genre>?>(10, TimeUnit.HOURS)
private var restUrl: String? = null
private var cachedMusicFolderId: String? = null
@ -72,41 +79,51 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
if (refresh) {
cachedMusicFolders.clear()
}
var result = cachedMusicFolders.get()
val cache = cachedMusicFolders.get()
if (cache != null) return cache
val result = musicService.getMusicFolders(refresh)
cachedMusicFolders.set(result)
if (result.isEmpty()) {
result = musicService.getMusicFolders(refresh)
cachedMusicFolders.set(result)
}
return result
}
@Throws(Exception::class)
override fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes {
override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
checkSettingsChanged()
if (refresh) {
cachedIndexes.clear()
cachedMusicFolders.clear()
cachedMusicDirectories.clear()
}
var result = cachedIndexes.get()
if (result == null) {
result = musicService.getIndexes(musicFolderId, refresh)
cachedIndexes.set(result)
var indexes: List<Index>
if (musicFolderId == null) {
indexes = cachedIndexes.get()
} else {
indexes = cachedIndexes.get(musicFolderId)
}
return result
if (indexes.isEmpty()) {
indexes = musicService.getIndexes(musicFolderId, refresh)
cachedIndexes.upsert(indexes)
}
return indexes
}
@Throws(Exception::class)
override fun getArtists(refresh: Boolean): Indexes {
override fun getArtists(refresh: Boolean): List<Artist> {
checkSettingsChanged()
if (refresh) {
cachedArtists.clear()
}
var result = cachedArtists.get()
if (result == null) {
if (result.isEmpty()) {
result = musicService.getArtists(refresh)
cachedArtist.clear()
cachedArtists.set(result)
}
return result
@ -296,19 +313,26 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
return musicService.setJukeboxGain(gain)
}
@Synchronized
private fun checkSettingsChanged() {
val newUrl = activeServerProvider.getRestUrl(null)
val newFolderId = activeServerProvider.getActiveServer().musicFolderId
if (!Util.equals(newUrl, restUrl) || !Util.equals(cachedMusicFolderId, newFolderId)) {
cachedMusicFolders.clear()
// Switch database
metaDatabase = activeServerProvider.getActiveMetaDatabase()
cachedArtists = metaDatabase.artistsDao()
cachedIndexes = metaDatabase.indexDao()
// Clear in memory caches
cachedMusicDirectories.clear()
cachedLicenseValid.clear()
cachedIndexes.clear()
cachedPlaylists.clear()
cachedGenres.clear()
cachedAlbum.clear()
cachedArtist.clear()
cachedUserInfo.clear()
// Set the cache keys
restUrl = newUrl
cachedMusicFolderId = newFolderId
}
@ -330,7 +354,7 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
}
@Throws(Exception::class)
override fun getGenres(refresh: Boolean): List<Genre>? {
override fun getGenres(refresh: Boolean): List<Genre> {
checkSettingsChanged()
if (refresh) {
cachedGenres.clear()
@ -338,11 +362,11 @@ class CachedMusicService(private val musicService: MusicService) : MusicService,
var result = cachedGenres.get()
if (result == null) {
result = musicService.getGenres(refresh)
cachedGenres.set(result)
cachedGenres.set(result!!)
}
val sorted = result?.toMutableList()
sorted?.sortWith { genre, genre2 ->
val sorted = result.toMutableList()
sorted.sortWith { genre, genre2 ->
genre.name.compareTo(
genre2.name,
ignoreCase = true

View File

@ -7,10 +7,11 @@
package org.moire.ultrasonic.service
import java.io.InputStream
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory
@ -46,10 +47,10 @@ interface MusicService {
fun getMusicFolders(refresh: Boolean): List<MusicFolder>
@Throws(Exception::class)
fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes
fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index>
@Throws(Exception::class)
fun getArtists(refresh: Boolean): Indexes
fun getArtists(refresh: Boolean): List<Artist>
@Throws(Exception::class)
fun getMusicDirectory(id: String, name: String?, refresh: Boolean): MusicDirectory

View File

@ -28,7 +28,7 @@ import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory
@ -50,21 +50,21 @@ import timber.log.Timber
class OfflineMusicService : MusicService, KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
override fun getIndexes(musicFolderId: String?, refresh: Boolean): Indexes {
val artists: MutableList<Artist> = ArrayList()
override fun getIndexes(musicFolderId: String?, refresh: Boolean): List<Index> {
val indexes: MutableList<Index> = ArrayList()
val root = FileUtil.getMusicDirectory()
for (file in FileUtil.listFiles(root)) {
if (file.isDirectory) {
val artist = Artist()
artist.id = file.path
artist.index = file.name.substring(0, 1)
artist.name = file.name
artists.add(artist)
val index = Index(file.path)
index.id = file.path
index.index = file.name.substring(0, 1)
index.name = file.name
indexes.add(index)
}
}
val ignoredArticlesString = "The El La Los Las Le Les"
val ignoredArticles = COMPILE.split(ignoredArticlesString)
artists.sortWith { lhsArtist, rhsArtist ->
indexes.sortWith { lhsArtist, rhsArtist ->
var lhs = lhsArtist.name!!.lowercase(Locale.ROOT)
var rhs = rhsArtist.name!!.lowercase(Locale.ROOT)
val lhs1 = lhs[0]
@ -92,7 +92,7 @@ class OfflineMusicService : MusicService, KoinComponent {
lhs.compareTo(rhs)
}
return Indexes(0L, ignoredArticlesString, artists = artists)
return indexes
}
override fun getMusicDirectory(
@ -127,8 +127,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val artistName = artistFile.name
if (artistFile.isDirectory) {
if (matchCriteria(criteria, artistName).also { closeness = it } > 0) {
val artist = Artist()
artist.id = artistFile.path
val artist = Artist(artistFile.path)
artist.index = artistFile.name.substring(0, 1)
artist.name = artistName
artist.closeness = closeness
@ -442,7 +441,7 @@ class OfflineMusicService : MusicService, KoinComponent {
override fun isLicenseValid(): Boolean = true
@Throws(OfflineException::class)
override fun getArtists(refresh: Boolean): Indexes {
override fun getArtists(refresh: Boolean): List<Artist> {
throw OfflineException("getArtists isn't available in offline mode")
}

View File

@ -6,9 +6,6 @@
*/
package org.moire.ultrasonic.service
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import okhttp3.Protocol
@ -20,15 +17,13 @@ import org.moire.ultrasonic.api.subsonic.models.AlbumListType.Companion.fromName
import org.moire.ultrasonic.api.subsonic.models.JukeboxAction
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.cache.PermanentFileStorage
import org.moire.ultrasonic.cache.serializers.getIndexesSerializer
import org.moire.ultrasonic.cache.serializers.getMusicFolderListSerializer
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.domain.Indexes
import org.moire.ultrasonic.domain.Index
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.domain.Lyrics
import org.moire.ultrasonic.domain.MusicDirectory
@ -39,11 +34,14 @@ import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.domain.UserInfo
import org.moire.ultrasonic.domain.toArtistList
import org.moire.ultrasonic.domain.toDomainEntitiesList
import org.moire.ultrasonic.domain.toDomainEntity
import org.moire.ultrasonic.domain.toDomainEntityList
import org.moire.ultrasonic.domain.toIndexList
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtilKt
import org.moire.ultrasonic.util.Util
import timber.log.Timber
@ -53,7 +51,6 @@ import timber.log.Timber
@Suppress("LargeClass")
open class RESTMusicService(
val subsonicAPIClient: SubsonicAPIClient,
private val fileStorage: PermanentFileStorage,
private val activeServerProvider: ActiveServerProvider
) : MusicService {
@ -77,49 +74,31 @@ open class RESTMusicService(
override fun getMusicFolders(
refresh: Boolean
): List<MusicFolder> {
val cachedMusicFolders = fileStorage.load(
MUSIC_FOLDER_STORAGE_NAME, getMusicFolderListSerializer()
)
if (cachedMusicFolders != null && !refresh) return cachedMusicFolders
val response = API.getMusicFolders().execute().throwOnFailure()
val musicFolders = response.body()!!.musicFolders.toDomainEntityList()
fileStorage.store(MUSIC_FOLDER_STORAGE_NAME, musicFolders, getMusicFolderListSerializer())
return musicFolders
return response.body()!!.musicFolders.toDomainEntityList()
}
/**
* Retrieves the artists for a given music folder *
*/
@Throws(Exception::class)
override fun getIndexes(
musicFolderId: String?,
refresh: Boolean
): Indexes {
val indexName = INDEXES_STORAGE_NAME + (musicFolderId ?: "")
val cachedIndexes = fileStorage.load(indexName, getIndexesSerializer())
if (cachedIndexes != null && !refresh) return cachedIndexes
): List<Index> {
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
val indexes = response.body()!!.indexes.toDomainEntity()
fileStorage.store(indexName, indexes, getIndexesSerializer())
return indexes
return response.body()!!.indexes.toIndexList(musicFolderId)
}
@Throws(Exception::class)
override fun getArtists(
refresh: Boolean
): Indexes {
val cachedArtists = fileStorage.load(ARTISTS_STORAGE_NAME, getIndexesSerializer())
if (cachedArtists != null && !refresh) return cachedArtists
): List<Artist> {
val response = API.getArtists(null).execute().throwOnFailure()
val indexes = response.body()!!.indexes.toDomainEntity()
fileStorage.store(ARTISTS_STORAGE_NAME, indexes, getIndexesSerializer())
return indexes
return response.body()!!.indexes.toArtistList()
}
@Throws(Exception::class)
@ -186,11 +165,11 @@ open class RESTMusicService(
criteria: SearchCriteria
): SearchResult {
return try {
if (
!isOffline() &&
Util.getShouldUseId3Tags()
) search3(criteria)
else search2(criteria)
if (!isOffline() && Util.getShouldUseId3Tags()) {
search3(criteria)
} else {
search2(criteria)
}
} catch (ignored: ApiNotSupportedException) {
// Ensure backward compatibility with REST 1.3.
searchOld(criteria)
@ -262,28 +241,7 @@ open class RESTMusicService(
activeServerProvider.getActiveServer().name, name
)
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
FileUtilKt.savePlaylist(playlistFile, playlist, name)
}
@Throws(Exception::class)
@ -708,13 +666,7 @@ open class RESTMusicService(
// By registering a callback we ensure this info is saved in the database as well
subsonicAPIClient.onProtocolChange = {
Timber.i("Server minimum API version set to %s", it)
activeServerProvider.setMinimumApiVersion(it.toString())
activeServerProvider.setMinimumApiVersion(it.restApiVersion)
}
}
companion object {
private const val MUSIC_FOLDER_STORAGE_NAME = "music_folder"
private const val INDEXES_STORAGE_NAME = "indexes"
private const val ARTISTS_STORAGE_NAME = "artists"
}
}

View File

@ -16,6 +16,7 @@ import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
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.MusicDirectory
import org.moire.ultrasonic.fragment.AlbumListModel
import org.moire.ultrasonic.fragment.ArtistListModel
@ -91,11 +92,11 @@ class AndroidAutoMediaBrowser(application: Application) {
class ArtistListObserver(
val idPrefix: String,
val result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>,
data: LiveData<List<Artist>>
data: LiveData<List<ArtistOrIndex>>
) :
Observer<List<Artist>> {
Observer<List<ArtistOrIndex>> {
private var liveData: LiveData<List<Artist>>? = null
private var liveData: LiveData<List<ArtistOrIndex>>? = null
init {
// Order is very important here. When observerForever is called onChanged
@ -106,7 +107,7 @@ class AndroidAutoMediaBrowser(application: Application) {
liveData = data
}
override fun onChanged(artistList: List<Artist>?) {
override fun onChanged(artistList: List<ArtistOrIndex>?) {
if (liveData == null) {
// See comment in the initializer
return

View File

@ -0,0 +1,47 @@
/*
* FileUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber
// TODO: Convert FileUtil.java and merge into here.
object FileUtilKt {
fun savePlaylist(
playlistFile: File?,
playlist: MusicDirectory,
name: String
) {
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
bw.close()
fw.close()
}
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Cargando&#8230;</string>
<string name="background_task.network_error">Se ha producido un error de red. Por favor comprueba la dirección del servidor o reinténtalo mas tarde.</string>
@ -111,7 +111,9 @@
<string name="main.songs_starred">Me gusta</string>
<string name="main.songs_title">Canciones</string>
<string name="main.videos">Vídeos</string>
<string name="main.welcome_title">¡Saludos!</string>
<string name="main.welcome_text_demo">Para utilizar Ultrasonic con tu música necesitas un <b>servidor propio</b>. \n\n➤ En caso de que quieras probar la aplicación primero, se puede añadir ahora un servidor de demostración. \n\n➤ En caso contrario, puedes configurar tu servidor personal en la <b>configuración</b>.</string>
<string name="main.welcome_title">¡Bienvenido a Ultrasonic!</string>
<string name="main.welcome_cancel">Llévame a la configuración</string>
<string name="menu.about">Acerca de</string>
<string name="menu.common">Común</string>
<string name="menu.deleted_playlist">Eliminada lista de reproducción %s</string>
@ -319,7 +321,7 @@
<string name="settings.use_id3_summary">Usar el método de etiquetas ID3 en lugar del método basado en el sistema de ficheros</string>
<string name="settings.show_artist_picture">Mostrar la imagen del artista en la lista de artistas</string>
<string name="settings.show_artist_picture_summary">Muestra la imagen del artista en la lista de artistas si está disponible</string>
<string name="main.video">Vídeo</string>
<string name="main.video" tools:ignore="UnusedResources">Vídeo</string>
<string name="settings.view_refresh">Refresco de la vista</string>
<string name="settings.view_refresh_500">.5 segundos</string>
<string name="settings.view_refresh_1000">1 segundo</string>
@ -339,7 +341,7 @@
<string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="util.bytes_format.kilobyte">0 KB</string>
<string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="util.no_time">-:--</string>
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.zero_time">0:00</string>
<string name="widget.initial_text">Toca para seleccionar música</string>
<string name="widget.sdcard_busy">Tarjeta SD no disponible</string>
@ -434,6 +436,7 @@
<string name="server_editor.authentication">Autenticación</string>
<string name="server_editor.advanced">Configuración avanzada</string>
<string name="server_editor.disabled_feature">Una o más funciones se han deshabilitado porque el servidor no las admite.\nPuedes ejecutar esta prueba nuevamente en cualquier momento.</string>
<string name="server_menu.demo">Servidor de demostración</string>
<plurals name="select_album_n_songs">
<item quantity="one">1 canción</item>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">Carregando&#8230;</string>
<string name="background_task.network_error">Ocorreu um erro de rede. Verifique o endereço do servidor ou tente mais tarde.</string>
@ -15,9 +15,16 @@
<string name="button_bar.chat">Chat</string>
<string name="button_bar.home">Menu Principal</string>
<string name="button_bar.now_playing">Tocando Agora</string>
<string name="buttons.play">Tocar</string>
<string name="buttons.pause">Pausar</string>
<string name="buttons.repeat">Repetir</string>
<string name="buttons.shuffle">Misturar</string>
<string name="buttons.stop">Parar</string>
<string name="buttons.next">Próxima</string>
<string name="buttons.previous">Anterior</string>
<string name="podcasts.label">Podcasts</string>
<string name="podcasts_channels.empty">Nenhum canal de podcasts registrado</string>
<string name="button_bar.podcasts">Podcast</string>
<string name="button_bar.podcasts">Podcasts</string>
<string name="button_bar.playlists">Playlists</string>
<string name="button_bar.search">Pesquisa</string>
<string name="chat.send_a_message">Enviar uma mensagem</string>
@ -32,8 +39,11 @@
<string name="common.name">Nome</string>
<string name="common.ok">OK</string>
<string name="common.pin">Fixar</string>
<string name="common.pause">Pausar</string>
<string name="common.play">Tocar</string>
<string name="common.play_last">Tocar por Último</string>
<string name="common.play_next">Tocar na Próxima</string>
<string name="common.play_previous">Tocar a Anterior</string>
<string name="common.play_now">Tocar Agora</string>
<string name="common.play_shuffled">Tocar Aleatoriamente</string>
<string name="common.public">Público</string>
@ -101,7 +111,9 @@
<string name="main.songs_starred">Favoritas</string>
<string name="main.songs_title">Músicas</string>
<string name="main.videos">Vídeos</string>
<string name="main.welcome_title">Bem-vindo!</string>
<string name="main.welcome_text_demo">Para usar o Ultrasonic com sua própria música, você precisará de um <b>servidor próprio</b>.\n\n➤ Caso queira experimentar o aplicativo primeiro, você pode adicionar um servidor de demonstração agora.\n\n➤ Caso contrário, configure seu servidor nas <b>configurações</b>.</string>
<string name="main.welcome_title">Bem-vindo ao Ultrasonic!</string>
<string name="main.welcome_cancel">Vá para as configurações</string>
<string name="menu.about">Sobre</string>
<string name="menu.common">Comum</string>
<string name="menu.deleted_playlist">Playlist excluída %s</string>
@ -250,6 +262,8 @@
<string name="settings.playback.resume_play_on_headphones_plug.summary">O aplicativo retomará a reprodução em pausa na inserção dos fones de ouvido no dispositivo.</string>
<string name="settings.screen_lit_summary">Manter a tela ligada enquanto baixando aumenta a velocidade de download.</string>
<string name="settings.screen_lit_title">Manter a Tela Ligada</string>
<string name="settings.scrobble_summary">Lembrar de configurar o usuário e senha no(s) serviço(s) Scrobble do servidor</string>
<string name="settings.scrobble_title">Scrobble minhas músicas</string>
<string name="settings.search_1">1</string>
<string name="settings.search_10">10</string>
<string name="settings.search_100">100</string>
@ -307,7 +321,7 @@
<string name="settings.use_id3_summary">Usar as etiquetas ID3 ao invés do sistema de arquivos</string>
<string name="settings.show_artist_picture">Mostrar Foto do Artista na Lista</string>
<string name="settings.show_artist_picture_summary">Mostrar a imagem do artista na lista de artistas, se disponível</string>
<string name="main.video">Vídeo</string>
<string name="main.video" tools:ignore="UnusedResources">Vídeo</string>
<string name="settings.view_refresh">Atualização da Tela</string>
<string name="settings.view_refresh_500">.5 segundos</string>
<string name="settings.view_refresh_1000">1 segundo</string>
@ -327,7 +341,7 @@
<string name="util.bytes_format.gigabyte">0.00 GB</string>
<string name="util.bytes_format.kilobyte">0 KB</string>
<string name="util.bytes_format.megabyte">0.00 MB</string>
<string name="util.no_time">-:--</string>
<string name="util.no_time" tools:ignore="TypographyDashes">-:--</string>
<string name="util.zero_time">0:00</string>
<string name="widget.initial_text">Toque para selecionar a música</string>
<string name="widget.sdcard_busy">Cartão SD indisponível</string>
@ -421,6 +435,9 @@
<string name="server_menu.move_down">Para baixo</string>
<string name="server_editor.authentication">Autenticação</string>
<string name="server_editor.advanced">Configurações avançadas</string>
<string name="server_editor.disabled_feature">Um ou mais recursos foram desativados porque o servidor não os suporta.\nVocê pode rodar este teste novamente a qualquer momento.</string>
<string name="server_menu.demo">Servidor Demonstração</string>
<plurals name="select_album_n_songs">
<item quantity="one">%d música</item>
<item quantity="other">%d músicas</item>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="background_task.loading">載入中…</string>
<string name="button_bar.bookmarks">書籤</string>

View File

@ -11,7 +11,7 @@ import org.moire.ultrasonic.api.subsonic.models.Indexes
/**
* Unit tests for extension functions in [APIIndexesConverter.kt].
*/
class APIIndexesConverterTest {
class APIIndexConverterTest {
@Test
fun `Should convert Indexes entity`() {
val artistsA = listOf(
@ -31,15 +31,12 @@ class APIIndexesConverterTest {
shortcutList = artistsA
)
val convertedEntity = entity.toDomainEntity()
val convertedEntity = entity.toArtistList()
val expectedArtists = (artistsA + artistsT).map { it.toDomainEntity() }.toMutableList()
with(convertedEntity) {
lastModified `should be equal to` entity.lastModified
ignoredArticles `should be equal to` entity.ignoredArticles
artists.size `should be equal to` expectedArtists.size
artists `should be equal to` expectedArtists
shortcuts `should be equal to` artistsA.map { it.toDomainEntity() }.toMutableList()
size `should be equal to` expectedArtists.size
this `should be equal to` expectedArtists
}
}
}