mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2026-10-22 23:42:14 +03:00
Merge branch 'bugfixes' into 'develop'
Bugfixes See merge request ultrasonic/ultrasonic!1447
This commit is contained in:
commit
17b86b218c
19 files changed
+471
-152
No files matched your search
@@ -7,10 +7,10 @@
|
||||
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.Room
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -40,8 +40,7 @@ import timber.log.Timber
|
||||
class ActiveServerProvider(private val repository: ServerSettingDao) :
|
||||
CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO) {
|
||||
|
||||
private var cachedDatabase: MetaDatabase? = null
|
||||
private var cachedServerId: Int? = null
|
||||
private val metadataDatabases = MetaDatabaseRegistry()
|
||||
|
||||
private val _accessState = MutableStateFlow(
|
||||
LibraryAccessState(Settings.activeServer, Settings.explicitOffline)
|
||||
@@ -111,6 +110,8 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
|
||||
// The server selected by the user; this is not the effective server while offline.
|
||||
fun getSelectedServerId(): Int = Settings.activeServer
|
||||
|
||||
suspend fun getServerById(serverId: Int): ServerSetting? = repository.findById(serverId)
|
||||
|
||||
/**
|
||||
* Queries if Scrobbling is enabled
|
||||
*/
|
||||
@@ -281,17 +282,15 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
|
||||
}
|
||||
|
||||
/** Clears a deleted selected server; unlike manual offline mode, there is nothing to restore. */
|
||||
fun clearSelectedServer() {
|
||||
launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val state = _accessState.value
|
||||
if (state.selectedServerId == OFFLINE_DB_ID && state.explicitOffline) {
|
||||
return@withContext
|
||||
}
|
||||
Settings.activeServer = OFFLINE_DB_ID
|
||||
Settings.explicitOffline = true
|
||||
publishResolvedAccessState(OFFLINE_DB_ID, explicitOffline = true, null)
|
||||
fun clearSelectedServer(): Job = launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
val state = _accessState.value
|
||||
if (state.selectedServerId == OFFLINE_DB_ID && state.explicitOffline) {
|
||||
return@withContext
|
||||
}
|
||||
Settings.activeServer = OFFLINE_DB_ID
|
||||
Settings.explicitOffline = true
|
||||
publishResolvedAccessState(OFFLINE_DB_ID, explicitOffline = true, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,66 +298,33 @@ class ActiveServerProvider(private val repository: ServerSettingDao) :
|
||||
fun getActiveMetaDatabase(): MetaDatabase {
|
||||
if (isOffline()) return offlineMetaDatabase
|
||||
|
||||
return getSelectedMetaDatabase()
|
||||
return getMetaDatabaseForServer(getSelectedServerId())
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for the server selected by the user, even if it is temporarily unreachable.
|
||||
* Background work that belongs to that server (for example a completed download) must use
|
||||
* this rather than the effective, offline database.
|
||||
*/
|
||||
/** Metadata storage owned by a server, independent of the current UI selection. */
|
||||
@Synchronized
|
||||
fun getSelectedMetaDatabase(): MetaDatabase {
|
||||
val selectedServer = getSelectedServerId()
|
||||
fun getMetaDatabaseForServer(serverId: Int): MetaDatabase =
|
||||
metadataDatabases.forServer(serverId)
|
||||
|
||||
if (selectedServer == cachedServerId && cachedDatabase != null) {
|
||||
return cachedDatabase!!
|
||||
}
|
||||
|
||||
if (selectedServer < 1) {
|
||||
return offlineMetaDatabase
|
||||
}
|
||||
|
||||
Timber.i("Switching to new database, id:$selectedServer")
|
||||
cachedServerId = selectedServer
|
||||
cachedDatabase = initDatabase(selectedServer)
|
||||
|
||||
return cachedDatabase!!
|
||||
}
|
||||
|
||||
val offlineMetaDatabase: MetaDatabase by lazy {
|
||||
initDatabase(0)
|
||||
}
|
||||
|
||||
private fun initDatabase(serverId: Int): MetaDatabase = Room.databaseBuilder(
|
||||
UApp.applicationContext(),
|
||||
MetaDatabase::class.java,
|
||||
METADATA_DB + serverId
|
||||
)
|
||||
.addMigrations(META_MIGRATION_2_3, META_MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigrationOnDowngrade(true)
|
||||
.build()
|
||||
val offlineMetaDatabase: MetaDatabase get() = metadataDatabases.forServer(0)
|
||||
|
||||
@Synchronized
|
||||
fun deleteMetaDatabase(id: Int) {
|
||||
if (cachedServerId == id) {
|
||||
cachedDatabase?.close()
|
||||
cachedDatabase = null
|
||||
cachedServerId = null
|
||||
}
|
||||
UApp.applicationContext().deleteDatabase(METADATA_DB + id)
|
||||
metadataDatabases.delete(id)
|
||||
Timber.i("Deleted metadataBase, id:$id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum Subsonic API version of the current server.
|
||||
*/
|
||||
fun setMinimumApiVersion(apiVersion: String) {
|
||||
fun setMinimumApiVersionForServer(serverId: Int, apiVersion: String) {
|
||||
launch {
|
||||
val server = _accessState.value.server ?: return@launch
|
||||
val server = repository.findById(serverId) ?: return@launch
|
||||
val updated = server.copy(minimumApiVersion = apiVersion)
|
||||
repository.update(updated)
|
||||
_accessState.value = _accessState.value.copy(server = updated)
|
||||
if (_accessState.value.selectedServerId == serverId && !_accessState.value.isOffline) {
|
||||
_accessState.value = _accessState.value.copy(server = updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.media3.datasource.HttpDataSource.HttpDataSourceException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InterruptedIOException
|
||||
import org.moire.ultrasonic.util.AbstractFile
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import timber.log.Timber
|
||||
@@ -45,9 +44,9 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
|
||||
private var responseByteStream: InputStream? = null
|
||||
private var openedFile = false
|
||||
private var cachePath: String? = null
|
||||
private var cacheFile: AbstractFile? = null
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
if (this.dataSpec != null) close()
|
||||
Timber.i(
|
||||
"CachedDatasource: Open: %s",
|
||||
dataSpec.toString()
|
||||
@@ -56,6 +55,9 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
|
||||
this.dataSpec = dataSpec
|
||||
bytesRead = 0
|
||||
bytesToRead = 0
|
||||
cachePath = null
|
||||
responseByteStream = null
|
||||
openedFile = false
|
||||
|
||||
val components = dataSpec.uri.toString().split('|')
|
||||
val path = components[2]
|
||||
@@ -171,13 +173,20 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
|
||||
|
||||
override fun close() {
|
||||
Timber.i("CachedDatasource: close %s", openedFile)
|
||||
if (openedFile) {
|
||||
openedFile = false
|
||||
transferEnded()
|
||||
responseByteStream?.close()
|
||||
try {
|
||||
if (openedFile) {
|
||||
openedFile = false
|
||||
transferEnded()
|
||||
responseByteStream?.close()
|
||||
} else if (dataSpec != null) {
|
||||
upstreamDataSource.close()
|
||||
}
|
||||
} finally {
|
||||
responseByteStream = null
|
||||
} else {
|
||||
upstreamDataSource.close()
|
||||
cachePath = null
|
||||
dataSpec = null
|
||||
bytesRead = 0
|
||||
bytesToRead = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,16 +204,14 @@ class CachedDataSource(private var upstreamDataSource: DataSource) : BaseDataSou
|
||||
|
||||
if (!found) return -1
|
||||
|
||||
val file = Storage.getFromPath(filePath) ?: return -1
|
||||
val length = file.getDocumentFileDescriptor("r")?.use { it.length } ?: return -1
|
||||
if (length <= 0) return -1
|
||||
|
||||
responseByteStream = file.getFileInputStream()
|
||||
cachePath = filePath
|
||||
openedFile = true
|
||||
|
||||
cacheFile = Storage.getFromPath(filePath)!!
|
||||
responseByteStream = cacheFile!!.getFileInputStream()
|
||||
|
||||
val descriptor = cacheFile!!.getDocumentFileDescriptor("r")
|
||||
val length = descriptor!!.length
|
||||
descriptor.close()
|
||||
|
||||
return length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import androidx.room.Room
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import timber.log.Timber
|
||||
|
||||
/** Owns one Room handle per server for the lifetime of the application process. */
|
||||
internal class MetaDatabaseRegistry(
|
||||
private val open: (Int) -> MetaDatabase = { serverId ->
|
||||
Room.databaseBuilder(
|
||||
UApp.applicationContext(),
|
||||
MetaDatabase::class.java,
|
||||
ActiveServerProvider.METADATA_DB + serverId
|
||||
)
|
||||
.addMigrations(META_MIGRATION_2_3, META_MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigrationOnDowngrade(true)
|
||||
.build()
|
||||
},
|
||||
private val deleteFile: (Int) -> Unit = { serverId ->
|
||||
UApp.applicationContext().deleteDatabase(ActiveServerProvider.METADATA_DB + serverId)
|
||||
}
|
||||
) {
|
||||
private val databases = mutableMapOf<Int, MetaDatabase>()
|
||||
|
||||
@Synchronized
|
||||
fun forServer(serverId: Int): MetaDatabase {
|
||||
val databaseId = serverId.coerceAtLeast(0)
|
||||
return databases.getOrPut(databaseId) {
|
||||
Timber.i("Opening metadata database, id:$databaseId")
|
||||
open(databaseId)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun delete(serverId: Int) {
|
||||
databases.remove(serverId)?.close()
|
||||
deleteFile(serverId)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ val appPermanentStorage = module {
|
||||
|
||||
single { get<AppDatabase>().serverSettingDao() }
|
||||
|
||||
viewModel { ServerSettingsModel(get(), get(), get()) }
|
||||
viewModel { ServerSettingsModel(get(), get(), get(), get()) }
|
||||
|
||||
viewModel { EditServerModel(get()) }
|
||||
viewModel { ChatViewModel() }
|
||||
|
||||
@@ -36,12 +36,9 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.R
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.model.EditServerModel
|
||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.UltrasonicBus
|
||||
import org.moire.ultrasonic.util.CommunicationError
|
||||
import org.moire.ultrasonic.util.ErrorDialog
|
||||
import org.moire.ultrasonic.util.InfoDialog
|
||||
@@ -57,8 +54,6 @@ private const val DIALOG_PADDING = 12
|
||||
class EditServerFragment : Fragment() {
|
||||
|
||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val musicServiceFactory: MusicServiceFactory by inject()
|
||||
private val communicationError: CommunicationError by inject()
|
||||
|
||||
private var currentServerSetting: ServerSetting? = null
|
||||
@@ -100,6 +95,7 @@ class EditServerFragment : Fragment() {
|
||||
savedInstanceState: Bundle?
|
||||
): View? = inflater.inflate(R.layout.server_edit, container, false)
|
||||
|
||||
@Suppress("LongMethod", "TooGenericExceptionCaught")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
@@ -124,32 +120,27 @@ class EditServerFragment : Fragment() {
|
||||
if (t != null) {
|
||||
currentServerSetting = t
|
||||
if (!isInstanceStateSaved) setFields()
|
||||
// Remove the minimum API version so it can be detected again
|
||||
if (currentServerSetting?.minimumApiVersion != null) {
|
||||
currentServerSetting!!.minimumApiVersion = null
|
||||
serverSettingsModel.updateItem(currentServerSetting)
|
||||
if (
|
||||
activeServerProvider.getActiveServer().id ==
|
||||
currentServerSetting!!.id
|
||||
) {
|
||||
musicServiceFactory.resetMusicService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveButton!!.setOnClickListener {
|
||||
if (currentServerSetting != null) {
|
||||
if (getFields()) {
|
||||
serverSettingsModel.updateItem(currentServerSetting)
|
||||
// Apply modifications if the current server was modified
|
||||
if (
|
||||
activeServerProvider.getActiveServer().id ==
|
||||
currentServerSetting!!.id
|
||||
) {
|
||||
musicServiceFactory.resetMusicService()
|
||||
UltrasonicBus.publishActiveServerChanged(currentServerSetting!!)
|
||||
val updated = currentServerSetting!!.copy(minimumApiVersion = null)
|
||||
saveButton!!.isEnabled = false
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
serverSettingsModel.updateItem(updated)
|
||||
findNavController().navigateUp()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (exception: Exception) {
|
||||
saveButton?.isEnabled = true
|
||||
ErrorDialog.Builder(requireContext())
|
||||
.setTitle(R.string.error_label)
|
||||
.setMessage(communicationError.getErrorMessage(exception))
|
||||
.show()
|
||||
}
|
||||
}
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ListView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.moire.ultrasonic.R
|
||||
@@ -16,6 +20,7 @@ import org.moire.ultrasonic.adapters.ServerRowAdapter
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||
import org.moire.ultrasonic.service.DownloadManager
|
||||
import org.moire.ultrasonic.util.ErrorDialog
|
||||
import org.moire.ultrasonic.util.UiUtil
|
||||
import timber.log.Timber
|
||||
@@ -28,6 +33,7 @@ class ServerSelectorFragment : Fragment() {
|
||||
private var listView: ListView? = null
|
||||
private val serverSettingsModel: ServerSettingsModel by viewModel()
|
||||
private val activeServerProvider: ActiveServerProvider by inject()
|
||||
private val downloadManager: DownloadManager by inject()
|
||||
private var serverRowAdapter: ServerRowAdapter? = null
|
||||
|
||||
@Override
|
||||
@@ -96,14 +102,15 @@ class ServerSelectorFragment : Fragment() {
|
||||
val activeServerId = activeServerProvider.getSelectedServerId()
|
||||
|
||||
// If the currently active server is deleted, go offline
|
||||
if (id == activeServerId) activeServerProvider.clearSelectedServer()
|
||||
|
||||
serverSettingsModel.deleteItemById(id)
|
||||
|
||||
// Clear the metadata cache
|
||||
activeServerProvider.deleteMetaDatabase(id)
|
||||
|
||||
Timber.i("Server deleted, id: $id")
|
||||
lifecycleScope.launch {
|
||||
if (id == activeServerId) activeServerProvider.clearSelectedServer().join()
|
||||
downloadManager.cancelForServer(id)
|
||||
serverSettingsModel.deleteItemById(id)
|
||||
withContext(Dispatchers.IO) {
|
||||
activeServerProvider.deleteMetaDatabase(id)
|
||||
}
|
||||
Timber.i("Server deleted, id: $id")
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.common_cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.data.ServerSettingDao
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.moire.ultrasonic.service.UltrasonicBus
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,7 @@ import timber.log.Timber
|
||||
class ServerSettingsModel(
|
||||
private val repository: ServerSettingDao,
|
||||
private val activeServerProvider: ActiveServerProvider,
|
||||
private val musicServiceFactory: MusicServiceFactory,
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
@@ -94,31 +97,33 @@ class ServerSettingsModel(
|
||||
/**
|
||||
* Removes a Setting from the database
|
||||
*/
|
||||
fun deleteItemById(id: Int) {
|
||||
suspend fun deleteItemById(id: Int) {
|
||||
if (id == OFFLINE_DB_ID) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val itemToBeDeleted = repository.findById(id)
|
||||
if (itemToBeDeleted != null) {
|
||||
repository.delete(itemToBeDeleted)
|
||||
Timber.d("deleteItem deleted id: $id")
|
||||
reindexSettings()
|
||||
activeServerProvider.invalidateCache()
|
||||
}
|
||||
val itemToBeDeleted = repository.findById(id)
|
||||
if (itemToBeDeleted != null) {
|
||||
repository.delete(itemToBeDeleted)
|
||||
Timber.d("deleteItem deleted id: $id")
|
||||
reindexSettings()
|
||||
activeServerProvider.invalidateCache()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a Setting in the database
|
||||
*/
|
||||
fun updateItem(serverSetting: ServerSetting?) {
|
||||
suspend fun updateItem(serverSetting: ServerSetting?) {
|
||||
if (serverSetting == null) return
|
||||
|
||||
appScope.launch {
|
||||
repository.update(serverSetting)
|
||||
activeServerProvider.invalidateCache()
|
||||
Timber.d("updateItem updated server setting: $serverSetting")
|
||||
repository.update(serverSetting)
|
||||
activeServerProvider.invalidateCache()
|
||||
if (activeServerProvider.getSelectedServerId() == serverSetting.id) {
|
||||
musicServiceFactory.resetMusicService()
|
||||
if (!activeServerProvider.isOffline()) {
|
||||
UltrasonicBus.publishActiveServerChanged(serverSetting)
|
||||
}
|
||||
}
|
||||
Timber.d("updateItem updated server setting: $serverSetting")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -234,16 +234,20 @@ class DownloadManager(
|
||||
|
||||
// Remove tracks which are currently downloading
|
||||
filteredTracks = filteredTracks.filter {
|
||||
!downloadQueue.any { i -> i.id == it.id } && !activeDownloads.containsKey(it.id)
|
||||
!downloadQueue.any { i -> i.id == it.id } &&
|
||||
!activeDownloads.containsKey(it.id)
|
||||
}
|
||||
|
||||
// The remaining tracks should be added to the download queue
|
||||
// By using the counter we ensure that the songs are added in the correct order
|
||||
var priority = 0
|
||||
val selectedServerId = activeServerProvider.getSelectedServerId()
|
||||
val tracksToDownload =
|
||||
filteredTracks.map {
|
||||
val sourceServerId = it.downloadSourceServerId(selectedServerId)
|
||||
DownloadableTrack(
|
||||
it,
|
||||
it.copy(serverId = sourceServerId),
|
||||
sourceServerId,
|
||||
save,
|
||||
0,
|
||||
if (isHighPriority) priority++ else backgroundPriorityCounter++
|
||||
@@ -356,6 +360,17 @@ class DownloadManager(
|
||||
failedList.clear()
|
||||
}
|
||||
|
||||
/** Finishes work for a server before its metadata database is closed and deleted. */
|
||||
suspend fun cancelForServer(serverId: Int) {
|
||||
val tasks = synchronized(this) {
|
||||
downloadQueue.removeIf { it.sourceServerId == serverId }
|
||||
failedList.entries.removeIf { it.value.sourceServerId == serverId }
|
||||
activeDownloads.values.filter { it.downloadTrack.sourceServerId == serverId }
|
||||
}
|
||||
tasks.forEach { it.cancelAndJoin() }
|
||||
updateLiveData()
|
||||
}
|
||||
|
||||
private fun delete(track: Track) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
deleteAsync(track)
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
@@ -41,11 +42,12 @@ class DownloadTask(
|
||||
val downloadTrack: DownloadableTrack,
|
||||
private val scope: CoroutineScope,
|
||||
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit,
|
||||
musicServiceFactory: MusicServiceFactory,
|
||||
private val musicServiceFactory: MusicServiceFactory,
|
||||
private val imageLoaderProvider: ImageLoaderProvider,
|
||||
private val activeServerProvider: ActiveServerProvider
|
||||
) {
|
||||
private val musicService = musicServiceFactory.getMusicService()
|
||||
private lateinit var serverService: MusicServiceFactory.ServerService
|
||||
private val musicService: MusicService get() = serverService.musicService
|
||||
|
||||
private var job: Job? = null
|
||||
private var inputStream: InputStream? = null
|
||||
@@ -219,6 +221,7 @@ class DownloadTask(
|
||||
fun start() {
|
||||
Timber.i("Launching new Job ${downloadTrack.pinnedFile}")
|
||||
job = scope.launch(exceptionHandler()) {
|
||||
serverService = musicServiceFactory.forServer(downloadTrack.sourceServerId)
|
||||
FileUtil.createDirectoryForParent(downloadTrack.pinnedFile)
|
||||
if (!checkIfExists() && isActive) {
|
||||
download()
|
||||
@@ -233,8 +236,14 @@ class DownloadTask(
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
suspend fun cancelAndJoin() {
|
||||
job?.cancelAndJoin()
|
||||
}
|
||||
|
||||
private fun Track.cacheMetadataAndArtwork() {
|
||||
val onlineDB = activeServerProvider.getSelectedMetaDatabase()
|
||||
val onlineDB = activeServerProvider.getMetaDatabaseForServer(
|
||||
downloadTrack.sourceServerId
|
||||
)
|
||||
val offlineDB = activeServerProvider.offlineMetaDatabase
|
||||
|
||||
var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId
|
||||
@@ -280,7 +289,7 @@ class DownloadTask(
|
||||
// Now cache the track data
|
||||
offlineDB.trackDao().insert(this)
|
||||
// Download the largest size that we can display in the UI
|
||||
imageLoaderProvider.executeOn { imageLoader ->
|
||||
imageLoaderProvider.executeOn(serverService.apiClient) { imageLoader ->
|
||||
imageLoader.downloadCoverArt(this@cacheMetadataAndArtwork)
|
||||
// Cache small copies of the Artist picture
|
||||
directArtist?.let { imageLoader.cacheArtistPicture(it) }
|
||||
@@ -296,8 +305,7 @@ class DownloadTask(
|
||||
}
|
||||
activeServerProvider.offlineMetaDatabase.localMediaCatalogDao().upsert(
|
||||
toLocalMediaCatalogEntry(
|
||||
sourceServerId = serverId.takeIf { it > 0 }
|
||||
?: activeServerProvider.getSelectedServerId(),
|
||||
sourceServerId = downloadTrack.sourceServerId,
|
||||
localPath = localPath,
|
||||
pinned = localPath == getPinnedFile()
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
||||
|
||||
class DownloadableTrack(
|
||||
val track: Track,
|
||||
val sourceServerId: Int,
|
||||
var pinned: Boolean,
|
||||
var tryCount: Int,
|
||||
var priority: Int
|
||||
@@ -28,3 +29,6 @@ class DownloadableTrack(
|
||||
override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack)
|
||||
fun compareTo(other: DownloadableTrack): Int = priority.compareTo(other.priority)
|
||||
}
|
||||
|
||||
fun Track.downloadSourceServerId(selectedServerId: Int): Int =
|
||||
serverId.takeIf { it > 0 } ?: selectedServerId
|
||||
@@ -25,6 +25,7 @@ import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions
|
||||
import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.LibraryAccessState
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.util.Constants
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -55,6 +56,17 @@ class MusicServiceFactory(
|
||||
}
|
||||
) {
|
||||
|
||||
data class ServerService(val musicService: MusicService, val apiClient: SubsonicAPIClient)
|
||||
|
||||
/** Builds a service whose configuration and entity IDs remain tied to one server. */
|
||||
suspend fun forServer(serverId: Int): ServerService {
|
||||
val server = requireNotNull(activeServerProvider.getServerById(serverId)) {
|
||||
"Server $serverId was deleted before its download started"
|
||||
}.copy()
|
||||
val client = SubsonicAPIClient(configurationFor(server), okLogger)
|
||||
return ServerService(RESTMusicService(client, activeServerProvider, server), client)
|
||||
}
|
||||
|
||||
// Identifies the access state the delegate was last built for. Only the transition
|
||||
// dimensions are compared: server property edits intentionally do not rebuild.
|
||||
private var loadedStateKey: Triple<Int, Boolean, LibraryAccessState.AutomaticOfflineReason?>? =
|
||||
@@ -107,20 +119,7 @@ class MusicServiceFactory(
|
||||
private fun buildMusicService() {
|
||||
Timber.i("Building the MusicService for the current access state")
|
||||
val server = activeServerProvider.getActiveServer()
|
||||
val configuration = SubsonicClientConfiguration(
|
||||
baseUrl = server.url,
|
||||
username = server.userName,
|
||||
password = server.password,
|
||||
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
|
||||
server.minimumApiVersion ?: Constants.REST_PROTOCOL_VERSION
|
||||
),
|
||||
clientID = Constants.REST_CLIENT_ID,
|
||||
allowSelfSignedCertificate = server.allowSelfSignedCertificate,
|
||||
forcePlainTextPassword = server.forcePlainTextPassword,
|
||||
debug = BuildConfig.DEBUG,
|
||||
isRealProtocolVersion = server.minimumApiVersion != null
|
||||
)
|
||||
val apiClient = SubsonicAPIClient(configuration, okLogger)
|
||||
val apiClient = SubsonicAPIClient(configurationFor(server), okLogger)
|
||||
currentApiClient = apiClient
|
||||
currentDelegate = if (activeServerProvider.isOffline()) {
|
||||
offlineServiceFactory()
|
||||
@@ -130,6 +129,20 @@ class MusicServiceFactory(
|
||||
loadedStateKey = accessStateKey
|
||||
}
|
||||
|
||||
private fun configurationFor(server: ServerSetting) = SubsonicClientConfiguration(
|
||||
baseUrl = server.url,
|
||||
username = server.userName,
|
||||
password = server.password,
|
||||
minimalProtocolVersion = SubsonicAPIVersions.getClosestKnownClientApiVersion(
|
||||
server.minimumApiVersion ?: Constants.REST_PROTOCOL_VERSION
|
||||
),
|
||||
clientID = Constants.REST_CLIENT_ID,
|
||||
allowSelfSignedCertificate = server.allowSelfSignedCertificate,
|
||||
forcePlainTextPassword = server.forcePlainTextPassword,
|
||||
debug = BuildConfig.DEBUG,
|
||||
isRealProtocolVersion = server.minimumApiVersion != null
|
||||
)
|
||||
|
||||
private val accessStateKey: Triple<Int, Boolean, LibraryAccessState.AutomaticOfflineReason?>
|
||||
get() {
|
||||
val state = activeServerProvider.getLibraryAccessState()
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.domain.Album
|
||||
import org.moire.ultrasonic.domain.Artist
|
||||
import org.moire.ultrasonic.domain.Bookmark
|
||||
@@ -43,6 +44,7 @@ import org.moire.ultrasonic.domain.toIndexList
|
||||
import org.moire.ultrasonic.domain.toMusicDirectoryDomainEntity
|
||||
import org.moire.ultrasonic.domain.toTrackEntity
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Settings
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
@@ -51,9 +53,12 @@ import timber.log.Timber
|
||||
@Suppress("LargeClass")
|
||||
open class RESTMusicService(
|
||||
private val subsonicAPIClient: SubsonicAPIClient,
|
||||
private val activeServerProvider: ActiveServerProvider
|
||||
private val activeServerProvider: ActiveServerProvider,
|
||||
private val boundServer: ServerSetting? = null
|
||||
) : MusicService {
|
||||
|
||||
private val server: ServerSetting get() = boundServer ?: activeServerProvider.getActiveServer()
|
||||
|
||||
// Shortcut to the API
|
||||
@Suppress("VariableNaming", "PropertyName")
|
||||
val API = subsonicAPIClient.api
|
||||
@@ -85,7 +90,7 @@ open class RESTMusicService(
|
||||
val response = API.getIndexes(musicFolderId, null).execute().throwOnFailure()
|
||||
|
||||
return response.body()!!.indexes.toIndexList(
|
||||
activeServerProvider.getSelectedServerId(),
|
||||
activeServerId,
|
||||
musicFolderId
|
||||
)
|
||||
}
|
||||
@@ -142,7 +147,9 @@ open class RESTMusicService(
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun search(criteria: SearchCriteria): SearchResult = try {
|
||||
if (activeServerProvider.shouldUseId3Tags()) {
|
||||
if (boundServer?.let { Settings.id3TagsEnabledOnline }
|
||||
?: activeServerProvider.shouldUseId3Tags()
|
||||
) {
|
||||
search3(criteria)
|
||||
} else {
|
||||
search2(criteria)
|
||||
@@ -212,7 +219,7 @@ open class RESTMusicService(
|
||||
@Throws(IOException::class)
|
||||
private fun savePlaylist(name: String, playlist: MusicDirectory) {
|
||||
val playlistFile = FileUtil.getPlaylistFile(
|
||||
activeServerProvider.getActiveServer().name,
|
||||
server.name,
|
||||
name
|
||||
)
|
||||
|
||||
@@ -443,7 +450,7 @@ open class RESTMusicService(
|
||||
}
|
||||
|
||||
override fun isJukeboxAvailable(): Boolean {
|
||||
val username = activeServerProvider.getActiveServer().userName
|
||||
val username = server.userName
|
||||
return getUser(username).jukeboxRole
|
||||
}
|
||||
|
||||
@@ -596,14 +603,14 @@ open class RESTMusicService(
|
||||
}
|
||||
|
||||
private val activeServerId: Int
|
||||
get() = activeServerProvider.getSelectedServerId()
|
||||
get() = boundServer?.id ?: activeServerProvider.getSelectedServerId()
|
||||
|
||||
init {
|
||||
// The client will notice if the minimum supported API version has changed
|
||||
// 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.restApiVersion)
|
||||
activeServerProvider.setMinimumApiVersionForServer(activeServerId, it.restApiVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,14 @@ class ImageLoaderProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs download artwork work with the API client captured by its server task. */
|
||||
fun executeOn(client: SubsonicAPIClient, cb: (iL: ImageLoader) -> Unit) {
|
||||
launch(CoroutinePatterns.loggingExceptionHandler) {
|
||||
val loader = ImageLoader(UApp.applicationContext(), client, config)
|
||||
withContext(Dispatchers.Main) { cb(loader) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val config by lazy {
|
||||
var defaultSize = 0
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.util.FileUtil
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class CachedDataSourceTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val realContext = ApplicationProvider.getApplicationContext<Context>()
|
||||
context = mock()
|
||||
whenever(context.getString(any())).thenAnswer { it.arguments[0].toString() }
|
||||
whenever(context.getSharedPreferences(any(), any())).thenAnswer {
|
||||
realContext.getSharedPreferences(it.arguments[0] as String, it.arguments[1] as Int)
|
||||
}
|
||||
whenever(context.getExternalFilesDir(anyOrNull()))
|
||||
.thenReturn(File(realContext.cacheDir, "media").apply { mkdirs() })
|
||||
whenever(context.contentResolver).thenReturn(realContext.contentResolver)
|
||||
val app: UApp = mock()
|
||||
whenever(app.applicationContext).thenReturn(context)
|
||||
UApp.instance = app
|
||||
FileUtil.cachedUltrasonicDirectory = null
|
||||
Storage.reset()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
UApp.instance = null
|
||||
Storage.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache hit followed by miss reads from upstream`() {
|
||||
val directory = ApplicationProvider.getApplicationContext<Context>().cacheDir
|
||||
val cached = File(directory, "cached-song.mp3").apply {
|
||||
writeBytes(byteArrayOf(1, 2))
|
||||
}
|
||||
val missing = File(directory, "missing-song.mp3")
|
||||
missing.delete()
|
||||
val upstream: DataSource = mock()
|
||||
whenever(upstream.open(any())).thenReturn(3)
|
||||
whenever(upstream.read(any(), eq(0), eq(3))).thenReturn(3)
|
||||
val source = CachedDataSource(upstream)
|
||||
|
||||
source.open(DataSpec(Uri.parse("1|0|${cached.absolutePath}")))
|
||||
source.read(ByteArray(2), 0, 2) shouldBeEqualTo 2
|
||||
source.close()
|
||||
|
||||
source.open(DataSpec(Uri.parse("2|0|${missing.absolutePath}")))
|
||||
source.read(ByteArray(3), 0, 3) shouldBeEqualTo 3
|
||||
verify(upstream).read(any(), eq(0), eq(3))
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.moire.ultrasonic.data
|
||||
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
class MetaDatabaseRegistryTest {
|
||||
@Test
|
||||
fun `server switch reuses handles and deletion closes only its server`() {
|
||||
val first: MetaDatabase = mock()
|
||||
val second: MetaDatabase = mock()
|
||||
val opened = mutableListOf<Int>()
|
||||
val deleted = mutableListOf<Int>()
|
||||
val registry = MetaDatabaseRegistry(
|
||||
open = { id ->
|
||||
opened += id
|
||||
if (id == 1) first else second
|
||||
},
|
||||
deleteFile = { deleted += it }
|
||||
)
|
||||
|
||||
registry.forServer(1) shouldBeEqualTo first
|
||||
registry.forServer(2) shouldBeEqualTo second
|
||||
registry.forServer(1) shouldBeEqualTo first
|
||||
opened shouldBeEqualTo listOf(1, 2)
|
||||
|
||||
registry.delete(1)
|
||||
|
||||
verify(first).close()
|
||||
deleted shouldBeEqualTo listOf(1)
|
||||
registry.forServer(2) shouldBeEqualTo second
|
||||
opened shouldBeEqualTo listOf(1, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.moire.ultrasonic.model
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.inOrder
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.moire.ultrasonic.app.UApp
|
||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.moire.ultrasonic.data.ServerSettingDao
|
||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class ServerSettingsModelTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
val appContext: Application = mock()
|
||||
whenever(appContext.getString(any())).thenAnswer { it.arguments[0].toString() }
|
||||
whenever(appContext.applicationContext).thenReturn(appContext)
|
||||
val app: UApp = mock()
|
||||
whenever(app.applicationContext).thenReturn(appContext)
|
||||
UApp.instance = app
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
UApp.instance = null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active server client is invalidated after settings and state are updated`() = runBlocking {
|
||||
val repository: ServerSettingDao = mock()
|
||||
val provider: ActiveServerProvider = mock()
|
||||
val factory: MusicServiceFactory = mock()
|
||||
val server = ServerSetting().apply { id = 7 }
|
||||
whenever(provider.getSelectedServerId()).thenReturn(7)
|
||||
whenever(provider.isOffline()).thenReturn(true)
|
||||
val model = ServerSettingsModel(
|
||||
repository,
|
||||
provider,
|
||||
factory,
|
||||
ApplicationProvider.getApplicationContext<Application>()
|
||||
)
|
||||
|
||||
model.updateItem(server)
|
||||
|
||||
val order = inOrder(repository, provider, factory)
|
||||
order.verify(repository).update(server)
|
||||
order.verify(provider).invalidateCache()
|
||||
order.verify(factory).resetMusicService()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||
import org.moire.ultrasonic.util.Storage
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowEnvironment
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ import org.robolectric.shadows.ShadowEnvironment
|
||||
* download tasks are started against the mocked MusicServiceFactory.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class DownloadManagerTest {
|
||||
|
||||
private class FakeHost : DownloadManager.Host {
|
||||
@@ -147,6 +149,29 @@ class DownloadManagerTest {
|
||||
manager.inFlightDownloads().size shouldBeEqualTo 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued item keeps its source after selected server changes`() {
|
||||
runBlocking {
|
||||
manager.downloadAsync(listOf(track("shared")))
|
||||
}
|
||||
whenever(activeServerProvider.getSelectedServerId()).thenReturn(2)
|
||||
|
||||
manager.inFlightDownloads().single().serverId shouldBeEqualTo 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting one server removes only its queued downloads`() {
|
||||
runBlocking {
|
||||
val first = track("first").copy(serverId = 1)
|
||||
val second = track("second").copy(serverId = 2)
|
||||
manager.downloadAsync(listOf(first, second))
|
||||
|
||||
manager.cancelForServer(1)
|
||||
|
||||
manager.inFlightDownloads().map { it.serverId } shouldBeEqualTo listOf(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already downloaded tracks are skipped instead of queued`() {
|
||||
runBlocking {
|
||||
@@ -193,7 +218,7 @@ class DownloadManagerTest {
|
||||
|
||||
@Test
|
||||
fun `retrying requeues the track and increments the try count`() {
|
||||
val item = DownloadableTrack(track("5"), false, 0, 0)
|
||||
val item = DownloadableTrack(track("5"), 1, false, 0, 0)
|
||||
|
||||
manager.downloadStateChangedCallback(item, DownloadState.RETRYING, null)
|
||||
|
||||
@@ -203,7 +228,7 @@ class DownloadManagerTest {
|
||||
|
||||
@Test
|
||||
fun `failed downloads are remembered and removed by delete`() {
|
||||
val item = DownloadableTrack(track("6"), false, 0, 0)
|
||||
val item = DownloadableTrack(track("6"), 1, false, 0, 0)
|
||||
manager.downloadStateChangedCallback(item, DownloadState.FAILED, null)
|
||||
manager.getDownloadState(track("6")) shouldBeEqualTo DownloadState.FAILED
|
||||
|
||||
@@ -219,7 +244,7 @@ class DownloadManagerTest {
|
||||
manager.attachService(host)
|
||||
manager.detachService()
|
||||
|
||||
val item = DownloadableTrack(track("9"), false, 0, 0)
|
||||
val item = DownloadableTrack(track("9"), 1, false, 0, 0)
|
||||
manager.downloadStateChangedCallback(item, DownloadState.FAILED, null)
|
||||
|
||||
manager.getDownloadState(track("9")) shouldBeEqualTo DownloadState.FAILED
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.moire.ultrasonic.service
|
||||
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import org.moire.ultrasonic.domain.Track
|
||||
|
||||
class DownloadSourceTest {
|
||||
@Test
|
||||
fun `track keeps its source server when selection changes`() {
|
||||
val track = Track(id = "same-id", serverId = 7)
|
||||
|
||||
track.downloadSourceServerId(selectedServerId = 9) shouldBeEqualTo 7
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy track captures selected server at enqueue`() {
|
||||
val track = Track(id = "same-id")
|
||||
|
||||
track.downloadSourceServerId(selectedServerId = 7) shouldBeEqualTo 7
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package org.moire.ultrasonic.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.amshove.kluent.shouldNotBeEqualTo
|
||||
@@ -27,6 +28,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider
|
||||
import org.moire.ultrasonic.data.LibraryAccessState
|
||||
import org.moire.ultrasonic.data.ServerSetting
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Verifies the switch logic of the long-lived [MusicServiceFactory]: the delegate is
|
||||
@@ -37,6 +39,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
* object, which reads Android settings and resources.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class MusicServiceFactoryTest {
|
||||
|
||||
companion object {
|
||||
@@ -178,6 +181,32 @@ class MusicServiceFactoryTest {
|
||||
builtDelegates.size shouldBeEqualTo 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server bound service uses its own server after selection changes`() {
|
||||
runBlocking {
|
||||
stubState(selectedServerId = 9, serverUrl = "https://selected.example.com")
|
||||
val source = ServerSetting(
|
||||
id = 7,
|
||||
index = 1,
|
||||
name = "source",
|
||||
url = "https://source.example.com",
|
||||
userName = "source-user",
|
||||
password = "pass",
|
||||
jukeboxByDefault = false,
|
||||
allowSelfSignedCertificate = false,
|
||||
forcePlainTextPassword = false,
|
||||
musicFolderId = "",
|
||||
minimumApiVersion = null
|
||||
)
|
||||
whenever(activeServerProvider.getServerById(7)).thenReturn(source)
|
||||
|
||||
val service = factory.forServer(7).musicService
|
||||
|
||||
service.getStreamUrl("song", null, null)
|
||||
.orEmpty().startsWith("https://source.example.com/") shouldBeEqualTo true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `silent refresh keeps the delegate but resets nothing`() {
|
||||
stubState()
|
||||
|
||||
Reference in new issue
Block a user