Bunch of fixes 💐

This commit is contained in:
birdbird 2022-10-18 15:28:37 +00:00
parent 5222e952aa
commit 548ecc6517
22 changed files with 278 additions and 84 deletions

View File

@ -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"
}
}

View File

@ -12,17 +12,6 @@
column="5"/>
</issue>
<issue
id="AllowAllHostnameVerifier"
message="Using the `AllowAllHostnameVerifier` HostnameVerifier is unsafe because it always returns true, which could cause insecure network traffic due to trusting TLS/SSL server certificates for wrong hostnames"
errorLine1=" public static final X509HostnameVerifier ALLOW_ALL_HOSTNAME_VERIFIER = new AllowAllHostnameVerifier();"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/org/moire/ultrasonic/service/ssl/SSLSocketFactory.java"
line="142"
column="73"/>
</issue>
<issue
id="TrustAllX509TrustManager"
message="`checkClientTrusted` is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers">
@ -297,7 +286,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="30"
line="29"
column="10"/>
</issue>
@ -374,7 +363,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/share_details.xml"
line="30"
line="29"
column="10"/>
</issue>
@ -400,17 +389,6 @@
column="13"/>
</issue>
<issue
id="HardcodedText"
message="Hardcoded string &quot;http://&quot;, should use `@string` resource"
errorLine1=" a:text=&quot;http://&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/server_edit.xml"
line="43"
column="13"/>
</issue>
<issue
id="RelativeOverlap"
message="`@id/current_playing_duration` can overlap `@id/current_playing_position` if @string/util.no_time, @string/util.no_time grow due to localized text expansion"

View File

@ -127,12 +127,12 @@
</receiver>
<provider
android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
android:authorities="${applicationId}.provider.SearchSuggestionProvider"
android:exported="true" />
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="org.moire.ultrasonic.provider.AlbumArtContentProvider"
android:authorities="${applicationId}.provider.AlbumArtContentProvider"
android:exported="true" />
</application>

View File

@ -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()

View File

@ -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 {

View File

@ -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
}

View File

@ -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<EqualizerSettings>(
UApp.applicationContext(), "equalizer.dat"
)
settings?.apply(equalizer!!)
} catch (all: Throwable) {
Timber.w(all, "Failed to load equalizer settings.")
launch {
try {
val settings = deserialize<EqualizerSettings>(
UApp.applicationContext(), "equalizer.dat"
)
settings?.apply(equalizer!!)
} catch (all: Throwable) {
Timber.w(all, "Failed to load equalizer settings.")
}
}
}

View File

@ -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,

View File

@ -61,6 +61,12 @@ interface AlbumDao : GenericDao<Album> {
@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

View File

@ -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)
}

View File

@ -413,6 +413,7 @@ class SettingsFragment :
// Clear download queue.
mediaPlayerController.clear()
Storage.reset()
Storage.ensureRootIsAvailable()
}
private fun setDebugLogToFile(writeLog: Boolean) {

View File

@ -85,7 +85,6 @@ class BitmapUtils {
} catch (expected: Exception) {
Timber.e(expected, "Exception in BitmapFactory.decodeFile()")
}
Timber.i("getBitmapFromDisk %s", size.toString())
return bitmap1
}
}

View File

@ -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())

View File

@ -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
*/

View File

@ -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 {

View File

@ -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())
}

View File

@ -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)

View File

@ -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!!
}

View File

@ -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<ActiveServerProvider>()
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<AbstractFile> = ArrayList()

View File

@ -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

View File

@ -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

View File

@ -33,7 +33,7 @@
a:title="@string/settings.use_folder_for_album_artist"
app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:defaultValue="true"
a:defaultValue="false"
a:key="@string/setting_key.show_track_number"
a:summary="@string/settings.show_track_number_summary"
a:title="@string/settings.show_track_number"