di: replace KoinComponent service-locator sprawl with constructor injection

Move ~20 classes from KoinComponent/'by inject()' lookup to constructor
injection, registered in the Koin modules; the dependency graph is now
visible in constructors and these classes are testable without Koin.

- util: CacheCleaner, CommunicationError, DownloadUtil, ContextMenuUtil
  are now plain classes provided as singles.
- adapters: Utils object becomes PopupMenuFactory; TrackViewBinder,
  HeaderViewBinder, ArtistRowBinder, TextRowBinder, ShareRowBinder,
  AlbumRowDelegate/AlbumGridDelegate receive their dependencies (incl.
  PopupMenuFactory) via constructor; FolderSelectorBinder drops a dead
  KoinComponent.
- service: MusicServiceFactory and ImageLoaderProvider receive the Koin
  instance explicitly (they must resolve from the dynamically reloaded
  music service module); PlaybackStateSerializer, DownloadTask,
  MediaLibrarySessionCallback, CachedMusicService and OfflineMusicService
  take their dependencies via constructor.
- subsonic: VideoPlayer becomes an injected class; AvatarFetcher and
  CoverArtFetcher take the SubsonicAPIClient, their Coil factories accept
  a lazy clientProvider so the client stays current across server
  switches.
- models: EditServerModel, GenericListModel and all subclasses get
  constructor injection and are wired in the viewModel registrations.
- imageloader: ArtworkBitmapLoader takes the ImageLoaderProvider.
- view: ChatAdapter takes activeServerProvider/imageLoaderProvider.

Service-locator access is confined to Android framework entry points
(UApp, DownloadService, PlaybackService, UltrasonicIntentReceiver,
AlbumArtContentProvider) and the shared Fragment error-handling
extensions; redundant KoinComponent/KoinScopeComponent declarations were
removed from fragments.

Tests: CommunicationErrorReportingTest, AvatarFetcherTest,
CoverArtFetcherTest and OfflineMusicServiceCatalogTest inject mocks
directly instead of booting Koin.
This commit is contained in:
birdbird committed 2026-09-30 09:58:58 +02:00
1 parent caa45c2243
commit fbe409b47c
62 files changed
+462 -395

No files matched your search

