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"/>