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() return call.toStreamResponse()
} }
val isOffline by lazy {
config.baseUrl == OFFLINE_DB_URL
}
companion object { companion object {
val jacksonMapper: ObjectMapper = ObjectMapper() val jacksonMapper: ObjectMapper = ObjectMapper()
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
.registerModule(KotlinModule.Builder().build()) .registerModule(KotlinModule.Builder().build())
const val OFFLINE_DB_URL = "http://localhost"
} }
} }

View File

@ -12,17 +12,6 @@
column="5"/> column="5"/>
</issue> </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 <issue
id="TrustAllX509TrustManager" id="TrustAllX509TrustManager"
message="`checkClientTrusted` is empty, which could cause insecure network traffic due to trusting arbitrary TLS/SSL certificates presented by peers"> 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=" ~~~~~~~~"> errorLine2=" ~~~~~~~~">
<location <location
file="src/main/res/layout/share_details.xml" file="src/main/res/layout/share_details.xml"
line="30" line="29"
column="10"/> column="10"/>
</issue> </issue>
@ -374,7 +363,7 @@
errorLine2=" ~~~~~~~~"> errorLine2=" ~~~~~~~~">
<location <location
file="src/main/res/layout/share_details.xml" file="src/main/res/layout/share_details.xml"
line="30" line="29"
column="10"/> column="10"/>
</issue> </issue>
@ -400,17 +389,6 @@
column="13"/> column="13"/>
</issue> </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 <issue
id="RelativeOverlap" 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" 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> </receiver>
<provider <provider
android:name=".provider.SearchSuggestionProvider" android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider" android:authorities="${applicationId}.provider.SearchSuggestionProvider"
android:exported="true" /> android:exported="true" />
<provider <provider
android:name=".provider.AlbumArtContentProvider" android:name=".provider.AlbumArtContentProvider"
android:authorities="org.moire.ultrasonic.provider.AlbumArtContentProvider" android:authorities="${applicationId}.provider.AlbumArtContentProvider"
android:exported="true" /> android:exported="true" />
</application> </application>

View File