@@ -31,7 +31,8 @@ import org.moire.ultrasonic.util.LayoutType
open class AlbumRowDelegate(
open val onItemClick: (Album) -> Unit,
open val onContextMenuClick: (MenuItem, Album) -> Boolean,
private val imageLoaderProvider: ImageLoaderProvider
private val imageLoaderProvider: ImageLoaderProvider,
private val popupMenuFactory: PopupMenuFactory
) : ItemViewDelegate<Album, AlbumRowDelegate.ListViewHolder>() {
private val starDrawable: Int = R.drawable.rating_star_full
@@ -45,7 +46,7 @@ open class AlbumRowDelegate(
holder.details.setOnClickListener { onItemClick(item) }
holder.coverArt.setOnClickListener { onItemClick(item) }
holder.details.setOnLongClickListener {
val popup = Utils.createPopupMenu(holder.itemView)
val popup = popupMenuFactory.createPopupMenu(holder.itemView)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, item)
@@ -135,7 +136,8 @@ open class AlbumRowDelegate(
class AlbumGridDelegate(
onItemClick: (Album) -> Unit,
onContextMenuClick: (MenuItem, Album) -> Boolean,
imageLoaderProvider: ImageLoaderProvider
) : AlbumRowDelegate(onItemClick, onContextMenuClick, imageLoaderProvider) {
imageLoaderProvider: ImageLoaderProvider,
popupMenuFactory: PopupMenuFactory
) : AlbumRowDelegate(onItemClick, onContextMenuClick, imageLoaderProvider, popupMenuFactory) {
override var layoutType = LayoutType.COVER
}
@@ -34,9 +34,10 @@ class ArtistRowBinder(
val onContextMenuClick: (MenuItem, ArtistOrIndex) -> Boolean,
private val enableSections: Boolean = true,
private val imageLoaderProvider: ImageLoaderProvider,
private val activeServerProvider: ActiveServerProvider
private val activeServerProvider: ActiveServerProvider,
private val popupMenuFactory: PopupMenuFactory
) : ItemViewBinder<ArtistOrIndex, ArtistRowBinder.ViewHolder>(),
Utils.SectionedBinder {
SectionedBinder {
val layout = R.layout.list_item_artist
val contextMenuLayout = R.menu.context_menu_artist
@@ -47,7 +48,7 @@ class ArtistRowBinder(
holder.section.isVisible = enableSections
holder.layout.setOnClickListener { onItemClick(item) }
holder.layout.setOnLongClickListener {
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
val popup = popupMenuFactory.createPopupMenu(holder.itemView, contextMenuLayout)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick(menuItem, item)
@@ -222,7 +222,7 @@ class BaseAdapter<T : Identifiable>(allowDuplicateEntries: Boolean = false) :
val type = getItemViewType(position)
val binder = types.getType<Any>(type).delegate
if (binder is Utils.SectionedBinder) {
if (binder is SectionedBinder) {
return binder.getSectionName(items[position] as Identifiable)
}
@@ -11,7 +11,6 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import java.lang.ref.WeakReference
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicFolder
@@ -23,8 +22,7 @@ import org.moire.ultrasonic.service.UltrasonicBus
* select one. The intended usage is to supply a filter to lists of artists, albums, etc
*/
class FolderSelectorBinder(context: Context) :
ItemViewBinder<FolderSelectorBinder.FolderHeader, FolderSelectorBinder.ViewHolder>(),
KoinComponent {
ItemViewBinder<FolderSelectorBinder.FolderHeader, FolderSelectorBinder.ViewHolder>() {
private val weakContext: WeakReference<Context> = WeakReference(context)
@@ -11,8 +11,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.drakeet.multitype.ItemViewBinder
import java.lang.ref.WeakReference
import java.util.Random
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Util
@@ -20,13 +18,11 @@ import org.moire.ultrasonic.util.Util
/**
* This Binder can bind a list of entries into a Header
*/
class HeaderViewBinder(context: Context) :
ItemViewBinder<AlbumHeader, HeaderViewBinder.ViewHolder>(),
KoinComponent {
class HeaderViewBinder(context: Context, private val imageLoaderProvider: ImageLoaderProvider) :
ItemViewBinder<AlbumHeader, HeaderViewBinder.ViewHolder>() {
private val weakContext: WeakReference<Context> = WeakReference(context)
private val random: Random = Random()
private val imageLoaderProvider: ImageLoaderProvider by inject()
// Set our layout files
val layout = R.layout.list_header_album
@@ -22,7 +22,8 @@ import org.moire.ultrasonic.domain.Share
*/
class ShareRowBinder(
private val onItemClick: (Share) -> Unit,
private val onContextMenuItemClick: (MenuItem, Share) -> Boolean
private val onContextMenuItemClick: (MenuItem, Share) -> Boolean,
private val popupMenuFactory: PopupMenuFactory
) : ItemViewBinder<Share, ShareRowBinder.ViewHolder>() {
override fun onBindViewHolder(holder: ViewHolder, item: Share) {
@@ -30,7 +31,7 @@ class ShareRowBinder(
holder.description.text = item.description
holder.itemView.setOnClickListener { onItemClick(item) }
holder.itemView.setOnLongClickListener { view ->
val popup = Utils.createPopupMenu(view, R.menu.select_share_context)
val popup = popupMenuFactory.createPopupMenu(view, R.menu.select_share_context)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuItemClick(menuItem, item)
@@ -25,6 +25,7 @@ class TextRowBinder<T : GenericEntry>(
private val textResolver: (T) -> String?,
private val onItemClick: (T) -> Unit,
private val onContextMenuItemClick: (MenuItem, T) -> Boolean,
private val popupMenuFactory: PopupMenuFactory,
private val contextMenuLayout: (T) -> Int? = { null }
) : ItemViewBinder<T, TextRowBinder.ViewHolder>() {
@@ -34,7 +35,7 @@ class TextRowBinder<T : GenericEntry>(
holder.itemView.setOnLongClickListener { view ->
val menuLayout = contextMenuLayout(item) ?: return@setOnLongClickListener false
val popup = Utils.createPopupMenu(view, menuLayout)
val popup = popupMenuFactory.createPopupMenu(view, menuLayout)
popup.setOnMenuItemClickListener { menuItem ->
onContextMenuItemClick(menuItem, item)
}
@@ -9,8 +9,6 @@ import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.lifecycle.LifecycleOwner
import com.drakeet.multitype.ItemViewBinder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
@@ -24,23 +22,20 @@ class TrackViewBinder(
val checkable: Boolean,
val draggable: Boolean,
val lifecycleOwner: LifecycleOwner,
val popupMenuFactory: PopupMenuFactory,
val playback: PlaybackRepository,
val activeServerProvider: ActiveServerProvider,
val showPlayingInPlayOrder: Boolean = false,
val createContextMenu: (View, Track) -> PopupMenu = { view, _ ->
Utils.createPopupMenu(
popupMenuFactory.createPopupMenu(
view,
R.menu.context_menu_track
)
}
) : ItemViewBinder<Identifiable, TrackViewHolder>(),
KoinComponent {
) : ItemViewBinder<Identifiable, TrackViewHolder>() {
var startDrag: ((TrackViewHolder) -> Unit)? = null
// Resolved once per binder (which lives as long as the fragment) instead of
// once per recycled ViewHolder.
private val playback: PlaybackRepository by inject()
private val activeServerProvider: ActiveServerProvider by inject()
// Set our layout files
val layout = R.layout.list_item_track
@@ -3,17 +3,16 @@ package org.moire.ultrasonic.adapters
import android.view.MenuInflater
import android.view.View
import android.widget.PopupMenu
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
object Utils : KoinComponent {
/**
* Creates the context menus shown on long-press in the list views. Hides the
* download and share actions while the app is in offline mode.
*/
class PopupMenuFactory(private val activeServerProvider: ActiveServerProvider) {
private val activeServerProvider: ActiveServerProvider by inject()
@JvmStatic
fun createPopupMenu(view: View, layout: Int = R.menu.context_menu_artist): PopupMenu {
val popup = PopupMenu(view.context, view)
val inflater: MenuInflater = popup.menuInflater
@@ -31,8 +30,8 @@ object Utils : KoinComponent {
popup.show()
return popup
}
}
interface SectionedBinder {
fun getSectionName(item: Identifiable): String
}
interface SectionedBinder {
fun getSectionName(item: Identifiable): String
}
@@ -16,6 +16,8 @@ import coil3.request.crossfade
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.moire.ultrasonic.BuildConfig
@@ -44,7 +46,8 @@ import timber.log.Timber.DebugTree
class UApp :
Application(),
SingletonImageLoader.Factory {
SingletonImageLoader.Factory,
KoinComponent {
private var ioScope = CoroutinePatterns.loggingScope(Dispatchers.IO)
@@ -90,9 +93,11 @@ class UApp :
override fun newImageLoader(context: Context): ImageLoader = ImageLoader.Builder(context)
.components {
add(CoverArtFetcher.Factory())
// The client is resolved lazily when a fetcher is created: the SubsonicAPIClient
// is regenerated whenever the active server changes.
add(CoverArtFetcher.Factory { get() })
add(CoverArtKeyer())
add(AvatarFetcher.Factory())
add(AvatarFetcher.Factory { get() })
add(AvatarKeyer())
}
.memoryCache(
@@ -147,10 +152,9 @@ class UApp :
companion object {
var instance: UApp? = null
fun applicationContext(): Context =
requireNotNull(instance) {
"UApp.instance accessed before Application.onCreate"
}.applicationContext
fun applicationContext(): Context = requireNotNull(instance) {
"UApp.instance accessed before Application.onCreate"
}.applicationContext
}
}
@@ -28,6 +28,7 @@ import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.model.ServerSettingsModel
import org.moire.ultrasonic.model.ShareListModel
import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Settings
const val SP_NAME = "Default_SP"
@@ -64,13 +65,13 @@ val appPermanentStorage = module {
viewModel { EditServerModel(get()) }
viewModel { ChatViewModel() }
viewModel { AlbumListModel(get()) }
viewModel { ArtistListModel(get()) }
viewModel { GenreListModel(get()) }
viewModel { PlaylistListModel(get()) }
viewModel { PodcastListModel(get()) }
viewModel { SearchListModel(get()) }
viewModel { ShareListModel(get()) }
viewModel { TrackCollectionModel(get()) }
viewModel { DownloadListModel(get()) }
viewModel { AlbumListModel(get(), get(), get(), get()) }
viewModel { ArtistListModel(get(), get(), get(), get()) }
viewModel { GenreListModel(get(), get(), get(), get()) }
viewModel { PlaylistListModel(get(), get(), get(), get(), get()) }
viewModel { PodcastListModel(get(), get(), get(), get()) }
viewModel { SearchListModel(get(), get(), get(), get()) }
viewModel { ShareListModel(get(), get(), get(), get()) }
viewModel { TrackCollectionModel(get(), get(), get(), get()) }
viewModel { DownloadListModel(get(), get(), get(), get()) }
}
@@ -1,10 +1,16 @@
package org.moire.ultrasonic.di
import org.koin.core.Koin
import org.koin.dsl.module
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.subsonic.VideoPlayer
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.ContextMenuUtil
import org.moire.ultrasonic.util.DownloadUtil
/**
* This Koin module contains the registration of general classes needed for Ultrasonic
@@ -14,8 +20,18 @@ val applicationModule = module {
// Must NOT live in musicServiceModule: that module is unloaded/reloaded by the
// factory itself whenever the library access state changes.
single { MusicServiceFactory(get()) }
single { MusicServiceFactory(get(), getKoin()) }
single { ImageLoaderProvider() }
single { CacheCleaner() }
// The ImageLoaderProvider resolves the SubsonicAPIClient and the ServerID from the
// music service module at request time, because that module is regenerated whenever
// the active server changes. It therefore receives the Koin instance itself instead
// of a fixed snapshot of its definitions.
single { ImageLoaderProvider(getKoin()) }
single { PopupMenuFactory(get()) }
single { CacheCleaner(get(), get()) }
single { CommunicationError(get()) }
single { DownloadUtil(get(), get(), get()) }
single { ContextMenuUtil(get(), get(), get()) }
single { VideoPlayer(get()) }
}
@@ -1,5 +1,7 @@
package org.moire.ultrasonic.di
import android.content.Context
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import org.moire.ultrasonic.model.NowPlayingViewModel
@@ -21,7 +23,7 @@ import org.moire.ultrasonic.subsonic.ShareHandler
val mediaPlayerModule = module {
// These are dependency-free
single { PlaybackStateSerializer() }
single { PlaybackStateSerializer(androidContext()) }
single { ExternalStorageMonitor() }
single { NetworkAndStorageChecker(get()) }
single { ShareHandler(get()) }
@@ -59,10 +59,10 @@ val musicServiceModule = module {
single { SubsonicAPIClient(get(), get()) }
single<MusicService>(named(ONLINE_MUSIC_SERVICE)) {
CachedMusicService(RESTMusicService(get(), get()))
CachedMusicService(RESTMusicService(get(), get()), get())
}
single<MusicService>(named(OFFLINE_MUSIC_SERVICE)) {
OfflineMusicService()
OfflineMusicService(get())
}
}
@@ -19,11 +19,13 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumGridDelegate
import org.moire.ultrasonic.adapters.AlbumRowDelegate
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Album
@@ -44,6 +46,8 @@ class AlbumListFragment(
) : EntryListFragment<Album>(),
FilterableFragment {
private val popupMenuFactory: PopupMenuFactory by inject()
private var filterButtonBar: FilterButtonBar? = null
/**
@@ -283,12 +287,14 @@ class AlbumListFragment(
AlbumRowDelegate(
::onItemClick,
::onContextMenuItemSelected,
imageLoaderProvider = imageLoaderProvider
imageLoaderProvider = imageLoaderProvider,
popupMenuFactory = popupMenuFactory
),
AlbumGridDelegate(
::onItemClick,
::onContextMenuItemSelected,
imageLoaderProvider
imageLoaderProvider,
popupMenuFactory
)
).withKotlinClassLinker { _, _ ->
when (layoutType) {
@@ -12,10 +12,12 @@ import android.view.View
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.domain.Index
@@ -26,6 +28,8 @@ import org.moire.ultrasonic.model.ArtistListModel
*/
class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
private val popupMenuFactory: PopupMenuFactory by inject()
/**
* The ViewModel to use to get the data
*/
@@ -53,7 +57,8 @@ class ArtistListFragment : EntryListFragment<ArtistOrIndex>() {
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider = imageLoaderProvider,
activeServerProvider = activeServerProvider
activeServerProvider = activeServerProvider,
popupMenuFactory = popupMenuFactory
)
)
}
@@ -35,14 +35,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
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.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
import org.moire.ultrasonic.model.ChatViewModel
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.RefreshableFragment
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.applyTheme
@@ -52,7 +52,6 @@ import org.moire.ultrasonic.view.ChatAdapter
class ChatFragment :
Fragment(),
KoinComponent,
RefreshableFragment {
private lateinit var chatListView: ListView
private lateinit var messageEditText: EditText
@@ -60,6 +59,7 @@ class ChatFragment :
override var swipeRefresh: SwipeRefreshLayout? = null
private val activeServerProvider: ActiveServerProvider by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val chatViewModel: ChatViewModel by viewModel()
@@ -134,7 +134,12 @@ class ChatFragment :
private fun observeChatMessages() {
chatViewModel.chatMessages.observe(viewLifecycleOwner) { messages ->
if (!messages.isNullOrEmpty()) {
chatListView.adapter = ChatAdapter(requireContext(), messages)
chatListView.adapter = ChatAdapter(
requireContext(),
messages,
activeServerProvider,
imageLoaderProvider
)
}
}
}
@@ -13,13 +13,19 @@ import android.view.MenuItem
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.PlaybackRepository
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Util
/**
@@ -33,6 +39,9 @@ import org.moire.ultrasonic.util.Util
*/
class DownloadsFragment : MultiListFragment<Track>() {
private val playback: PlaybackRepository by inject()
private val popupMenuFactory: PopupMenuFactory by inject()
/**
* The ViewModel to use to get the data
*/
@@ -57,7 +66,10 @@ class DownloadsFragment : MultiListFragment<Track>() {
{ _, _ -> true },
checkable = false,
draggable = false,
lifecycleOwner = viewLifecycleOwner
lifecycleOwner = viewLifecycleOwner,
popupMenuFactory = popupMenuFactory,
playback = playback,
activeServerProvider = activeServerProvider
)
)
@@ -79,6 +91,11 @@ class DownloadsFragment : MultiListFragment<Track>() {
}
}
class DownloadListModel(application: Application) : GenericListModel(application) {
class DownloadListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
fun getList(): LiveData<List<Track>> = DownloadService.observableDownloads
}
@@ -42,7 +42,7 @@ 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.getErrorMessage
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.ErrorDialog
import org.moire.ultrasonic.util.InfoDialog
import org.moire.ultrasonic.util.ServerColor
@@ -59,6 +59,7 @@ 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
@@ -432,7 +433,7 @@ class EditServerFragment : Fragment() {
Timber.w(exception)
ErrorDialog.Builder(requireContext())
.setTitle(R.string.error_label)
.setMessage(getErrorMessage(exception))
.setMessage(communicationError.getErrorMessage(exception))
.show()
}
}
@@ -14,23 +14,23 @@ import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.koin.core.component.KoinScopeComponent
import org.koin.android.ext.android.inject
import org.moire.ultrasonic.adapters.FolderSelectorBinder
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.GenericEntry
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
import org.moire.ultrasonic.util.ContextMenuUtil
/**
* An extension of the MultiListFragment, with a few helper functions geared
* towards the display of MusicDirectory.Entries.
* @param T: The type of data which will be used (must extend GenericEntry)
*/
abstract class EntryListFragment<T : GenericEntry> :
MultiListFragment<T>(),
KoinScopeComponent {
abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
internal val contextMenuUtil: ContextMenuUtil by inject()
/**
* Whether to show the folder selector
*/
@@ -41,7 +41,7 @@ abstract class EntryListFragment<T : GenericEntry> :
override fun onContextMenuItemSelected(menuItem: MenuItem, item: T): Boolean {
val isArtist = (item is Artist)
return handleContextMenu(menuItem, item, isArtist, this)
return contextMenuUtil.handleContextMenu(menuItem, item, isArtist, this)
}
override fun onItemClick(item: T) {
@@ -26,7 +26,6 @@ import kotlin.collections.hashMapOf
import kotlin.collections.set
import kotlinx.coroutines.launch
import org.koin.androidx.scope.ScopeFragment
import org.koin.core.component.KoinScopeComponent
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
@@ -39,9 +38,7 @@ import org.moire.ultrasonic.view.SortOrder
import org.moire.ultrasonic.view.ViewCapabilities
import timber.log.Timber
class MainFragment :
ScopeFragment(),
KoinScopeComponent {
class MainFragment : ScopeFragment() {
private var filterButtonBar: FilterButtonBar? = null
private var layoutType: LayoutType = LayoutType.COVER
@@ -75,9 +75,9 @@ import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.scope.ScopeFragment
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.component.KoinScopeComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.BaseAdapter
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
@@ -94,13 +94,14 @@ import org.moire.ultrasonic.model.PlayerViewModel
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.PlaybackBackend
import org.moire.ultrasonic.service.PlaybackProgress
import org.moire.ultrasonic.service.PlaybackRepository
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.CoroutinePatterns
import org.moire.ultrasonic.util.ConfirmationDialog
import org.moire.ultrasonic.util.CoroutinePatterns
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.themeColor
@@ -117,7 +118,6 @@ import timber.log.Timber
class PlayerFragment :
ScopeFragment(),
GestureDetector.OnGestureListener,
KoinScopeComponent,
CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.Main) {
// Settings
@@ -137,6 +137,9 @@ class PlayerFragment :
private val playerViewModel: PlayerViewModel by viewModel()
private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val playback: PlaybackRepository by inject()
private val popupMenuFactory: PopupMenuFactory by inject()
private val communicationError: CommunicationError by inject()
private var currentSong: Track? = null
private lateinit var viewManager: LinearLayoutManager
private var progressJob: Job? = null
@@ -440,7 +443,7 @@ class PlayerFragment :
}
// Query the Jukebox state in an IO Context
ioScope.launch(CommunicationError.getHandler(context)) {
ioScope.launch(communicationError.getHandler(context)) {
try {
jukeboxAvailable = musicServiceFactory.getMusicService().isJukeboxAvailable()
} catch (all: Exception) {
@@ -855,7 +858,7 @@ class PlayerFragment :
toast(R.string.download_playlist_done)
} else {
Timber.e(it, "Exception has occurred in savePlaylistInBackground")
CommunicationError.handleError(
communicationError.handleError(
it,
context,
showToast = true,
@@ -905,6 +908,9 @@ class PlayerFragment :
checkable = false,
draggable = true,
lifecycleOwner = viewLifecycleOwner,
popupMenuFactory = popupMenuFactory,
playback = playback,
activeServerProvider = activeServerProvider,
showPlayingInPlayOrder = true
) { view, track -> onCreateContextMenu(view, track) }.apply {
this.startDrag = { holder ->
@@ -26,9 +26,11 @@ import androidx.navigation.fragment.findNavController
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.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TextRowBinder
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
@@ -45,6 +47,9 @@ import org.moire.ultrasonic.util.toastingExceptionHandler
*/
class PlaylistsFragment : MultiListFragment<Playlist>() {
private val popupMenuFactory: PopupMenuFactory by inject()
private val downloadUtil: DownloadUtil by inject()
/**
* The ViewModel to use to get the data
*/
@@ -81,7 +86,8 @@ class PlaylistsFragment : MultiListFragment<Playlist>() {
} else {
R.menu.select_playlist_context
}
}
},
popupMenuFactory = popupMenuFactory
)
)
}
@@ -138,7 +144,7 @@ class PlaylistsFragment : MultiListFragment<Playlist>() {
R.id.playlist_menu_unpin -> DownloadAction.UNPIN
else -> DownloadAction.DOWNLOAD
}
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = action,
fragment = this,
id = playlist.id,
@@ -12,9 +12,11 @@ import android.view.MenuItem
import android.view.View
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TextRowBinder
import org.moire.ultrasonic.domain.PodcastsChannel
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
@@ -25,6 +27,8 @@ import org.moire.ultrasonic.model.PodcastListModel
*/
class PodcastFragment : MultiListFragment<PodcastsChannel>() {
private val popupMenuFactory: PopupMenuFactory by inject()
/**
* The ViewModel to use to get the data
*/
@@ -52,7 +56,8 @@ class PodcastFragment : MultiListFragment<PodcastsChannel>() {
TextRowBinder(
textResolver = { it.title },
onItemClick = { onItemClick(it) },
onContextMenuItemClick = { _, _ -> false }
onContextMenuItemClick = { _, _ -> false },
popupMenuFactory = popupMenuFactory
)
)
}
@@ -16,15 +16,15 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.component.KoinScopeComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumRowDelegate
import org.moire.ultrasonic.adapters.ArtistRowBinder
import org.moire.ultrasonic.adapters.DividerBinder
import org.moire.ultrasonic.adapters.MoreButtonBinder
import org.moire.ultrasonic.adapters.MoreButtonBinder.MoreButton
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.domain.Album
@@ -37,9 +37,9 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.PlaybackQueueActions
import org.moire.ultrasonic.subsonic.VideoPlayer.Companion.playVideo
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenu
import org.moire.ultrasonic.util.ContextMenuUtil.handleContextMenuTracks
import org.moire.ultrasonic.service.PlaybackRepository
import org.moire.ultrasonic.subsonic.VideoPlayer
import org.moire.ultrasonic.util.ContextMenuUtil
import org.moire.ultrasonic.util.RefreshableFragment
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.toast
@@ -51,11 +51,14 @@ import org.moire.ultrasonic.util.toastingExceptionHandler
*/
class SearchFragment :
MultiListFragment<Identifiable>(),
KoinScopeComponent,
RefreshableFragment {
private var searchResult: SearchResult? = null
override var swipeRefresh: SwipeRefreshLayout? = null
private val playbackQueue: PlaybackQueueActions by inject()
private val playback: PlaybackRepository by inject()
private val popupMenuFactory: PopupMenuFactory by inject()
private val contextMenuUtil: ContextMenuUtil by inject()
private val videoPlayer: VideoPlayer by inject()
private val navArgs by navArgs<SearchFragmentArgs>()
override val listModel: SearchListModel by viewModel()
override val mainLayout: Int = R.layout.search
@@ -88,7 +91,8 @@ class SearchFragment :
onContextMenuClick = ::onContextMenuItemSelected,
enableSections = false,
imageLoaderProvider = imageLoaderProvider,
activeServerProvider = activeServerProvider
activeServerProvider = activeServerProvider,
popupMenuFactory = popupMenuFactory
)
)
@@ -96,7 +100,8 @@ class SearchFragment :
AlbumRowDelegate(
onItemClick = ::onItemClick,
onContextMenuClick = ::onContextMenuItemSelected,
imageLoaderProvider = imageLoaderProvider
imageLoaderProvider = imageLoaderProvider,
popupMenuFactory = popupMenuFactory
)
)
@@ -106,7 +111,10 @@ class SearchFragment :
onContextMenuClick = ::onContextMenuItemSelected,
checkable = false,
draggable = false,
lifecycleOwner = viewLifecycleOwner
lifecycleOwner = viewLifecycleOwner,
popupMenuFactory = popupMenuFactory,
playback = playback,
activeServerProvider = activeServerProvider
)
)
@@ -231,7 +239,7 @@ class SearchFragment :
}
private fun onVideoSelected(track: Track) {
playVideo(requireContext(), track)
videoPlayer.playVideo(requireContext(), track)
}
private fun autoplay() {
@@ -266,13 +274,13 @@ class SearchFragment :
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Identifiable): Boolean {
// Here the Item could be a track or an album or an artist
if (item is Track) {
return handleContextMenuTracks(
return contextMenuUtil.handleContextMenuTracks(
menuItem = menuItem,
tracks = listOf(item),
fragment = this
)
} else {
return handleContextMenu(
return contextMenuUtil.handleContextMenu(
menuItem = menuItem,
item = item,
isArtist = item is Artist,
@@ -12,9 +12,11 @@ import android.view.MenuItem
import android.view.View
import androidx.lifecycle.LiveData
import androidx.navigation.fragment.findNavController
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TextRowBinder
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
@@ -26,6 +28,8 @@ import org.moire.ultrasonic.util.Settings.maxSongs
*/
class SelectGenreFragment : MultiListFragment<Genre>() {
private val popupMenuFactory: PopupMenuFactory by inject()
/**
* The ViewModel to use to get the data
*/
@@ -53,7 +57,8 @@ class SelectGenreFragment : MultiListFragment<Genre>() {
TextRowBinder(
textResolver = { it.name },
onItemClick = { onItemClick(it) },
onContextMenuItemClick = { _, _ -> false }
onContextMenuItemClick = { _, _ -> false },
popupMenuFactory = popupMenuFactory
)
)
}
@@ -23,7 +23,6 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import java.io.File
import kotlin.math.ceil
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
@@ -59,8 +58,7 @@ import timber.log.Timber
@Suppress("TooManyFunctions")
class SettingsFragment :
PreferenceFragmentCompat(),
OnSharedPreferenceChangeListener,
KoinComponent {
OnSharedPreferenceChangeListener {
private var theme: ListPreference? = null
private var cacheLocation: Preference? = null
private var showArtistPicture: CheckBoxPreference? = null
@@ -29,10 +29,11 @@ import java.util.concurrent.TimeUnit
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.koin.core.component.KoinScopeComponent
import org.moire.ultrasonic.NavigationGraphDirections
import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.ShareRowBinder
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
@@ -50,15 +51,17 @@ import org.moire.ultrasonic.util.toastingExceptionHandler
/**
* Displays the shares in the media library
*/
class SharesFragment :
MultiListFragment<Share>(),
KoinScopeComponent {
class SharesFragment : MultiListFragment<Share>() {
/**
* The ViewModel to use to get the data
*/
override val listModel: ShareListModel by viewModel()
private val popupMenuFactory: PopupMenuFactory by inject()
private val contextMenuUtil: ContextMenuUtil by inject()
private val downloadUtil: DownloadUtil by inject()
/**
* The list is cached locally, no need to refresh it on creation
*/
@@ -81,7 +84,8 @@ class SharesFragment :
onItemClick = { onItemClick(it) },
onContextMenuItemClick = { menuItem, item ->
onContextMenuItemSelected(menuItem, item)
}
},
popupMenuFactory = popupMenuFactory
)
)
}
@@ -104,7 +108,7 @@ class SharesFragment :
R.id.share_menu_play_now,
R.id.share_menu_play_shuffled -> {
ContextMenuUtil.playTracksAndToast(
contextMenuUtil.playTracksAndToast(
this,
insertionMode = QueueInsertionMode.CLEAR,
id = item.id,
@@ -137,7 +141,7 @@ class SharesFragment :
R.id.share_menu_unpin -> DownloadAction.UNPIN
else -> DownloadAction.DOWNLOAD
}
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = action,
fragment = this,
id = share.id,
@@ -34,8 +34,8 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.adapters.AlbumHeader
import org.moire.ultrasonic.adapters.AlbumRowDelegate
import org.moire.ultrasonic.adapters.HeaderViewBinder
import org.moire.ultrasonic.adapters.PopupMenuFactory
import org.moire.ultrasonic.adapters.TrackViewBinder
import org.moire.ultrasonic.adapters.Utils
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.MusicDirectory
@@ -43,6 +43,7 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.fragment.FragmentTitle.setTitle
import org.moire.ultrasonic.model.TrackCollectionModel
import org.moire.ultrasonic.service.PlaybackQueueActions
import org.moire.ultrasonic.service.PlaybackRepository
import org.moire.ultrasonic.service.QueueInsertionMode
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.subsonic.ShareHandler
@@ -88,6 +89,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
internal val playbackQueue: PlaybackQueueActions by inject()
private val shareHandler: ShareHandler by inject()
private val playback: PlaybackRepository by inject()
private val popupMenuFactory: PopupMenuFactory by inject()
internal val contextMenuUtil: ContextMenuUtil by inject()
private val downloadUtil: DownloadUtil by inject()
private val videoPlayer: VideoPlayer by inject()
override val listModel: TrackCollectionModel by viewModel()
@@ -106,6 +112,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
sortOrderName?.let { sortOrder = SortOrder.valueOf(it) }
}
@Suppress("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -140,7 +147,8 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
viewAdapter.register(
HeaderViewBinder(
context = requireContext()
context = requireContext(),
imageLoaderProvider = imageLoaderProvider
)
)
@@ -151,8 +159,11 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
checkable = true,
draggable = false,
lifecycleOwner = viewLifecycleOwner,
popupMenuFactory = popupMenuFactory,
playback = playback,
activeServerProvider = activeServerProvider,
createContextMenu = { view, _ ->
Utils.createPopupMenu(view, R.menu.context_menu_track_collection)
popupMenuFactory.createPopupMenu(view, R.menu.context_menu_track_collection)
}
)
)
@@ -161,7 +172,8 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
AlbumRowDelegate(
{ entry -> onItemClick(entry) },
{ menuItem, entry -> onContextMenuItemSelected(menuItem, entry) },
imageLoaderProvider = imageLoaderProvider
imageLoaderProvider = imageLoaderProvider,
popupMenuFactory = popupMenuFactory
)
)
@@ -347,7 +359,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
// Need a valid id to recurse sub directories stuff
if (hasSubFolders && navArgs.id != null) {
ContextMenuUtil.playTracksAndToast(
contextMenuUtil.playTracksAndToast(
fragment = this,
insertionMode = insertionMode,
id = navArgs.id!!,
@@ -367,7 +379,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
}
}
private fun unpinSelectedTracks() {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = this,
tracks = getSelectedTracks()
@@ -375,7 +387,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
}
private fun downloadSelectedOrAllTracks(save: Boolean) {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = if (save) DownloadAction.PIN else DownloadAction.DOWNLOAD,
fragment = this,
tracks = getSelectedOrAllTracks()
@@ -383,7 +395,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
}
private fun playSelectedOrAllTracks(insertionMode: QueueInsertionMode) {
ContextMenuUtil.playTracksAndToast(
contextMenuUtil.playTracksAndToast(
fragment = this,
insertionMode = insertionMode,
tracks = getSelectedOrAllTracks()
@@ -391,7 +403,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
}
private fun deleteSelectedTracks() {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.DELETE,
fragment = this,
tracks = getSelectedTracks()
@@ -612,7 +624,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
val tracks = getClickedSong(item)
return ContextMenuUtil.handleContextMenuTracks(
return contextMenuUtil.handleContextMenuTracks(
menuItem = menuItem,
tracks = tracks,
fragment = this
@@ -660,7 +672,7 @@ open class TrackCollectionFragment(initialOrder: SortOrder? = null) :
}
item is Track && item.isVideo -> {
VideoPlayer.playVideo(requireContext(), item)
videoPlayer.playVideo(requireContext(), item)
}
else -> {
@@ -17,16 +17,10 @@ import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.MoreExecutors
import java.io.IOException
import java.util.concurrent.Executors
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
@SuppressLint("UnsafeOptInUsageError")
class ArtworkBitmapLoader :
BitmapLoader,
KoinComponent {
private val imageLoaderProvider: ImageLoaderProvider by inject()
class ArtworkBitmapLoader(private val imageLoaderProvider: ImageLoaderProvider) : BitmapLoader {
private val executorService: ListeningExecutorService by lazy {
MoreExecutors.listeningDecorator(
@@ -17,8 +17,6 @@ import coil3.request.Options
import java.io.IOException
import okio.buffer
import okio.source
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
data class AvatarRequest(val username: String)
@@ -27,11 +25,11 @@ class AvatarKeyer : Keyer<AvatarRequest> {
override fun key(data: AvatarRequest, options: Options): String = data.username
}
class AvatarFetcher(private val avatarRequest: AvatarRequest, private val options: Options) :
Fetcher,
KoinComponent {
private val client: SubsonicAPIClient by inject()
class AvatarFetcher(
private val avatarRequest: AvatarRequest,
private val options: Options,
private val client: SubsonicAPIClient
) : Fetcher {
override suspend fun fetch(): FetchResult {
// Inverted call order, because Mockito has problems with chained calls.
val response = client.toStreamResponse(
@@ -48,11 +46,16 @@ class AvatarFetcher(private val avatarRequest: AvatarRequest, private val option
)
}
class Factory : Fetcher.Factory<AvatarRequest> {
/**
* The client is resolved lazily through [clientProvider] at request time, because the
* SubsonicAPIClient is regenerated whenever the active server changes.
*/
class Factory(private val clientProvider: () -> SubsonicAPIClient) :
Fetcher.Factory<AvatarRequest> {
override fun create(
data: AvatarRequest,
options: Options,
imageLoader: coil3.ImageLoader
): Fetcher = AvatarFetcher(data, options)
): Fetcher = AvatarFetcher(data, options, clientProvider())
}
}
@@ -22,8 +22,6 @@ import java.io.File
import java.io.IOException
import okio.buffer
import okio.source
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE
@@ -37,11 +35,11 @@ class CoverArtKeyer : Keyer<CoverArtRequest> {
override fun key(data: CoverArtRequest, options: Options): String? = data.cacheKey
}
class CoverArtFetcher(private val coverArtRequest: CoverArtRequest, private val options: Options) :
Fetcher,
KoinComponent {
private val client: SubsonicAPIClient by inject()
class CoverArtFetcher(
private val coverArtRequest: CoverArtRequest,
private val options: Options,
private val client: SubsonicAPIClient
) : Fetcher {
override suspend fun fetch(): FetchResult {
// Check the cache before sending out a network request
val result = getCachedImage()
@@ -128,11 +126,16 @@ class CoverArtFetcher(private val coverArtRequest: CoverArtRequest, private val
return bitmap1
}
class Factory : Fetcher.Factory<CoverArtRequest> {
/**
* The client is resolved lazily through [clientProvider] at request time, because the
* SubsonicAPIClient is regenerated whenever the active server changes.
*/
class Factory(private val clientProvider: () -> SubsonicAPIClient) :
Fetcher.Factory<CoverArtRequest> {
override fun create(
data: CoverArtRequest,
options: Options,
imageLoader: coil3.ImageLoader
): Fetcher = CoverArtFetcher(data, options)
): Fetcher = CoverArtFetcher(data, options, clientProvider())
}
}
@@ -18,8 +18,14 @@ import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.service.CachedMusicService
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
class AlbumListModel(application: Application) : GenericListModel(application) {
class AlbumListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
val list: MutableLiveData<List<Album>> = MutableLiveData()
private var lastType: AlbumListType? = null
@@ -13,11 +13,18 @@ import java.text.Collator
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ArtistOrIndex
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
/**
* Provides ViewModel which contains the list of available Artists
*/
class ArtistListModel(application: Application) : GenericListModel(application) {
class ArtistListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
private val artists: MutableLiveData<List<ArtistOrIndex>> = MutableLiveData()
/**
@@ -16,8 +16,6 @@ import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.SubsonicAPIDefinition
@@ -26,18 +24,13 @@ import org.moire.ultrasonic.api.subsonic.SubsonicClientConfiguration
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.api.subsonic.allowSelfSignedCertificates
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.util.Constants
import retrofit2.HttpException
import timber.log.Timber
@Suppress("MagicNumber")
class EditServerModel(val app: Application) :
AndroidViewModel(app),
KoinComponent {
val activeServerProvider: ActiveServerProvider by inject()
class EditServerModel(val app: Application) : AndroidViewModel(app) {
private suspend fun serverFunctionAvailable(
type: ServerFeature,
@@ -18,8 +18,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.ServerSetting
import org.moire.ultrasonic.domain.MusicFolder
@@ -30,12 +28,12 @@ import org.moire.ultrasonic.util.CommunicationError
/**
* An abstract Model, which can be extended to retrieve a list of items from the API
*/
open class GenericListModel(application: Application) :
AndroidViewModel(application),
KoinComponent {
val activeServerProvider: ActiveServerProvider by inject()
val musicServiceFactory: MusicServiceFactory by inject()
open class GenericListModel(
application: Application,
val activeServerProvider: ActiveServerProvider,
val musicServiceFactory: MusicServiceFactory,
private val communicationError: CommunicationError
) : AndroidViewModel(application) {
val activeServer: ServerSetting
get() = activeServerProvider.getActiveServer()
@@ -118,7 +116,7 @@ open class GenericListModel(application: Application) :
// CommunicationError reports transport failures to the provider, which switches
// subsequent loads to the local catalog, and shows a unified message. API and
// authentication failures are deliberately left fully visible to the user.
CommunicationError.handleError(all, context)
communicationError.handleError(all, context)
}
}
}
@@ -9,13 +9,21 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Genre
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
/**
* Provides a ViewModel which contains the list of available Genres
*/
class GenreListModel(application: Application) : GenericListModel(application) {
class GenreListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
private val genres: MutableLiveData<List<Genre>> = MutableLiveData()
/**
@@ -9,16 +9,23 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CommunicationError
/**
* Provides a ViewModel which contains the list of available Playlists
*/
class PlaylistListModel(application: Application) : GenericListModel(application) {
private val cacheCleaner: CacheCleaner by inject()
class PlaylistListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError,
private val cacheCleaner: CacheCleaner
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
private val playlists: MutableLiveData<List<Playlist>> = MutableLiveData()
/**
@@ -9,13 +9,21 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.PodcastsChannel
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
/**
* Provides a ViewModel which contains the list of available Podcast channels
*/
class PodcastListModel(application: Application) : GenericListModel(application) {
class PodcastListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
private val channels: MutableLiveData<List<PodcastsChannel>> = MutableLiveData()
/**
@@ -11,12 +11,19 @@ import android.app.Application
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.SearchCriteria
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Settings
class SearchListModel(application: Application) : GenericListModel(application) {
class SearchListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
var searchResult: MutableLiveData<SearchResult?> = MutableLiveData()
@@ -9,13 +9,21 @@ package org.moire.ultrasonic.model
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Share
import org.moire.ultrasonic.service.MusicService
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
/**
* Provides a ViewModel which contains the list of available Shares
*/
class ShareListModel(application: Application) : GenericListModel(application) {
class ShareListModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
private val shares: MutableLiveData<List<Share>> = MutableLiveData()
/**
@@ -19,12 +19,18 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadService
import org.moire.ultrasonic.service.DownloadState
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.CommunicationError
import org.moire.ultrasonic.util.Util
/*
* Model for retrieving different collections of tracks from the API
*/
class TrackCollectionModel(application: Application) : GenericListModel(application) {
class TrackCollectionModel(
application: Application,
activeServerProvider: ActiveServerProvider,
musicServiceFactory: MusicServiceFactory,
communicationError: CommunicationError
) : GenericListModel(application, activeServerProvider, musicServiceFactory, communicationError) {
val currentList: MutableLiveData<List<MusicDirectory.Child>> = MutableLiveData()
private var loadedUntil: Int = 0
@@ -9,8 +9,6 @@ package org.moire.ultrasonic.service
import java.io.InputStream
import java.util.concurrent.TimeUnit
import kotlin.Pair
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase
@@ -38,10 +36,10 @@ import org.moire.ultrasonic.util.TimeLimitedCache
import org.moire.ultrasonic.util.Util
@Suppress("TooManyFunctions")
class CachedMusicService(private val musicService: MusicService) :
MusicService,
KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
class CachedMusicService(
private val musicService: MusicService,
private val activeServerProvider: ActiveServerProvider
) : MusicService {
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
// Old style TimeLimitedCache
@@ -31,7 +31,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.data.ActiveServerProvider
@@ -39,6 +38,7 @@ import org.moire.ultrasonic.data.toLocalMediaCatalogEntry
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
@@ -68,6 +68,9 @@ class DownloadService :
private var scope: CoroutineScope? = null
private val storageMonitor: ExternalStorageMonitor by inject()
private val cacheCleaner: CacheCleaner by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val binder: IBinder = SimpleServiceBinder(this)
private var isInForeground = false
@@ -151,7 +154,14 @@ class DownloadService :
// Use poll() instead of remove() which throws an Exception if there is no element.
val track: DownloadableTrack = downloadQueue.poll() ?: continue
val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback)
val downloadTask = DownloadTask(
track,
scope!!,
::downloadStateChangedCallback,
musicServiceFactory,
imageLoaderProvider,
activeServerProvider
)
activeDownloads[track.id] = downloadTask
downloadTask.start()
@@ -245,11 +255,13 @@ class DownloadService :
}
@Suppress("MagicNumber", "NestedBlockDepth", "TooManyFunctions")
companion object {
companion object : KoinComponent {
// Access the instance lazily; the companion may be touched before Koin started.
private fun activeServerProvider(): ActiveServerProvider =
inject<ActiveServerProvider>(ActiveServerProvider::class.java).value
// The companion is the static bridge to the running Android Service, and may be
// touched before Koin started. The lazy delegates therefore defer resolution until
// the first actual call.
private val activeServerProvider: ActiveServerProvider by inject()
private val cacheCleaner: CacheCleaner by inject()
private var startFuture: SettableFuture<DownloadService>? = null
@@ -424,12 +436,11 @@ class DownloadService :
Storage.delete(track.getPartialFile())
// A legacy filename can be shared by catalog rows from different servers.
// Delete the catalog record first and only remove a completed file when it has no owners.
val catalog = inject<ActiveServerProvider>(ActiveServerProvider::class.java)
.value.offlineMetaDatabase.localMediaCatalogDao()
val catalog = activeServerProvider.offlineMetaDatabase.localMediaCatalogDao()
val sourceServerId = if (track.serverId > 0) {
track.serverId
} else {
activeServerProvider().getSelectedServerId()
activeServerProvider.getSelectedServerId()
}
val catalogEntry = catalog.get(sourceServerId, track.id)
val pathsToDelete = if (catalogEntry != null) {
@@ -443,7 +454,6 @@ class DownloadService :
if (catalog.referencesToPath(path) == 0) Storage.delete(path)
}
postState(track, DownloadState.IDLE)
val cacheCleaner: CacheCleaner by inject(CacheCleaner::class.java)
cacheCleaner.cleanDatabaseSelective(track)
pathsToDelete.forEach(Util::scanMedia)
}
@@ -500,10 +510,10 @@ class DownloadService :
val sourceServerId = if (track.serverId > 0) {
track.serverId
} else {
activeServerProvider().getSelectedServerId()
activeServerProvider.getSelectedServerId()
}
inject<ActiveServerProvider>(ActiveServerProvider::class.java)
.value.offlineMetaDatabase.localMediaCatalogDao()
activeServerProvider
.offlineMetaDatabase.localMediaCatalogDao()
.upsert(
track.toLocalMediaCatalogEntry(
sourceServerId = sourceServerId,
@@ -16,8 +16,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
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.data.toLocalMediaCatalogEntry
@@ -42,12 +40,12 @@ private const val REFRESH_INTERVAL = 50
class DownloadTask(
val downloadTrack: DownloadableTrack,
private val scope: CoroutineScope,
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit
) : KoinComponent {
private val musicServiceFactory: MusicServiceFactory by inject()
private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit,
musicServiceFactory: MusicServiceFactory,
private val imageLoaderProvider: ImageLoaderProvider,
private val activeServerProvider: ActiveServerProvider
) {
private val musicService = musicServiceFactory.getMusicService()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private var job: Job? = null
private var inputStream: InputStream? = null
@@ -43,8 +43,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.app.UApp
@@ -103,21 +101,19 @@ const val PLAY_COMMAND = "play "
/**
* MediaBrowserService implementation for e.g. Android Auto
*/
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember")
@Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember", "LongParameterList")
@OptIn(UnstableApi::class)
class MediaLibrarySessionCallback(
private val activeServerProvider: ActiveServerProvider,
private val musicServiceFactory: MusicServiceFactory,
private val ratingManager: RatingManager,
private val moveInPlayOrder: (from: Int, to: Int) -> Unit,
private val shuffleFromStart: () -> Unit,
private val setBackend: (PlaybackBackend) -> Unit,
private val shutdown: () -> Unit,
private val playbackResumption: () ->
ListenableFuture<MediaSession.MediaItemsWithStartPosition>
) : MediaLibraryService.MediaLibrarySession.Callback,
KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
private val ratingManager: RatingManager by inject()
) : MediaLibraryService.MediaLibrarySession.Callback {
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@@ -18,10 +18,7 @@
*/
package org.moire.ultrasonic.service
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.context.loadKoinModules
import org.koin.core.context.unloadKoinModules
import org.koin.core.Koin
import org.koin.core.qualifier.named
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.LibraryAccessState
@@ -44,8 +41,13 @@ import timber.log.Timber
* external reset call, [getMusicService] checks for itself whether the module is still
* valid for the current state, and regenerates it if needed.
*/
class MusicServiceFactory(private val activeServerProvider: ActiveServerProvider) :
KoinComponent {
class MusicServiceFactory(
private val activeServerProvider: ActiveServerProvider,
// The Koin instance is injected instead of using the KoinComponent service locator,
// because this factory is the one component that must resolve from a Koin module
// that is regenerated at runtime (see below).
private val koin: Koin
) {
// Identifies the access state the module was last loaded for. Only the transition
// dimensions are compared: server property edits (invalidateCache) intentionally do
@@ -66,9 +68,9 @@ class MusicServiceFactory(private val activeServerProvider: ActiveServerProvider
fun getMusicService(): MusicService {
ensureMusicServiceUpToDate()
return if (activeServerProvider.isOffline()) {
get(named(OFFLINE_MUSIC_SERVICE))
koin.get(named(OFFLINE_MUSIC_SERVICE))
} else {
get(named(ONLINE_MUSIC_SERVICE))
koin.get(named(ONLINE_MUSIC_SERVICE))
}
}
@@ -80,8 +82,8 @@ class MusicServiceFactory(private val activeServerProvider: ActiveServerProvider
@Synchronized
fun resetMusicService() {
Timber.i("Regenerating Koin Music Service Module")
unloadKoinModules(musicServiceModule)
loadKoinModules(musicServiceModule)
koin.unloadModules(listOf(musicServiceModule))
koin.loadModules(listOf(musicServiceModule))
loadedStateKey = accessStateKey
}
@@ -20,8 +20,6 @@ import java.util.LinkedList
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.api.subsonic.models.AlbumListType
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.LocalMediaCatalogEntry
@@ -57,10 +55,8 @@ import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
@Suppress("TooManyFunctions")
class OfflineMusicService :
MusicService,
KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
class OfflineMusicService(private val activeServerProvider: ActiveServerProvider) :
MusicService {
private var metaDatabase: MetaDatabase = activeServerProvider.getActiveMetaDatabase()
@@ -58,7 +58,9 @@ import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider
import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.service.RatingManager
import org.moire.ultrasonic.service.UltrasonicBus
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.CoroutinePatterns
import org.moire.ultrasonic.util.Settings
@@ -85,6 +87,8 @@ class PlaybackService :
private val activeServerProvider: ActiveServerProvider by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
private val playbackStateSerializer: PlaybackStateSerializer by inject()
private val ratingManager: RatingManager by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
private lateinit var playbackStateStore: PlaybackStateStore
@@ -191,6 +195,9 @@ class PlaybackService :
// Create browser interface
librarySessionCallback = MediaLibrarySessionCallback(
activeServerProvider,
musicServiceFactory,
ratingManager,
::moveInPlayOrder,
{ (player as? QueueAwarePlayer)?.shuffleFromStart() },
::updateBackendFromController,
@@ -201,7 +208,7 @@ class PlaybackService :
// This will need to use the AutoCalls
mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getPendingIntentForContent())
.setBitmapLoader(ArtworkBitmapLoader())
.setBitmapLoader(ArtworkBitmapLoader(imageLoaderProvider))
.build()
// Send custom layout to legacy session.
@@ -12,8 +12,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil
import timber.log.Timber
@@ -47,9 +45,7 @@ internal class PendingPlaybackStateLoads {
*
* TODO: Should use: MediaItemsWithStartPosition
*/
class PlaybackStateSerializer : KoinComponent {
private val context by inject<Context>()
class PlaybackStateSerializer(private val context: Context) {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@@ -12,8 +12,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.Koin
import org.koin.core.qualifier.named
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
@@ -27,11 +26,14 @@ import timber.log.Timber
/**
* Handles the lifetime of the Image Loader
*/
class ImageLoaderProvider :
KoinComponent,
CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO) {
class ImageLoaderProvider(
// The Koin instance is injected instead of using the KoinComponent service locator:
// the SubsonicAPIClient and the ServerID live in the music service module which is
// regenerated on every server switch, so they must be re-resolved lazily.
private val koin: Koin
) : CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO) {
private var imageLoader: ImageLoader? = null
private var serverID: String = get(named("ServerID"))
private var serverID: String = koin.get(named("ServerID"))
@Synchronized
fun clearImageLoader() {
@@ -49,9 +51,9 @@ class ImageLoaderProvider :
@Synchronized
fun getImageLoader(): ImageLoader {
// We need to generate a new ImageLoader if the server has changed...
val currentID = get<String>(named("ServerID"))
val currentID = koin.get<String>(named("ServerID"))
if (imageLoader == null || currentID != serverID) {
imageLoader = ImageLoader(UApp.applicationContext(), get(), config)
imageLoader = ImageLoader(UApp.applicationContext(), koin.get(), config)
serverID = currentID
launch {
@@ -3,8 +3,6 @@ package org.moire.ultrasonic.subsonic
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MusicServiceFactory
@@ -13,32 +11,27 @@ import org.moire.ultrasonic.util.Util
/**
* This utility class helps starting video playback
*/
@Suppress("UtilityClassWithPublicConstructor")
class VideoPlayer {
class VideoPlayer(private val musicServiceFactory: MusicServiceFactory) {
companion object : KoinComponent {
private val musicServiceFactory: MusicServiceFactory by inject()
fun playVideo(context: Context, track: Track?) {
if (!Util.hasUsableNetwork() || track == null) {
Util.toast(R.string.select_album_no_network, true, context)
return
}
try {
val intent = Intent(Intent.ACTION_VIEW)
val url = musicServiceFactory.getMusicService().getStreamUrl(
track.id,
maxBitRate = null,
format = "raw"
)
intent.setDataAndType(
url?.toUri(),
"video/*"
)
context.startActivity(intent)
} catch (all: Exception) {
Util.toast(all.toString(), false, context)
}
fun playVideo(context: Context, track: Track?) {
if (!Util.hasUsableNetwork() || track == null) {
Util.toast(R.string.select_album_no_network, true, context)
return
}
try {
val intent = Intent(Intent.ACTION_VIEW)
val url = musicServiceFactory.getMusicService().getStreamUrl(
track.id,
maxBitRate = null,
format = "raw"
)
intent.setDataAndType(
url?.toUri(),
"video/*"
)
context.startActivity(intent)
} catch (all: Exception) {
Util.toast(all.toString(), false, context)
}
}
}
@@ -12,9 +12,6 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.TrackRef
import org.moire.ultrasonic.domain.Playlist
@@ -36,12 +33,10 @@ import timber.log.Timber
/**
* Responsible for cleaning up files from the offline download cache on the filesystem.
*/
class CacheCleaner :
CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO),
KoinComponent {
private val activeServerProvider by inject<ActiveServerProvider>()
private val playback by inject<PlaybackRepository>()
class CacheCleaner(
private val activeServerProvider: ActiveServerProvider,
private val playback: PlaybackRepository
) : CoroutineScope by CoroutinePatterns.loggingScope(Dispatchers.IO) {
private fun exceptionHandler(tag: String): CoroutineExceptionHandler =
CoroutineExceptionHandler {
@@ -249,11 +244,7 @@ class CacheCleaner :
private fun backgroundPlaylistsCleanup(vararg params: List<Playlist>) {
try {
val activeServerProvider = inject<ActiveServerProvider>(
ActiveServerProvider::class.java
)
val server = activeServerProvider.value.getActiveServer().name
val server = activeServerProvider.getActiveServer().name
val playlistFiles = listFiles(getPlaylistDirectory(server))
val playlists = params[0]
@@ -17,8 +17,6 @@ import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineExceptionHandler
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
@@ -31,8 +29,7 @@ import timber.log.Timber
* Contains helper functions to handle the exceptions
* thrown during the communication with a Subsonic server
*/
object CommunicationError : KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
class CommunicationError(private val activeServerProvider: ActiveServerProvider) {
fun getHandler(
context: Context?,
@@ -52,7 +49,6 @@ object CommunicationError : KoinComponent {
* @param showToast if true, the message is shown in a Toast; otherwise in an ErrorDialog
* @param messagePrefix optional operation-specific prefix, e.g. "Failed to delete playlist"
*/
@JvmStatic
@JvmOverloads
fun handleError(
error: Throwable,
@@ -88,7 +84,6 @@ object CommunicationError : KoinComponent {
}
}
@JvmStatic
@Suppress("ReturnCount")
fun getErrorMessage(error: Throwable): String {
val context = UApp.applicationContext()
@@ -9,8 +9,6 @@ package org.moire.ultrasonic.util
import android.view.MenuItem
import androidx.fragment.app.Fragment
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
@@ -19,9 +17,11 @@ import org.moire.ultrasonic.service.QueueInsertionMode
import org.moire.ultrasonic.subsonic.ShareHandler
import org.moire.ultrasonic.util.Util.navigateToCurrent
object ContextMenuUtil : KoinComponent {
private val playbackQueue: PlaybackQueueActions by inject()
private val shareHandler: ShareHandler by inject()
class ContextMenuUtil(
private val playbackQueue: PlaybackQueueActions,
private val shareHandler: ShareHandler,
private val downloadUtil: DownloadUtil
) {
/*
* Callback for menu items of collections (albums, artists etc)
@@ -58,7 +58,7 @@ object ContextMenuUtil : KoinComponent {
)
R.id.menu_pin ->
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = fragment,
id = item.id,
@@ -66,7 +66,7 @@ object ContextMenuUtil : KoinComponent {
)
R.id.menu_unpin ->
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = fragment,
id = item.id,
@@ -74,7 +74,7 @@ object ContextMenuUtil : KoinComponent {
)
R.id.menu_download ->
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = fragment,
id = item.id,
@@ -117,7 +117,7 @@ object ContextMenuUtil : KoinComponent {
}
R.id.song_menu_pin -> {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.PIN,
fragment = fragment,
tracks = tracks
@@ -125,7 +125,7 @@ object ContextMenuUtil : KoinComponent {
}
R.id.song_menu_unpin -> {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.UNPIN,
fragment = fragment,
tracks = tracks
@@ -133,7 +133,7 @@ object ContextMenuUtil : KoinComponent {
}
R.id.song_menu_download -> {
DownloadUtil.justDownload(
downloadUtil.justDownload(
action = DownloadAction.DOWNLOAD,
fragment = fragment,
tracks = tracks
@@ -165,7 +165,7 @@ object ContextMenuUtil : KoinComponent {
) {
fragment.launchWithToast {
val list = tracks.ifEmpty {
DownloadUtil.getTracksFromServerAsync(
downloadUtil.getTracksFromServerAsync(
isArtist,
requireNotNull(id),
isDirectory,
@@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import org.moire.ultrasonic.util.Util.toast
import timber.log.Timber
@@ -42,6 +43,12 @@ object CoroutinePatterns {
CoroutineScope(SupervisorJob() + dispatcher + loggingExceptionHandler)
}
/**
* Resolves the CommunicationError singleton at the Fragment boundary (the extension
* functions below are only ever called on Fragments, which are Android entry points).
*/
private fun communicationError(): CommunicationError = GlobalContext.get().get()
fun Fragment.toastingExceptionHandler(prefix: String = ""): CoroutineExceptionHandler =
CoroutineExceptionHandler {
_,
@@ -51,7 +58,7 @@ fun Fragment.toastingExceptionHandler(prefix: String = ""): CoroutineExceptionHa
if (this is RefreshableFragment) {
this.swipeRefresh?.isRefreshing = false
}
CommunicationError.handleError(
communicationError().handleError(
exception,
context,
showToast = true,
@@ -74,7 +81,7 @@ fun Fragment.launchWithToast(block: suspend CoroutineScope.() -> String?) {
// Setup a handler when the job is done
deferred.invokeOnCompletion {
if (it != null && it !is CancellationException) {
CommunicationError.handleError(it, context, showToast = true)
communicationError().handleError(it, context, showToast = true)
} else {
scope.launch(Dispatchers.Main) {
val successString = deferred.await()
@@ -11,8 +11,6 @@ import androidx.fragment.app.Fragment
import java.util.LinkedList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.MusicDirectory
@@ -24,11 +22,11 @@ import org.moire.ultrasonic.service.MusicServiceFactory
* Retrieves a list of songs and adds them to the now playing list
*/
@Suppress("LongParameterList")
object DownloadUtil : KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private val cacheCleaner: CacheCleaner by inject()
private val musicServiceFactory: MusicServiceFactory by inject()
class DownloadUtil(
private val activeServerProvider: ActiveServerProvider,
private val cacheCleaner: CacheCleaner,
private val musicServiceFactory: MusicServiceFactory
) {
fun justDownload(
action: DownloadAction,
@@ -17,23 +17,21 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.TextView
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.ChatMessage
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
class ChatAdapter(private val context: Context, private val messages: List<ChatMessage>) :
ArrayAdapter<ChatMessage>(
context,
R.layout.chat_item,
messages
),
KoinComponent {
private val activeServerProvider: ActiveServerProvider by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject()
class ChatAdapter(
private val context: Context,
private val messages: List<ChatMessage>,
private val activeServerProvider: ActiveServerProvider,
private val imageLoaderProvider: ImageLoaderProvider
) : ArrayAdapter<ChatMessage>(
context,
R.layout.chat_item,
messages
) {
override fun areAllItemsEnabled(): Boolean = true
@@ -19,14 +19,8 @@ import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldNotBe
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.mockito.Answers
import org.mockito.Mockito.`when`
import org.mockito.kotlin.any
@@ -40,31 +34,15 @@ import retrofit2.Response
import timber.log.Timber
@RunWith(RobolectricTestRunner::class)
class AvatarFetcherTest : KoinTest {
class AvatarFetcherTest {
private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS)
private val context: Context = ApplicationProvider.getApplicationContext()
private val imageLoader = coil3.ImageLoader.Builder(context).build()
@Before
fun setUp() {
startKoin {
modules(
module {
single { mockApiClient }
}
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `Should throw IOException when request to api failed`() = runTest {
// Create the fetcher under test.
val fetcher = AvatarFetcher.Factory().create(
val fetcher = AvatarFetcher.Factory { mockApiClient }.create(
AvatarRequest("user"),
Options(context),
imageLoader
@@ -95,7 +73,7 @@ class AvatarFetcherTest : KoinTest {
fun `Should load bitmap from network`() = runTest {
// Create the fetcher under test.
val user = "user"
val fetcher = AvatarFetcher.Factory().create(
val fetcher = AvatarFetcher.Factory { mockApiClient }.create(
AvatarRequest(user),
Options(context),
imageLoader
@@ -20,14 +20,8 @@ import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldNotBe
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.mockito.Answers
import org.mockito.Mockito.`when`
import org.mockito.kotlin.any
@@ -41,31 +35,15 @@ import retrofit2.Response
import timber.log.Timber
@RunWith(RobolectricTestRunner::class)
class CoverArtFetcherTest : KoinTest {
class CoverArtFetcherTest {
private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS)
private val context: Context = ApplicationProvider.getApplicationContext()
private val imageLoader = coil3.ImageLoader.Builder(context).build()
@Before
fun setUp() {
startKoin {
modules(
module {
single { mockApiClient }
}
)
}
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `Should throw IOException when request to api failed`() = runTest {
// Create the fetcher under test.
val fetcher = CoverArtFetcher.Factory().create(
val fetcher = CoverArtFetcher.Factory { mockApiClient }.create(
CoverArtRequest("some", "-1", 0),
Options(context),
imageLoader
@@ -97,7 +75,7 @@ class CoverArtFetcherTest : KoinTest {
// Create the fetcher under test.
val id = "id"
val size = 0
val fetcher = CoverArtFetcher.Factory().create(
val fetcher = CoverArtFetcher.Factory { mockApiClient }.create(
CoverArtRequest(id, "someCacheKey.jpg", size),
Options(context),
imageLoader
@@ -137,7 +115,7 @@ class CoverArtFetcherTest : KoinTest {
val id = "id"
val size = 0
val cacheKey = "Big_Buck_Bunny.jpeg"
val fetcher = CoverArtFetcher.Factory().create(
val fetcher = CoverArtFetcher.Factory { mockApiClient }.create(
CoverArtRequest(id, cacheKey, size),
Options(context),
imageLoader
@@ -165,7 +143,7 @@ class CoverArtFetcherTest : KoinTest {
val size = 0
val cacheKeySmall = "Big_Buck_Bunny.jpeg-small"
val cacheKeyLarge = "Big_Buck_Bunny.jpeg"
val fetcher = CoverArtFetcher.Factory().create(
val fetcher = CoverArtFetcher.Factory { mockApiClient }.create(
CoverArtRequest(id, cacheKeySmall, size),
Options(context),
imageLoader
@@ -8,9 +8,6 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
@@ -26,6 +23,7 @@ import org.robolectric.RobolectricTestRunner
class OfflineMusicServiceCatalogTest {
private lateinit var database: MetaDatabase
private lateinit var service: OfflineMusicService
private val activeServerProvider: ActiveServerProvider = mock()
@Before
fun setUp() {
@@ -44,18 +42,13 @@ class OfflineMusicServiceCatalogTest {
context,
MetaDatabase::class.java
).allowMainThreadQueries().build()
val activeServerProvider: ActiveServerProvider = mock()
whenever(activeServerProvider.getActiveMetaDatabase()).thenReturn(database)
startKoin {
modules(module { single { activeServerProvider } })
}
service = OfflineMusicService()
service = OfflineMusicService(activeServerProvider)
}
@After
fun tearDown() {
stopKoin()
database.close()
UApp.instance = null
}
@@ -14,9 +14,6 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.mockito.Mockito.reset
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
@@ -40,10 +37,9 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class CommunicationErrorReportingTest {
// CommunicationError is a Kotlin object and caches the injected provider on first use,
// so a single mock instance is deliberately shared by all tests of this class. It must
// be created lazily after UApp.instance is set, because mocking ActiveServerProvider
// triggers its companion object initialization, which reads Android settings.
// The mock must be created lazily after UApp.instance is set, because mocking
// ActiveServerProvider triggers its companion object initialization, which reads
// Android settings.
companion object {
private val activeServerProvider: ActiveServerProvider by lazy { mock() }
}
@@ -63,14 +59,10 @@ class CommunicationErrorReportingTest {
UApp.instance = app
reset(activeServerProvider)
startKoin {
modules(module { single { activeServerProvider } })
}
}
@After
fun tearDown() {
stopKoin()
UApp.instance = null
}
@@ -78,7 +70,7 @@ class CommunicationErrorReportingTest {
fun `handleError reports the error to the provider even without a UI context`() {
val error = IOException("Connection reset by peer")
CommunicationError.handleError(error, null)
CommunicationError(activeServerProvider).handleError(error, null)
verify(activeServerProvider).reportNetworkFailure(error, false)
}
@@ -87,7 +79,7 @@ class CommunicationErrorReportingTest {
fun `handleError reports local file IO errors to the provider untouched`() {
val error = IOException("No space left on device")
CommunicationError.handleError(error, null)
CommunicationError(activeServerProvider).handleError(error, null)
verify(activeServerProvider).reportNetworkFailure(error, false)
}