mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-14 16:37:16 +03:00
Bunch of fixes 💐
This commit is contained in:
parent
5222e952aa
commit
548ecc6517
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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 "http://", should use `@string` resource"
|
||||
errorLine1=" a:text="http://""
|
||||
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"
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -413,6 +413,7 @@ class SettingsFragment :
|
||||
// Clear download queue.
|
||||
mediaPlayerController.clear()
|
||||
Storage.reset()
|
||||
Storage.ensureRootIsAvailable()
|
||||
}
|
||||
|
||||
private fun setDebugLogToFile(writeLog: Boolean) {
|
||||
|
@ -85,7 +85,6 @@ class BitmapUtils {
|
||||
} catch (expected: Exception) {
|
||||
Timber.e(expected, "Exception in BitmapFactory.decodeFile()")
|
||||
}
|
||||
Timber.i("getBitmapFromDisk %s", size.toString())
|
||||
return bitmap1
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -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())
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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!!
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user