@ -28,6 +28,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentContainerView
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player.STATE_BUFFERING import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_READY 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.onNavDestinationSelected
import androidx.navigation.ui.setupActionBarWithNavController import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.moire.ultrasonic.NavigationGraphDirections import org.moire.ultrasonic.NavigationGraphDirections
@ -100,7 +102,6 @@ class NavigationActivity : AppCompatActivity() {
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject() private val serverRepository: ServerSettingDao by inject()
private var infoDialogDisplayed = false
private var currentFragmentId: Int = 0 private var currentFragmentId: Int = 0
private var cachedServerCount: Int = 0 private var cachedServerCount: Int = 0
@ -177,15 +178,7 @@ class NavigationActivity : AppCompatActivity() {
} }
// Determine if this is a first run // Determine if this is a first run
val showWelcomeScreen = Util.isFirstRun() val showWelcomeScreen = UApp.instance!!.isFirstRun
// Migrate Feature storage if needed
// TODO: Remove in December 2022
if (!Settings.hasKey(Constants.PREFERENCES_KEY_USE_FIVE_STAR_RATING)) {
Settings.migrateFeatureStorage()
}
loadSettings()
// This is a first run with only the demo entry inside the database // 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 // We set the active server to the demo one and show the welcome dialog
@ -227,6 +220,11 @@ class NavigationActivity : AppCompatActivity() {
super.onResume() super.onResume()
Storage.reset() Storage.reset()
lifecycleScope.launch(Dispatchers.IO) {
Storage.ensureRootIsAvailable()
}
setMenuForServerCapabilities() setMenuForServerCapabilities()
// Lifecycle support's constructor registers some event receivers so it should be created early // 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?) { override fun attachBaseContext(newBase: Context?) {
val locale = Settings.overrideLanguage val locale = Settings.overrideLanguage
val localeUpdatedContext: ContextWrapper = LocaleHelper.wrap(newBase, locale) if (locale.isNotEmpty()) {
super.attachBaseContext(localeUpdatedContext) val localeUpdatedContext: ContextWrapper = LocaleHelper.wrap(newBase, locale)
} super.attachBaseContext(localeUpdatedContext)
} else {
private fun loadSettings() { super.attachBaseContext(newBase)
PreferenceManager.setDefaultValues(this, R.xml.settings, false) }
} }
private fun exit() { private fun exit() {
@ -423,8 +421,7 @@ class NavigationActivity : AppCompatActivity() {
} }
private fun showWelcomeDialog() { private fun showWelcomeDialog() {
if (!infoDialogDisplayed) { if (!UApp.instance!!.setupDialogDisplayed) {
infoDialogDisplayed = true
Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext()) Settings.firstInstalledVersion = Util.getVersionCode(UApp.applicationContext())
@ -432,11 +429,13 @@ class NavigationActivity : AppCompatActivity() {
.setTitle(R.string.main_welcome_title) .setTitle(R.string.main_welcome_title)
.setMessage(R.string.main_welcome_text_demo) .setMessage(R.string.main_welcome_text_demo)
.setNegativeButton(R.string.main_welcome_cancel) { dialog, _ -> .setNegativeButton(R.string.main_welcome_cancel) { dialog, _ ->
UApp.instance!!.setupDialogDisplayed = true
// Go to the settings screen // Go to the settings screen
dialog.dismiss() dialog.dismiss()
findNavController(R.id.nav_host_fragment).navigate(R.id.serverSelectorFragment) findNavController(R.id.nav_host_fragment).navigate(R.id.serverSelectorFragment)
} }
.setPositiveButton(R.string.common_ok) { dialog, _ -> .setPositiveButton(R.string.common_ok) { dialog, _ ->
UApp.instance!!.setupDialogDisplayed = true
// Add the demo server // Add the demo server
val activeServerProvider: ActiveServerProvider by inject() val activeServerProvider: ActiveServerProvider by inject()
val demoIndex = serverSettingsModel.addDemoServer() val demoIndex = serverSettingsModel.addDemoServer()

View File

@ -107,7 +107,7 @@ class ArtistRowBinder(
if (name.isEmpty()) return SECTION_KEY_DEFAULT if (name.isEmpty()) return SECTION_KEY_DEFAULT
val section = name.first().uppercaseChar() val section = name.first().uppercaseChar()
if (!section.isLetter()) return SECTION_KEY_DEFAULT if (!section.isLetter()) return SECTION_KEY_DEFAULT
return Util.stripAccents(section.toString())!! return Util.stripAccents(section.toString())
} }
private fun showArtistPicture(): Boolean { private fun showArtistPicture(): Boolean {

View File

@ -1,7 +1,10 @@
package org.moire.ultrasonic.app package org.moire.ultrasonic.app
import android.content.Context import android.content.Context
import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import androidx.multidex.MultiDexApplication import androidx.multidex.MultiDexApplication
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.FileLoggerTree
import org.moire.ultrasonic.log.TimberKoinLogger import org.moire.ultrasonic.log.TimberKoinLogger
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
import timber.log.Timber.DebugTree import timber.log.Timber.DebugTree
@ -31,11 +35,15 @@ class UApp : MultiDexApplication() {
init { init {
instance = this instance = this
if (BuildConfig.DEBUG) if (BuildConfig.DEBUG) {
StrictMode.enableDefaults() StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
StrictMode.setVmPolicy(VmPolicy.Builder().detectAllExceptSocket().penaltyLog().build())
}
} }
var initiated = false var initiated = false
var isFirstRun = false
var setupDialogDisplayed = false
override fun onCreate() { override fun onCreate() {
initiated = true initiated = true
@ -52,6 +60,7 @@ class UApp : MultiDexApplication() {
if (Settings.debugLogToFile) { if (Settings.debugLogToFile) {
FileLoggerTree.plantToTimberForest() FileLoggerTree.plantToTimberForest()
} }
isFirstRun = Util.isFirstRun()
} }
startKoin() 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 androidx.lifecycle.MutableLiveData
import java.io.Serializable import java.io.Serializable
import java.lang.Exception 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.app.UApp
import org.moire.ultrasonic.util.FileUtil.deserialize import org.moire.ultrasonic.util.FileUtil.deserialize
import org.moire.ultrasonic.util.FileUtil.serialize import org.moire.ultrasonic.util.FileUtil.serialize
@ -21,7 +24,7 @@ import timber.log.Timber
* *
* TODO: Maybe store the settings in the DB? * TODO: Maybe store the settings in the DB?
*/ */
class EqualizerController { class EqualizerController : CoroutineScope by CoroutineScope(Dispatchers.IO) {
@JvmField @JvmField
var equalizer: Equalizer? = null var equalizer: Equalizer? = null
@ -29,22 +32,30 @@ class EqualizerController {
fun saveSettings() { fun saveSettings() {
if (equalizer == null) return if (equalizer == null) return
try { launch {
serialize(UApp.applicationContext(), EqualizerSettings(equalizer!!), "equalizer.dat") try {
} catch (all: Throwable) { serialize(
Timber.w(all, "Failed to save equalizer settings.") UApp.applicationContext(),
EqualizerSettings(equalizer!!),
"equalizer.dat"
)
} catch (all: Throwable) {
Timber.w(all, "Failed to save equalizer settings.")
}
} }
} }
fun loadSettings() { fun loadSettings() {
if (equalizer == null) return if (equalizer == null) return
try { launch {
val settings = deserialize<EqualizerSettings>( try {
UApp.applicationContext(), "equalizer.dat" val settings = deserialize<EqualizerSettings>(
) UApp.applicationContext(), "equalizer.dat"
settings?.apply(equalizer!!) )
} catch (all: Throwable) { settings?.apply(equalizer!!)
Timber.w(all, "Failed to load equalizer settings.") } 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.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.moire.ultrasonic.R 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.app.UApp
import org.moire.ultrasonic.di.DB_FILENAME import org.moire.ultrasonic.di.DB_FILENAME
import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService import org.moire.ultrasonic.service.MusicServiceFactory.resetMusicService
@ -202,7 +203,7 @@ class ActiveServerProvider(
id = OFFLINE_DB_ID, id = OFFLINE_DB_ID,
index = OFFLINE_DB_INDEX, index = OFFLINE_DB_INDEX,
name = UApp.applicationContext().getString(R.string.main_offline), name = UApp.applicationContext().getString(R.string.main_offline),
url = "http://localhost", url = OFFLINE_DB_URL,
userName = "", userName = "",
password = "", password = "",
jukeboxByDefault = false, jukeboxByDefault = false,

View File

@ -61,6 +61,12 @@ interface AlbumDao : GenericDao<Album> {
@Query("DELETE FROM albums WHERE artistId LIKE :id") @Query("DELETE FROM albums WHERE artistId LIKE :id")
fun clearByArtist(id: String) fun clearByArtist(id: String)
/**
* Clear albums by artist
*/
@Query("DELETE FROM albums WHERE id LIKE :id")
fun delete(id: String)
/** /**
* TODO: Make generic * TODO: Make generic
* Upserts (insert or update) an object to the database * Upserts (insert or update) an object to the database

View File

@ -1,6 +1,7 @@
package org.moire.ultrasonic.data package org.moire.ultrasonic.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -44,4 +45,19 @@ interface ArtistDao {
*/ */
@Query("SELECT * FROM artists WHERE id LIKE :id") @Query("SELECT * FROM artists WHERE id LIKE :id")
fun get(id: String): Artist? 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. // Clear download queue.
mediaPlayerController.clear() mediaPlayerController.clear()
Storage.reset() Storage.reset()
Storage.ensureRootIsAvailable()
} }
private fun setDebugLogToFile(writeLog: Boolean) { private fun setDebugLogToFile(writeLog: Boolean) {

View File

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

View File

@ -7,7 +7,6 @@ import com.squareup.picasso.RequestHandler
import java.io.IOException import java.io.IOException
import okio.source import okio.source
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient 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_LARGE
import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL 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() val size = request.uri.getQueryParameter(SIZE)?.toLong()
// Check if we have a hit in the disk cache // Check if we have a hit in the disk cache
// Note: Currently we are only caching full size images on disk // First check for a large and fallback to the small size.
// So we modify the key to query for the full size image,
// because scaling down a larger size image on the device is quicker than // because scaling down a larger size image on the device is quicker than
// requesting the down-sized image from the network. // requesting the down-sized image from the network.
val key = request.stableKey!!.replace(SUFFIX_SMALL, SUFFIX_LARGE) val key = request.stableKey!!
val cache = BitmapUtils.getAlbumArtBitmapFromDisk(key, size?.toInt()) 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) { if (cache != null) {
return Result(cache, DISK) return Result(cache, DISK)
} }
// Cancel early if we are offline
if (client.isOffline) {
throw UnsupportedOperationException()
}
// Try to fetch the image from the API // Try to fetch the image from the API
// Inverted call order, because Mockito has problems with chained calls. // Inverted call order, because Mockito has problems with chained calls.
val response = client.toStreamResponse(client.api.getCoverArt(id, size).execute()) 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.SubsonicAPIClient
import org.moire.ultrasonic.api.subsonic.throwOnFailure import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.FileUtil 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 * 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.MediaLibraryService
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.activity.NavigationActivity
@ -44,7 +47,10 @@ import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class PlaybackService : MediaLibraryService(), KoinComponent { class PlaybackService :
MediaLibraryService(),
KoinComponent,
CoroutineScope by CoroutineScope(Dispatchers.IO) {
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private var equalizer: EqualizerController? = null private var equalizer: EqualizerController? = null
@ -192,7 +198,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
player.currentMediaItemIndex, player.currentMediaItemIndex,
Settings.preloadCount Settings.preloadCount
).map { it.toTrack() } ).map { it.toTrack() }
DownloadService.download(nextSongs, save = false, isHighPriority = true)
launch {
DownloadService.download(nextSongs, save = false, isHighPriority = true)
}
} }
private fun getPendingIntentForContent(): PendingIntent { 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.app.UApp
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState 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
import org.moire.ultrasonic.util.FileUtil.getCompleteFile import org.moire.ultrasonic.util.FileUtil.getCompleteFile
import org.moire.ultrasonic.util.FileUtil.getPartialFile import org.moire.ultrasonic.util.FileUtil.getPartialFile
@ -365,6 +366,7 @@ class DownloadService : Service(), KoinComponent {
Storage.delete(track.getCompleteFile()) Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile()) Storage.delete(track.getPinnedFile())
postState(track, DownloadState.IDLE) postState(track, DownloadState.IDLE)
CacheCleaner().cleanDatabaseSelective(track)
Util.scanMedia(track.getPinnedFile()) Util.scanMedia(track.getPinnedFile())
} }

View File

@ -188,6 +188,8 @@ class DownloadTask(
val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId
var album: Album? = null 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 // Sometime in compilation albums, the individual tracks won't have an Artist id
// In this case, try to get the ArtistId of the album... // In this case, try to get the ArtistId of the album...
@ -198,7 +200,7 @@ class DownloadTask(
// Cache the artist // Cache the artist
if (artistId != null) if (artistId != null)
cacheArtist(onlineDB, offlineDB, artistId) directArtist = cacheArtist(onlineDB, offlineDB, artistId)
// Now cache the album // Now cache the album
if (albumId != null) { if (albumId != null) {
@ -216,7 +218,7 @@ class DownloadTask(
// If the album is a Compilation, also cache the Album artist // If the album is a Compilation, also cache the Album artist
if (album.artistId != null && album.artistId != artistId) 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) offlineDB.trackDao().insert(this)
// Download the largest size that we can display in the UI // 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) var artist: Artist? = onlineDB.artistDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list // 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) { if (artist != null) {
offlineDB.artistDao().insert(artist) offlineDB.artistDao().insert(artist)
} }
return artist
} }
@Throws(IOException::class) @Throws(IOException::class)

View File

@ -2,6 +2,9 @@ package org.moire.ultrasonic.subsonic
import android.content.Context import android.content.Context
import androidx.core.content.res.ResourcesCompat 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.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
@ -15,7 +18,9 @@ import org.moire.ultrasonic.util.Util
/** /**
* Handles the lifetime of the Image Loader * 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 imageLoader: ImageLoader? = null
private var serverID: String = get(named("ServerID")) private var serverID: String = get(named("ServerID"))
@ -32,6 +37,11 @@ class ImageLoaderProvider(val context: Context) : KoinComponent {
imageLoader = get() imageLoader = get()
serverID = currentID serverID = currentID
} }
launch {
FileUtil.ensureAlbumArtDirectory()
}
return imageLoader!! return imageLoader!!
} }

View File

@ -8,16 +8,17 @@
package org.moire.ultrasonic.util package org.moire.ultrasonic.util
import android.system.Os import android.system.Os
import java.util.ArrayList
import java.util.HashSet
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getCompleteFile 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. * 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 var mainScope = CoroutineScope(Dispatchers.Main)
private val activeServerProvider by inject<ActiveServerProvider>()
private fun exceptionHandler(tag: String): CoroutineExceptionHandler { private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, exception -> return CoroutineExceptionHandler { _, exception ->
@ -54,6 +56,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
cleaning = true cleaning = true
launch(exceptionHandler("clean")) { launch(exceptionHandler("clean")) {
backgroundCleanup() 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() { private fun backgroundCleanup() {
try { try {
val files: MutableList<AbstractFile> = ArrayList() val files: MutableList<AbstractFile> = ArrayList()

View File

@ -199,13 +199,17 @@ object FileUtil {
return "$albumArtDir/$cacheKey" return "$albumArtDir/$cacheKey"
} }
/**
* Get the album art directory quickly, without checking that it exists.
*/
val albumArtDirectory: File val albumArtDirectory: File
get() { get() = File(ultrasonicDirectory, "artwork")
val albumArtDir = File(ultrasonicDirectory, "artwork")
ensureDirectoryExistsAndIsReadWritable(albumArtDir) fun ensureAlbumArtDirectory() {
ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia")) val albumArtDir = albumArtDirectory
return albumArtDir ensureDirectoryExistsAndIsReadWritable(albumArtDir)
} ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
}
private fun getAlbumDirectory(entry: MusicDirectory.Child): String { private fun getAlbumDirectory(entry: MusicDirectory.Child): String {
val dir: String val dir: String

View File

@ -29,6 +29,9 @@ object Storage {
StorageFile.notExistingPathDictionary.clear() StorageFile.notExistingPathDictionary.clear()
mediaRoot.reset() mediaRoot.reset()
Timber.i("StorageFile caches were reset") Timber.i("StorageFile caches were reset")
}
fun ensureRootIsAvailable() {
val root = getRoot() val root = getRoot()
if (root == null) { if (root == null) {
Settings.customCacheLocation = false Settings.customCacheLocation = false

View File

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