diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt
index 001ee99c..addafac5 100644
--- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt
+++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt
@@ -155,11 +155,17 @@ class SubsonicAPIClient(
return call.toStreamResponse()
}
+ val isOffline by lazy {
+ config.baseUrl == OFFLINE_DB_URL
+ }
+
companion object {
val jacksonMapper: ObjectMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
.registerModule(KotlinModule.Builder().build())
+
+ const val OFFLINE_DB_URL = "http://localhost"
}
}
diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml
index bb988f23..33ca3828 100644
--- a/ultrasonic/lint-baseline.xml
+++ b/ultrasonic/lint-baseline.xml
@@ -12,17 +12,6 @@
column="5"/>
-
-
-
-
@@ -297,7 +286,7 @@
errorLine2=" ~~~~~~~~">
@@ -374,7 +363,7 @@
errorLine2=" ~~~~~~~~">
@@ -400,17 +389,6 @@
column="13"/>
-
-
-
-
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt
index 6206816e..9073d545 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt
@@ -28,6 +28,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentContainerView
+import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_READY
@@ -39,10 +40,11 @@ import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.onNavDestinationSelected
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
-import androidx.preference.PreferenceManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.navigation.NavigationView
import io.reactivex.rxjava3.disposables.CompositeDisposable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections
@@ -100,7 +102,6 @@ class NavigationActivity : AppCompatActivity() {
private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject()
- private var infoDialogDisplayed = false
private var currentFragmentId: Int = 0
private var cachedServerCount: Int = 0
@@ -177,15 +178,7 @@ class NavigationActivity : AppCompatActivity() {
}
// Determine if this is a first run
- val showWelcomeScreen = Util.isFirstRun()
-
- // Migrate Feature storage if needed
- // TODO: Remove in December 2022
- if (!Settings.hasKey(Constants.PREFERENCES_KEY_USE_FIVE_STAR_RATING)) {
- Settings.migrateFeatureStorage()
- }
-
- loadSettings()
+ val showWelcomeScreen = UApp.instance!!.isFirstRun
// This is a first run with only the demo entry inside the database
// We set the active server to the demo one and show the welcome dialog
@@ -227,6 +220,11 @@ class NavigationActivity : AppCompatActivity() {
super.onResume()
Storage.reset()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ Storage.ensureRootIsAvailable()
+ }
+
setMenuForServerCapabilities()
// Lifecycle support's constructor registers some event receivers so it should be created early
@@ -402,12 +400,12 @@ class NavigationActivity : AppCompatActivity() {
*/
override fun attachBaseContext(newBase: Context?) {
val locale = Settings.overrideLanguage
- val localeUpdatedContext: ContextWrapper = LocaleHelper.wrap(newBase, locale)
- super.attachBaseContext(localeUpdatedContext)
- }
-
- private fun loadSettings() {
- PreferenceManager.setDefaultValues(this, R.xml.settings, false)
+ if (locale.isNotEmpty()) {
+ val localeUpdatedContext: ContextWrapper = LocaleHelper.wrap(newBase, locale)
+ super.attachBaseContext(localeUpdatedContext)
+ } else {
+ super.attachBaseContext(newBase)
+ }
}
private fun exit() {
@@ -423,8 +421,7 @@ class NavigationActivity : AppCompatActivity() {
}
private fun showWelcomeDialog() {
- if (!infoDialogDisplayed) {
- infoDialogDisplayed = true
+ if (!UApp.instance!!.setupDialogDisplayed) {
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
@@ -432,11 +429,13 @@ class NavigationActivity : AppCompatActivity() {
.setTitle(R.string.main_welcome_title)
.setMessage(R.string.main_welcome_text_demo)
.setNegativeButton(R.string.main_welcome_cancel) { dialog, _ ->
+ UApp.instance!!.setupDialogDisplayed = true
// Go to the settings screen
dialog.dismiss()
findNavController(R.id.nav_host_fragment).navigate(R.id.serverSelectorFragment)
}
.setPositiveButton(R.string.common_ok) { dialog, _ ->
+ UApp.instance!!.setupDialogDisplayed = true
// Add the demo server
val activeServerProvider: ActiveServerProvider by inject()
val demoIndex = serverSettingsModel.addDemoServer()
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt
index f69aa325..b85308f7 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/ArtistRowBinder.kt
@@ -107,7 +107,7 @@ class ArtistRowBinder(
if (name.isEmpty()) return SECTION_KEY_DEFAULT
val section = name.first().uppercaseChar()
if (!section.isLetter()) return SECTION_KEY_DEFAULT
- return Util.stripAccents(section.toString())!!
+ return Util.stripAccents(section.toString())
}
private fun showArtistPicture(): Boolean {
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt
index 14da6243..c6663a12 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt
@@ -1,7 +1,10 @@
package org.moire.ultrasonic.app
import android.content.Context
+import android.os.Build
import android.os.StrictMode
+import android.os.StrictMode.ThreadPolicy
+import android.os.StrictMode.VmPolicy
import androidx.multidex.MultiDexApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -18,6 +21,7 @@ import org.moire.ultrasonic.di.musicServiceModule
import org.moire.ultrasonic.log.FileLoggerTree
import org.moire.ultrasonic.log.TimberKoinLogger
import org.moire.ultrasonic.util.Settings
+import org.moire.ultrasonic.util.Util
import timber.log.Timber
import timber.log.Timber.DebugTree
@@ -31,11 +35,15 @@ class UApp : MultiDexApplication() {
init {
instance = this
- if (BuildConfig.DEBUG)
- StrictMode.enableDefaults()
+ if (BuildConfig.DEBUG) {
+ StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
+ StrictMode.setVmPolicy(VmPolicy.Builder().detectAllExceptSocket().penaltyLog().build())
+ }
}
var initiated = false
+ var isFirstRun = false
+ var setupDialogDisplayed = false
override fun onCreate() {
initiated = true
@@ -52,6 +60,7 @@ class UApp : MultiDexApplication() {
if (Settings.debugLogToFile) {
FileLoggerTree.plantToTimberForest()
}
+ isFirstRun = Util.isFirstRun()
}
startKoin()
@@ -92,3 +101,23 @@ class UApp : MultiDexApplication() {
}
}
}
+
+private fun VmPolicy.Builder.detectAllExceptSocket(): VmPolicy.Builder {
+ detectLeakedSqlLiteObjects()
+ detectActivityLeaks()
+ detectLeakedClosableObjects()
+ detectLeakedRegistrationObjects()
+ detectFileUriExposure()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ detectContentUriWithoutPermission()
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ detectCredentialProtectedWhileLocked()
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ detectUnsafeIntentLaunch()
+ detectIncorrectContextUse()
+ }
+ return this
+}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/audiofx/EqualizerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/audiofx/EqualizerController.kt
index 5c9630d5..80b25bbc 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/audiofx/EqualizerController.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/audiofx/EqualizerController.kt
@@ -11,6 +11,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import java.io.Serializable
import java.lang.Exception
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.util.FileUtil.deserialize
import org.moire.ultrasonic.util.FileUtil.serialize
@@ -21,7 +24,7 @@ import timber.log.Timber
*
* TODO: Maybe store the settings in the DB?
*/
-class EqualizerController {
+class EqualizerController : CoroutineScope by CoroutineScope(Dispatchers.IO) {
@JvmField
var equalizer: Equalizer? = null
@@ -29,22 +32,30 @@ class EqualizerController {
fun saveSettings() {
if (equalizer == null) return
- try {
- serialize(UApp.applicationContext(), EqualizerSettings(equalizer!!), "equalizer.dat")
- } catch (all: Throwable) {
- Timber.w(all, "Failed to save equalizer settings.")
+ launch {
+ try {
+ serialize(
+ UApp.applicationContext(),
+ EqualizerSettings(equalizer!!),
+ "equalizer.dat"
+ )
+ } catch (all: Throwable) {
+ Timber.w(all, "Failed to save equalizer settings.")
+ }
}
}
fun loadSettings() {
if (equalizer == null) return
- try {
- val settings = deserialize(
- UApp.applicationContext(), "equalizer.dat"
- )
- settings?.apply(equalizer!!)
- } catch (all: Throwable) {
- Timber.w(all, "Failed to load equalizer settings.")
+ launch {
+ try {
+ val settings = deserialize(
+ UApp.applicationContext(), "equalizer.dat"
+ )
+ settings?.apply(equalizer!!)
+ } catch (all: Throwable) {
+ Timber.w(all, "Failed to load equalizer settings.")
+ }
}
}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt
index 34903985..4b94c4ba 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ActiveServerProvider.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R
+import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient.Companion.OFFLINE_DB_URL
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.di.DB_FILENAME
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
@@ -202,7 +203,7 @@ class ActiveServerProvider(
id = OFFLINE_DB_ID,
index = OFFLINE_DB_INDEX,
name = UApp.applicationContext().getString(R.string.main_offline),
- url = "http://localhost",
+ url = OFFLINE_DB_URL,
userName = "",
password = "",
jukeboxByDefault = false,
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt
index cc49dfa0..0b8e2b7b 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/AlbumDao.kt
@@ -61,6 +61,12 @@ interface AlbumDao : GenericDao {
@Query("DELETE FROM albums WHERE artistId LIKE :id")
fun clearByArtist(id: String)
+ /**
+ * Clear albums by artist
+ */
+ @Query("DELETE FROM albums WHERE id LIKE :id")
+ fun delete(id: String)
+
/**
* TODO: Make generic
* Upserts (insert or update) an object to the database
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ArtistDao.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ArtistDao.kt
index 9b2dc395..a5758a5f 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ArtistDao.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/data/ArtistDao.kt
@@ -1,6 +1,7 @@
package org.moire.ultrasonic.data
import androidx.room.Dao
+import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@@ -44,4 +45,19 @@ interface ArtistDao {
*/
@Query("SELECT * FROM artists WHERE id LIKE :id")
fun get(id: String): Artist?
+
+ /**
+ * Delete an object from the database
+ *
+ * @param obj the object to be deleted
+ */
+ @Delete
+ @JvmSuppressWildcards
+ fun delete(obj: Artist)
+
+ /**
+ * Delete artist by id
+ */
+ @Query("DELETE FROM artists WHERE id LIKE :id")
+ fun delete(id: String)
}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt
index e1c32c2f..ccfce111 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt
@@ -413,6 +413,7 @@ class SettingsFragment :
// Clear download queue.
mediaPlayerController.clear()
Storage.reset()
+ Storage.ensureRootIsAvailable()
}
private fun setDebugLogToFile(writeLog: Boolean) {
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt
index 0fff2bd1..7da8c6f9 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt
@@ -85,7 +85,6 @@ class BitmapUtils {
} catch (expected: Exception) {
Timber.e(expected, "Exception in BitmapFactory.decodeFile()")
}
- Timber.i("getBitmapFromDisk %s", size.toString())
return bitmap1
}
}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt
index 9fcec91c..150e42a3 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt
@@ -7,7 +7,6 @@ import com.squareup.picasso.RequestHandler
import java.io.IOException
import okio.source
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
-import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE
import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL
@@ -28,16 +27,24 @@ class CoverArtRequestHandler(private val client: SubsonicAPIClient) : RequestHan
val size = request.uri.getQueryParameter(SIZE)?.toLong()
// Check if we have a hit in the disk cache
- // Note: Currently we are only caching full size images on disk
- // So we modify the key to query for the full size image,
+ // First check for a large and fallback to the small size.
// because scaling down a larger size image on the device is quicker than
// requesting the down-sized image from the network.
- val key = request.stableKey!!.replace(SUFFIX_SMALL, SUFFIX_LARGE)
- val cache = BitmapUtils.getAlbumArtBitmapFromDisk(key, size?.toInt())
+ val key = request.stableKey!!
+ val largeKey = key.replace(SUFFIX_SMALL, SUFFIX_LARGE)
+ var cache = BitmapUtils.getAlbumArtBitmapFromDisk(largeKey, size?.toInt())
+ if (cache == null && key != largeKey) {
+ cache = BitmapUtils.getAlbumArtBitmapFromDisk(key, size?.toInt())
+ }
if (cache != null) {
return Result(cache, DISK)
}
+ // Cancel early if we are offline
+ if (client.isOffline) {
+ throw UnsupportedOperationException()
+ }
+
// Try to fetch the image from the API
// Inverted call order, because Mockito has problems with chained calls.
val response = client.toStreamResponse(client.api.getCoverArt(id, size).execute())
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt
index 40908d1b..f3ed199e 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt
@@ -20,6 +20,7 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
+import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.FileUtil
@@ -176,6 +177,13 @@ class ImageLoader(
}
}
+ fun cacheArtistPicture(artist: Artist) {
+ if (artist.coverArt == null) return
+ val key = FileUtil.getArtistArtKey(artist.name, false)
+ val file = FileUtil.getAlbumArtFile(key)
+ cacheCoverArt(artist.coverArt!!, file)
+ }
+
/**
* Download a cover art file of a Track and cache it on disk
*/
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt
index ad9ceacd..bbe91de7 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt
@@ -24,6 +24,9 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import io.reactivex.rxjava3.disposables.CompositeDisposable
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.activity.NavigationActivity
@@ -44,7 +47,10 @@ import org.moire.ultrasonic.util.toTrack
import timber.log.Timber
@SuppressLint("UnsafeOptInUsageError")
-class PlaybackService : MediaLibraryService(), KoinComponent {
+class PlaybackService :
+ MediaLibraryService(),
+ KoinComponent,
+ CoroutineScope by CoroutineScope(Dispatchers.IO) {
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private var equalizer: EqualizerController? = null
@@ -192,7 +198,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
player.currentMediaItemIndex,
Settings.preloadCount
).map { it.toTrack() }
- DownloadService.download(nextSongs, save = false, isHighPriority = true)
+
+ launch {
+ DownloadService.download(nextSongs, save = false, isHighPriority = true)
+ }
}
private fun getPendingIntentForContent(): PendingIntent {
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt
index 91f19fed..4c34c1fe 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt
@@ -30,6 +30,7 @@ import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState
+import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile
@@ -365,6 +366,7 @@ class DownloadService : Service(), KoinComponent {
Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile())
postState(track, DownloadState.IDLE)
+ CacheCleaner().cleanDatabaseSelective(track)
Util.scanMedia(track.getPinnedFile())
}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt
index 883e8816..7d52c23a 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt
@@ -188,6 +188,8 @@ class DownloadTask(
val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId
var album: Album? = null
+ var directArtist: Artist? = null
+ var compilationArtist: Artist? = null
// Sometime in compilation albums, the individual tracks won't have an Artist id
// In this case, try to get the ArtistId of the album...
@@ -198,7 +200,7 @@ class DownloadTask(
// Cache the artist
if (artistId != null)
- cacheArtist(onlineDB, offlineDB, artistId)
+ directArtist = cacheArtist(onlineDB, offlineDB, artistId)
// Now cache the album
if (albumId != null) {
@@ -216,7 +218,7 @@ class DownloadTask(
// If the album is a Compilation, also cache the Album artist
if (album.artistId != null && album.artistId != artistId)
- cacheArtist(onlineDB, offlineDB, album.artistId!!)
+ compilationArtist = cacheArtist(onlineDB, offlineDB, album.artistId!!)
}
}
@@ -224,10 +226,20 @@ class DownloadTask(
offlineDB.trackDao().insert(this)
// Download the largest size that we can display in the UI
- imageLoaderProvider.getImageLoader().cacheCoverArt(this)
+ val imageLoader = imageLoaderProvider.getImageLoader()
+ imageLoader.cacheCoverArt(this)
+
+ // Cache small copies of the Artist picture
+
+ directArtist?.let { imageLoader.cacheArtistPicture(it) }
+ compilationArtist?.let { imageLoader.cacheArtistPicture(it) }
}
- private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) {
+ private fun cacheArtist(
+ onlineDB: MetaDatabase,
+ offlineDB: MetaDatabase,
+ artistId: String
+ ): Artist? {
var artist: Artist? = onlineDB.artistDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
@@ -243,6 +255,8 @@ class DownloadTask(
if (artist != null) {
offlineDB.artistDao().insert(artist)
}
+
+ return artist
}
@Throws(IOException::class)
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt
index 84a89986..ec5bda36 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ImageLoaderProvider.kt
@@ -2,6 +2,9 @@ package org.moire.ultrasonic.subsonic
import android.content.Context
import androidx.core.content.res.ResourcesCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.qualifier.named
@@ -15,7 +18,9 @@ import org.moire.ultrasonic.util.Util
/**
* Handles the lifetime of the Image Loader
*/
-class ImageLoaderProvider(val context: Context) : KoinComponent {
+class ImageLoaderProvider(val context: Context) :
+ KoinComponent,
+ CoroutineScope by CoroutineScope(Dispatchers.IO) {
private var imageLoader: ImageLoader? = null
private var serverID: String = get(named("ServerID"))
@@ -32,6 +37,11 @@ class ImageLoaderProvider(val context: Context) : KoinComponent {
imageLoader = get()
serverID = currentID
}
+
+ launch {
+ FileUtil.ensureAlbumArtDirectory()
+ }
+
return imageLoader!!
}
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt
index dc9f4309..c5325ad5 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt
@@ -8,16 +8,17 @@
package org.moire.ultrasonic.util
import android.system.Os
-import java.util.ArrayList
-import java.util.HashSet
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future
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.domain.Playlist
+import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
@@ -35,9 +36,10 @@ import timber.log.Timber
/**
* Responsible for cleaning up files from the offline download cache on the filesystem.
*/
-class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
+class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinComponent {
private var mainScope = CoroutineScope(Dispatchers.Main)
+ private val activeServerProvider by inject()
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, exception ->
@@ -54,6 +56,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
cleaning = true
launch(exceptionHandler("clean")) {
backgroundCleanup()
+ backgroundCleanWholeDatabase()
}
}
}
@@ -80,6 +83,94 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
}
}
+ private fun backgroundCleanWholeDatabase() {
+ val offlineDB = activeServerProvider.offlineMetaDatabase
+
+ Timber.i("Starting Database cleanup")
+
+ // Get all tracks in the db
+ val tracks = offlineDB.trackDao().get().toMutableSet()
+
+ // Check all tracks if they have files
+ val orphanedTracks = tracks.filter {
+ !Storage.isPathExists(it.getPinnedFile()) && !Storage.isPathExists(it.getCompleteFile())
+ }
+
+ // Delete orphaned tracks
+ orphanedTracks.forEach {
+ offlineDB.trackDao().delete(it)
+ }
+
+ // Remove deleted tracks from our list
+ tracks -= orphanedTracks.toSet()
+
+ // Check for orphaned Albums
+ val usedAlbumIds = tracks.mapNotNull { it.albumId }.toSet()
+ val albums = offlineDB.albumDao().get().toMutableSet()
+ val orphanedAlbums = albums.filterNot {
+ usedAlbumIds.contains(it.id)
+ }.toSet()
+
+ // Delete orphaned Albums
+ orphanedAlbums.forEach {
+ offlineDB.albumDao().delete(it)
+ }
+
+ albums -= orphanedAlbums
+
+ // Check for orphaned Artists
+ val usedArtistsIds = tracks.mapNotNull { it.artistId } + albums.mapNotNull { it.artistId }
+ val artists = offlineDB.artistDao().get().toSet()
+ val orphanedArtists = artists.filterNot {
+ usedArtistsIds.contains(it.id)
+ }
+
+ // Delete orphaned Artists
+ orphanedArtists.forEach {
+ offlineDB.artistDao().delete(it)
+ }
+
+ Timber.e("Database cleanup done")
+ }
+
+ fun cleanDatabaseSelective(trackToRemove: Track) {
+ launch(exceptionHandler("cleanDatabase")) {
+ backgroundDatabaseSelective(trackToRemove)
+ }
+ }
+
+ private fun backgroundDatabaseSelective(track: Track) {
+ val offlineDB = activeServerProvider.offlineMetaDatabase
+
+ // Delete track
+ offlineDB.trackDao().delete(track)
+
+ // Setup up artistList
+ val artistsToCheck = mutableListOf(track.artistId)
+
+ // Check if we have other tracks for the album
+ var albumToDelete: String? = null
+ if (track.albumId != null) {
+ val tracks = offlineDB.trackDao().byAlbum(track.albumId!!)
+ if (tracks.isEmpty()) albumToDelete = track.albumId!!
+ }
+
+ // Delete empty album
+ if (albumToDelete != null) {
+ val album = offlineDB.albumDao().get(albumToDelete)
+ if (album != null) {
+ artistsToCheck.add(album.artistId)
+ offlineDB.albumDao().delete(album)
+ }
+ }
+
+ // Check if we have an empty artist now..
+ artistsToCheck.filterNotNull().forEach {
+ val tracks = offlineDB.trackDao().byArtist(it)
+ if (tracks.isEmpty()) offlineDB.artistDao().delete(it)
+ }
+ }
+
private fun backgroundCleanup() {
try {
val files: MutableList = ArrayList()
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt
index 854660a0..c3f308bd 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt
@@ -199,13 +199,17 @@ object FileUtil {
return "$albumArtDir/$cacheKey"
}
+ /**
+ * Get the album art directory quickly, without checking that it exists.
+ */
val albumArtDirectory: File
- get() {
- val albumArtDir = File(ultrasonicDirectory, "artwork")
- ensureDirectoryExistsAndIsReadWritable(albumArtDir)
- ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
- return albumArtDir
- }
+ get() = File(ultrasonicDirectory, "artwork")
+
+ fun ensureAlbumArtDirectory() {
+ val albumArtDir = albumArtDirectory
+ ensureDirectoryExistsAndIsReadWritable(albumArtDir)
+ ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
+ }
private fun getAlbumDirectory(entry: MusicDirectory.Child): String {
val dir: String
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt
index a6794159..a1cf3602 100644
--- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt
+++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt
@@ -29,6 +29,9 @@ object Storage {
StorageFile.notExistingPathDictionary.clear()
mediaRoot.reset()
Timber.i("StorageFile caches were reset")
+ }
+
+ fun ensureRootIsAvailable() {
val root = getRoot()
if (root == null) {
Settings.customCacheLocation = false
diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml
index 9f792691..ee11bda0 100644
--- a/ultrasonic/src/main/res/xml/settings.xml
+++ b/ultrasonic/src/main/res/xml/settings.xml
@@ -33,7 +33,7 @@
a:title="@string/settings.use_folder_for_album_artist"
app:iconSpaceReserved="false"/>