diff --git a/dependencies.gradle b/dependencies.gradle index 4aae7cf4..5ac416a4 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -24,6 +24,7 @@ ext.versions = [ kotlin : "1.5.31", kotlinxCoroutines : "1.5.2-native-mt", viewModelKtx : "2.3.0", + lifecycle : "2.3.1", retrofit : "2.6.4", jackson : "2.9.5", @@ -66,6 +67,7 @@ ext.androidSupport = [ roomRuntime : "androidx.room:room-runtime:$versions.room", roomKtx : "androidx.room:room-ktx:$versions.room", viewModelKtx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.viewModelKtx", + lifecycle : "androidx.lifecycle:lifecycle-process:$versions.lifecycle", navigationFragment : "androidx.navigation:navigation-fragment:$versions.navigation", navigationUi : "androidx.navigation:navigation-ui:$versions.navigation", navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$versions.navigation", diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 2895de49..5bd4da0f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -98,6 +98,7 @@ dependencies { implementation androidSupport.navigationFragmentKtx implementation androidSupport.navigationUiKtx implementation androidSupport.navigationFeature + implementation androidSupport.lifecycle implementation other.kotlinStdlib implementation other.kotlinxCoroutines diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java index 7ce9eae6..182d413c 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java @@ -194,9 +194,10 @@ public class StreamProxy implements Runnable String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile(); int cbSentThisBatch = 0; - if (StorageFile.Companion.isPathExists(file)) + StorageFile storageFile = StorageFile.Companion.getFromPath(file); + if (storageFile != null) { - InputStream input = StorageFile.Companion.getFromPath(file).getFileInputStream(); + InputStream input = storageFile.getFileInputStream(); try { 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 36eb3786..cb167245 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/app/UApp.kt @@ -1,6 +1,10 @@ package org.moire.ultrasonic.app import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner import androidx.multidex.MultiDexApplication import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -15,6 +19,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.StorageFile import timber.log.Timber import timber.log.Timber.DebugTree @@ -52,6 +57,8 @@ class UApp : MultiDexApplication() { mediaPlayerModule ) } + + ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleListener()) } companion object { @@ -62,3 +69,11 @@ class UApp : MultiDexApplication() { } } } + +class AppLifecycleListener : LifecycleObserver { + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun onMoveToForeground() { + StorageFile.resetCaches() + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 7556d148..52dcb576 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -413,9 +413,11 @@ class PlayerFragment : onCurrentChanged() } val handler = Handler() + + // TODO Use Rx for Update instead of polling! val runnable = Runnable { handler.post { update(cancellationToken) } } executorService = Executors.newSingleThreadScheduledExecutor() - executorService.scheduleWithFixedDelay(runnable, 0L, 250L, TimeUnit.MILLISECONDS) + executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS) if (mediaPlayerController.keepScreenOn) { requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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 a6d421cb..c72430fe 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -20,6 +20,7 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat +import com.github.k1rakishou.fsaf.FileChooser import kotlin.math.ceil import org.koin.core.component.KoinComponent import org.koin.java.KoinJavaComponent.get @@ -46,9 +47,11 @@ import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings.preferences import org.moire.ultrasonic.util.Settings.shareGreeting import org.moire.ultrasonic.util.Settings.shouldUseId3Tags +import org.moire.ultrasonic.util.StorageFile import org.moire.ultrasonic.util.TimeSpanPreference import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat import org.moire.ultrasonic.util.Util.toast +import org.moire.ultrasonic.util.isUri import timber.log.Timber import java.io.File @@ -89,6 +92,7 @@ class SettingsFragment : private var resumeOnBluetoothDevice: Preference? = null private var pauseOnBluetoothDevice: Preference? = null private var debugLogToFile: CheckBoxPreference? = null + private var customCacheLocation: CheckBoxPreference? = null private val mediaPlayerControllerLazy = inject( MediaPlayerController::class.java @@ -137,6 +141,8 @@ class SettingsFragment : pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE) debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE) showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE) + customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION) + sharingDefaultGreeting!!.text = shareGreeting setupClearSearchPreference() setupFeatureFlagsPreferences() @@ -186,7 +192,7 @@ class SettingsFragment : contentResolver.takePersistableUriPermission(uri, RW_FLAG) - setCacheLocation(uri) + setCacheLocation(uri.toString()) } } @@ -224,6 +230,9 @@ class SettingsFragment : Constants.PREFERENCES_KEY_THEME -> { RxBus.themeChangedEventPublisher.onNext(Unit) } + Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION -> { + setupCacheLocationPreference() + } } } @@ -247,12 +256,19 @@ class SettingsFragment : } private fun setupCacheLocationPreference() { - // TODO add means to reset cache directory to its default value + val isDefault = Settings.cacheLocation == defaultMusicDirectory.path + + if (!Settings.customCacheLocation) { + cacheLocation?.isVisible = false + if (!isDefault) setCacheLocation(defaultMusicDirectory.path) + return + } + + cacheLocation?.isVisible = true val uri = Uri.parse(Settings.cacheLocation) cacheLocation!!.summary = uri.path cacheLocation!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - val isDefault = Settings.cacheLocation == defaultMusicDirectory.path // Choose a directory using the system's file picker. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) @@ -434,14 +450,17 @@ class SettingsFragment : sendBluetoothAlbumArt!!.isEnabled = enabled } - private fun setCacheLocation(uri: Uri) { - if (uri.path != null) { - cacheLocation!!.summary = uri.path - Settings.cacheLocation = uri.toString() - - // Clear download queue. - mediaPlayerControllerLazy.value.clear() + private fun setCacheLocation(path: String) { + if (path.isUri()) { + val uri = Uri.parse(path) + cacheLocation!!.summary = uri.path ?: "" } + + Settings.cacheLocation = path + + // Clear download queue. + mediaPlayerControllerLazy.value.clear() + StorageFile.resetCaches() } private fun setDebugLogToFile(writeLog: Boolean) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index 808fb4ee..cec735a1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -46,6 +46,7 @@ class DownloadFile( private val desiredBitRate: Int = Settings.maxBitRate var priority = 100 + var downloadPrepared = false @Volatile private var isPlaying = false @@ -75,6 +76,13 @@ class DownloadFile( return if (song.bitRate == null) desiredBitRate else song.bitRate!! } + @Synchronized + fun prepare() { + // It is necessary to signal that the download will begin shortly on another thread + // so it won't get cleaned up accidentally + downloadPrepared = true + } + @Synchronized fun download() { FileUtil.createDirectoryForParent(saveFile) @@ -85,9 +93,7 @@ class DownloadFile( @Synchronized fun cancelDownload() { - if (downloadTask != null) { - downloadTask!!.cancel() - } + downloadTask?.cancel() } val completeOrSaveFile: String @@ -109,20 +115,20 @@ class DownloadFile( @get:Synchronized val isCompleteFileAvailable: Boolean - get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) + get() = StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile) @get:Synchronized val isWorkDone: Boolean - get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) && !save || - saveWhenDone || completeWhenDone + get() = StorageFile.isPathExists(completeFile) && !save || + StorageFile.isPathExists(saveFile) || saveWhenDone || completeWhenDone @get:Synchronized val isDownloading: Boolean - get() = downloadTask != null && downloadTask!!.isRunning + get() = downloadPrepared || (downloadTask != null && downloadTask!!.isRunning) @get:Synchronized val isDownloadCancelled: Boolean - get() = downloadTask != null && downloadTask!!.isCancelled + get() = downloadTask != null && downloadTask!!.isCancelled fun shouldSave(): Boolean { return save @@ -142,9 +148,8 @@ class DownloadFile( } fun unpin() { - if (StorageFile.isPathExists(saveFile)) { - StorageFile.rename(saveFile, completeFile) - } + val file = StorageFile.getFromPath(saveFile) ?: return + StorageFile.rename(file, completeFile) } fun cleanup(): Boolean { @@ -194,6 +199,7 @@ class DownloadFile( override fun execute() { + downloadPrepared = false var inputStream: InputStream? = null var outputStream: OutputStream? = null try { @@ -222,18 +228,12 @@ class DownloadFile( // Some devices seem to throw error on partial file which doesn't exist val needsDownloading: Boolean val duration = song.duration - var fileLength: Long = 0 - - if (!StorageFile.isPathExists(partialFile)) { - fileLength = 0 - } else { - fileLength = StorageFile.getFromPath(partialFile).length() - } + val fileLength = StorageFile.getFromPath(partialFile)?.length ?: 0 needsDownloading = ( - desiredBitRate == 0 || duration == null || - duration == 0 || fileLength == 0L - ) + desiredBitRate == 0 || duration == null || + duration == 0 || fileLength == 0L + ) if (needsDownloading) { // Attempt partial HTTP GET, appending to the file if it exists. @@ -372,7 +372,7 @@ class DownloadFile( } override val id: String - get() = song.id + get() = song.id companion object { const val MAX_RETRIES = 5 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index a1aa4bec..b5e221e2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -133,6 +133,7 @@ class Downloader( return } + Timber.v("Downloader checkDownloadsInternal checking downloads") // Check the active downloads for failures or completions and remove them // Store the result in a flag to know if changes have occurred var listChanged = cleanupActiveDownloads() @@ -183,6 +184,7 @@ class Downloader( if (listChanged) { updateLiveData() } + } private fun updateLiveData() { @@ -190,6 +192,7 @@ class Downloader( } private fun startDownloadOnService(task: DownloadFile) { + task.prepare() MediaPlayerService.executeOnStartedMediaPlayerService { task.download() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index f4ef95fb..753eea83 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -368,7 +368,7 @@ class LocalMediaPlayer : KoinComponent { } dataSource = String.format( Locale.getDefault(), "http://127.0.0.1:%d/%s", - proxy!!.port, URLEncoder.encode(dataSource, Constants.UTF_8) + proxy!!.port, URLEncoder.encode(file!!.path, Constants.UTF_8) ) Timber.i("Data Source: %s", dataSource) } else if (proxy != null) { @@ -379,7 +379,7 @@ class LocalMediaPlayer : KoinComponent { Timber.i("Preparing media player") if (dataSource != null) mediaPlayer.setDataSource(dataSource) - else if (file.isRawFile()) mediaPlayer.setDataSource(file.getRawFilePath()) + else if (file!!.isRawFile) mediaPlayer.setDataSource(file.rawFilePath) else { val descriptor = file.getDocumentFileDescriptor("r")!! mediaPlayer.setDataSource(descriptor.fileDescriptor) @@ -465,7 +465,7 @@ class LocalMediaPlayer : KoinComponent { } catch (ignored: Throwable) { } - if (file.isRawFile()) nextMediaPlayer!!.setDataSource(file.getRawFilePath()) + if (file!!.isRawFile) nextMediaPlayer!!.setDataSource(file.rawFilePath) else { val descriptor = file.getDocumentFileDescriptor("r")!! nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor) @@ -614,8 +614,7 @@ class LocalMediaPlayer : KoinComponent { private fun bufferComplete(): Boolean { val completeFileAvailable = downloadFile.isWorkDone - val size = if (!StorageFile.isPathExists(partialFile)) 0 - else StorageFile.getFromPath(partialFile).length() + val size = StorageFile.getFromPath(partialFile)?.length ?: 0 Timber.i( "Buffering %s (%d/%d, %s)", @@ -672,8 +671,8 @@ class LocalMediaPlayer : KoinComponent { val completeFileAvailable = downloadFile!!.isWorkDone val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) - val length = if (partialFile == null || !StorageFile.isPathExists(partialFile)) 0 - else StorageFile.getFromPath(partialFile).length() + val length = if (partialFile == null) 0 + else StorageFile.getFromPath(partialFile)?.length ?: 0 Timber.i("Buffering next %s (%d)", partialFile, length) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index e503b36f..d72d65e1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -205,6 +205,9 @@ class MediaPlayerService : Service() { @Synchronized fun setNextPlaying() { + // Download the next few songs if necessary + downloader.checkDownloads() + if (!Settings.gaplessPlayback) { localMediaPlayer.clearNextPlaying(true) return @@ -289,7 +292,6 @@ class MediaPlayerService : Service() { localMediaPlayer.play(downloader.getPlaylist()[index]) } } - downloader.checkDownloads() setNextPlaying() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 401d8e96..6ee13280 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -55,8 +55,8 @@ class OfflineMusicService : MusicService, KoinComponent { val root = FileUtil.musicDirectory for (file in FileUtil.listFiles(root)) { if (file.isDirectory) { - val index = Index(file.getPath()) - index.id = file.getPath() + val index = Index(file.path) + index.id = file.path index.index = file.name.substring(0, 1) index.name = file.name indexes.add(index) @@ -102,7 +102,7 @@ class OfflineMusicService : MusicService, KoinComponent { ): MusicDirectory { val dir = StorageFile.getFromPath(id) val result = MusicDirectory() - result.name = dir.name + result.name = dir?.name ?: return result val seen: MutableCollection = HashSet() @@ -127,7 +127,7 @@ class OfflineMusicService : MusicService, KoinComponent { val artistName = artistFile.name if (artistFile.isDirectory) { if (matchCriteria(criteria, artistName).also { closeness = it } > 0) { - val artist = Artist(artistFile.getPath()) + val artist = Artist(artistFile.path) artist.index = artistFile.name.substring(0, 1) artist.name = artistName artist.closeness = closeness @@ -205,12 +205,10 @@ class OfflineMusicService : MusicService, KoinComponent { var line = buffer.readLine() if ("#EXTM3U" != line) return playlist while (buffer.readLine().also { line = it } != null) { - if (StorageFile.isPathExists(line)) { - val entryFile = StorageFile.getFromPath(line) - val entryName = getName(entryFile.name, entryFile.isDirectory) - if (entryName != null) { - playlist.addChild(createEntry(entryFile, entryName)) - } + val entryFile = StorageFile.getFromPath(line) ?: continue + val entryName = getName(entryFile.name, entryFile.isDirectory) + if (entryName != null) { + playlist.addChild(createEntry(entryFile, entryName)) } } playlist @@ -500,12 +498,12 @@ class OfflineMusicService : MusicService, KoinComponent { @Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth") private fun createEntry(file: StorageFile, name: String?): MusicDirectory.Entry { - val entry = MusicDirectory.Entry(file.getPath()) + val entry = MusicDirectory.Entry(file.path) entry.isDirectory = file.isDirectory - entry.parent = file.getParent()!!.getPath() - entry.size = if (file.isFile) file.length() else 0 - val root = FileUtil.musicDirectory.getPath() - entry.path = file.getPath().replaceFirst( + entry.parent = file.parent!!.path + entry.size = if (file.isFile) file.length else 0 + val root = FileUtil.musicDirectory.path + entry.path = file.path.replaceFirst( String.format(Locale.ROOT, "^%s/", root).toRegex(), "" ) entry.title = name @@ -522,7 +520,7 @@ class OfflineMusicService : MusicService, KoinComponent { try { val mmr = MediaMetadataRetriever() - if (file.isRawFile()) mmr.setDataSource(file.getRawFilePath()) + if (file.isRawFile) mmr.setDataSource(file.rawFilePath) else { val descriptor = file.getDocumentFileDescriptor("r")!! mmr.setDataSource(descriptor.fileDescriptor) @@ -541,8 +539,8 @@ class OfflineMusicService : MusicService, KoinComponent { mmr.release() } catch (ignored: Exception) { } - entry.artist = artist ?: file.getParent()!!.getParent()!!.name - entry.album = album ?: file.getParent()!!.name + entry.artist = artist ?: file.parent!!.parent!!.name + entry.album = album ?: file.parent!!.name if (title != null) { entry.title = title } 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 1d6e9e33..4dfcb23f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -33,21 +33,38 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { } } + // Cache cleaning shouldn't run concurrently, as it is started after every completed download + // TODO serializing and throttling these is an ideal task for Rx fun clean() { - launch(exceptionHandler("clean")) { - backgroundCleanup() + if (cleaning) return + synchronized(lock) { + if (cleaning) return + cleaning = true + launch(exceptionHandler("clean")) { + backgroundCleanup() + } } } fun cleanSpace() { - launch(exceptionHandler("cleanSpace")) { - backgroundSpaceCleanup() + if (spaceCleaning) return + synchronized(lock) { + if (spaceCleaning) return + spaceCleaning = true + launch(exceptionHandler("cleanSpace")) { + backgroundSpaceCleanup() + } } } fun cleanPlaylists(playlists: List) { - launch(exceptionHandler("cleanPlaylists")) { - backgroundPlaylistsCleanup(playlists) + if (playlistCleaning) return + synchronized(lock) { + if (playlistCleaning) return + playlistCleaning = true + launch(exceptionHandler("cleanPlaylists")) { + backgroundPlaylistsCleanup(playlists) + } } } @@ -64,6 +81,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { deleteEmptyDirs(dirs, filesToNotDelete) } catch (all: RuntimeException) { Timber.e(all, "Error in cache cleaning.") + } finally { + cleaning = false } } @@ -82,6 +101,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { } } catch (all: RuntimeException) { Timber.e(all, "Error in cache cleaning.") + } finally { + spaceCleaning = false } } @@ -104,27 +125,34 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { } } catch (all: RuntimeException) { Timber.e(all, "Error in playlist cache cleaning.") + } finally { + playlistCleaning = false } } companion object { + private val lock = Object() + private var cleaning = false + private var spaceCleaning = false + private var playlistCleaning = false + private const val MIN_FREE_SPACE = 500 * 1024L * 1024L private fun deleteEmptyDirs(dirs: Iterable, doNotDelete: Collection) { for (dir in dirs) { - if (doNotDelete.contains(dir.getPath())) continue + if (doNotDelete.contains(dir.path)) continue var children = dir.listFiles() if (children != null) { // No songs left in the folder - if (children.size == 1 && children[0].getPath() == getAlbumArtFile(dir.getPath())) { + if (children.size == 1 && children[0].path == getAlbumArtFile(dir.path)) { // Delete Artwork files - delete(getAlbumArtFile(dir.getPath())) + delete(getAlbumArtFile(dir.path)) children = dir.listFiles() } // Delete empty directory if (children != null && children.isEmpty()) { - delete(dir.getPath()) + delete(dir.path) } } } @@ -137,7 +165,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { var bytesUsedBySubsonic = 0L for (file in files) { - bytesUsedBySubsonic += file.length() + bytesUsedBySubsonic += file.length } // Ensure that file system is not more than 95% full. @@ -146,8 +174,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { val bytesTotalFs: Long val bytesAvailableFs: Long - if (files[0].isRawFile()) { - val stat = StatFs(files[0].getRawFilePath()) + if (files[0].isRawFile) { + val stat = StatFs(files[0].rawFilePath) bytesTotalFs = stat.blockCountLong * stat.blockSizeLong bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong bytesUsedFs = bytesTotalFs - bytesAvailableFs @@ -201,9 +229,9 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { for (file in files) { if (!deletePartials && bytesDeleted > bytesToDelete) break if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) { - if (!doNotDelete.contains(file.getPath()) && file.name != Constants.ALBUM_ART_FILE) { - val size = file.length() - if (delete(file.getPath())) { + if (!doNotDelete.contains(file.path) && file.name != Constants.ALBUM_ART_FILE) { + val size = file.length + if (delete(file.path)) { bytesDeleted += size } } @@ -230,7 +258,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { private fun sortByAscendingModificationTime(files: MutableList) { files.sortWith { a: StorageFile, b: StorageFile -> - a.lastModified().compareTo(b.lastModified()) + a.lastModified.compareTo(b.lastModified) } } @@ -245,7 +273,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { filesToNotDelete.add(downloadFile.completeOrSaveFile) } - filesToNotDelete.add(musicDirectory.getPath()) + filesToNotDelete.add(musicDirectory.path) return filesToNotDelete } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt index cde2df4c..31744967 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Constants.kt @@ -64,6 +64,7 @@ object Constants { const val PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi" const val PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile" const val PREFERENCES_KEY_CACHE_SIZE = "cacheSize" + const val PREFERENCES_KEY_CUSTOM_CACHE_LOCATION = "customCacheLocation" const val PREFERENCES_KEY_CACHE_LOCATION = "cacheLocation" const val PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount" const val PREFERENCES_KEY_HIDE_MEDIA = "hideMedia" 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 da0047db..bd9cf7cb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -131,7 +131,7 @@ object FileUtil { */ fun getArtistArtKey(name: String?, large: Boolean): String { val artist = fileSystemSafe(name) - val dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, UNNAMED) + val dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, UNNAMED) return getAlbumArtKey(dir, large) } @@ -194,7 +194,7 @@ object FileUtil { dir = String.format( Locale.ROOT, "%s/%s", - musicDirectory.getPath(), + musicDirectory.path, if (entry.isDirectory) f else getParentPath(f) ?: "" ) } else { @@ -203,7 +203,7 @@ object FileUtil { if (UNNAMED == album) { album = fileSystemSafe(entry.title) } - dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, album) + dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, album) } return dir } @@ -241,7 +241,7 @@ object FileUtil { @JvmStatic val musicDirectory: StorageFile - get() = StorageFile.getMediaRoot() + get() = StorageFile.mediaRoot.value @JvmStatic @Suppress("ReturnCount") @@ -320,7 +320,7 @@ object FileUtil { fun listFiles(dir: StorageFile): SortedSet { val files = dir.listFiles() if (files == null) { - Timber.w("Failed to list children for %s", dir.getPath()) + Timber.w("Failed to list children for %s", dir.path) return TreeSet() } return TreeSet(files.asList()) @@ -500,8 +500,9 @@ object FileUtil { @JvmStatic fun delete(file: String?): Boolean { - if (file != null && StorageFile.isPathExists(file)) { - if (!StorageFile.getFromPath(file).delete()) { + if (file != null) { + val storageFile = StorageFile.getFromPath(file) + if (storageFile != null && !storageFile.delete()) { Timber.w("Failed to delete file %s", file) return false } @@ -509,5 +510,4 @@ object FileUtil { } return true } - } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ResettableLazy.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ResettableLazy.kt new file mode 100644 index 00000000..6159f573 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/ResettableLazy.kt @@ -0,0 +1,25 @@ +package org.moire.ultrasonic.util + +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KProperty + +class ResettableLazy(private val initializer: () -> T) { + private val lazyRef: AtomicReference> = AtomicReference( + lazy( + initializer + ) + ) + + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + return lazyRef.get().getValue(thisRef, property) + } + + val value: T + get() { + return lazyRef.get().value + } + + fun reset() { + lazyRef.set(lazy(initializer)) + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index d7c8545f..29c13b18 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -105,6 +105,12 @@ object Settings { return if (cacheSize == -1) Int.MAX_VALUE else cacheSize } + @JvmStatic + var customCacheLocation by BooleanSetting( + Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION, + false + ) + @JvmStatic var cacheLocation by StringSetting( Constants.PREFERENCES_KEY_CACHE_LOCATION, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt index 2ba10bb2..4c97eac2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt @@ -12,8 +12,11 @@ import android.net.Uri import com.github.k1rakishou.fsaf.FileManager import com.github.k1rakishou.fsaf.document_file.CachingDocumentFile import com.github.k1rakishou.fsaf.file.AbstractFile +import com.github.k1rakishou.fsaf.file.DirectorySegment +import com.github.k1rakishou.fsaf.file.FileSegment import com.github.k1rakishou.fsaf.file.RawFile import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory +import org.moire.ultrasonic.R import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -22,19 +25,20 @@ import java.io.InputStream import java.io.OutputStream import org.moire.ultrasonic.app.UApp import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap /** * Provides filesystem access abstraction which works * both on File based paths and Storage Access Framework Uris */ class StorageFile private constructor( - private var parent: StorageFile?, + private var parentStorageFile: StorageFile?, private var abstractFile: AbstractFile, private var fileManager: FileManager ): Comparable { override fun compareTo(other: StorageFile): Int { - return getPath().compareTo(other.getPath()) + return path.compareTo(other.path) } override fun toString(): String { @@ -47,25 +51,28 @@ class StorageFile private constructor( var isFile: Boolean = fileManager.isFile(abstractFile) - fun length(): Long = fileManager.getLength(abstractFile) + val length: Long + get() = fileManager.getLength(abstractFile) - fun lastModified(): Long = fileManager.lastModified(abstractFile) + val lastModified: Long + get() = fileManager.lastModified(abstractFile) - fun delete(): Boolean = fileManager.delete(abstractFile) + fun delete(): Boolean { + val deleted = fileManager.delete(abstractFile) + if (!deleted) return false + val path = normalizePath(path) + storageFilePathDictionary.remove(path) + notExistingPathDictionary.putIfAbsent(path, path) + return true + } fun listFiles(): Array { val fileList = fileManager.listFiles(abstractFile) return fileList.map { file -> StorageFile(this, file, fileManager) }.toTypedArray() } - fun getFileOutputStream(): OutputStream { - if (isRawFile()) return File(abstractFile.getFullPath()).outputStream() - return fileManager.getOutputStream(abstractFile) - ?: throw IOException("Couldn't retrieve OutputStream") - } - fun getFileOutputStream(append: Boolean): OutputStream { - if (isRawFile()) return FileOutputStream(File(abstractFile.getFullPath()), append) + if (isRawFile) return FileOutputStream(File(abstractFile.getFullPath()), append) val mode = if (append) "wa" else "w" val descriptor = UApp.applicationContext().contentResolver.openAssetFileDescriptor( abstractFile.getFileRoot().holder.uri(), mode) @@ -74,38 +81,43 @@ class StorageFile private constructor( } fun getFileInputStream(): InputStream { - if (isRawFile()) return FileInputStream(abstractFile.getFullPath()) + if (isRawFile) return FileInputStream(abstractFile.getFullPath()) return fileManager.getInputStream(abstractFile) ?: throw IOException("Couldn't retrieve InputStream") } - // TODO there are a few functions which could be getters - // They are functions for now to help us distinguish them from similar getters in File. These can be changed after the refactor is complete. - fun getPath(): String { - if (isRawFile()) return abstractFile.getFullPath() - if (getParent() != null) return getParent()!!.getPath() + "/" + name - return Uri.parse(abstractFile.getFullPath()).toString() - } + val path: String + get() { + if (isRawFile) return abstractFile.getFullPath() - fun getParent(): StorageFile? { - if (isRawFile()) { - return StorageFile( - null, - fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!), - fileManager - ) + // We can't assume that the file's Uri is related to its path, + // so we generate our own path by concatenating the names on the path. + if (parentStorageFile != null) return parentStorageFile!!.path + "/" + name + return Uri.parse(abstractFile.getFullPath()).toString() } - return parent - } - fun isRawFile(): Boolean { - return abstractFile is RawFile - } + val parent: StorageFile? + get() { + if (isRawFile) { + return StorageFile( + null, + fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!), + fileManager + ) + } + return parentStorageFile + } - fun getRawFilePath(): String? { - return if (abstractFile is RawFile) abstractFile.getFullPath() - else null - } + val isRawFile: Boolean + get() { + return abstractFile is RawFile + } + + val rawFilePath: String? + get() { + return if (abstractFile is RawFile) abstractFile.getFullPath() + else null + } fun getDocumentFileDescriptor(openMode: String): AssetFileDescriptor? { return if (abstractFile !is RawFile) { @@ -117,53 +129,38 @@ class StorageFile private constructor( } companion object { - // TODO it would be nice to check the access rights and reset the cache directory on error - private val MusicCacheFileManager: Lazy = lazy { + // These caches are necessary because SAF is very slow, and the caching in FSAF is buggy. + // Ultrasonic assumes that the files won't change while it is in the foreground. + // TODO to really handle concurrency we'd need API24. + // If this isn't good enough we can add locking. + private val storageFilePathDictionary = ConcurrentHashMap() + private val notExistingPathDictionary = ConcurrentHashMap() + + private val fileManager: ResettableLazy = ResettableLazy { val manager = FileManager(UApp.applicationContext()) manager.registerBaseDir(MusicCacheBaseDirectory()) manager } - fun getFromParentAndName(parent: StorageFile, name: String): StorageFile { - val file = parent.fileManager.findFile(parent.abstractFile, name) - ?: parent.fileManager.createFile(parent.abstractFile, name)!! - return StorageFile(parent, file, parent.fileManager) - } - - fun getMediaRoot(): StorageFile { - return StorageFile( + val mediaRoot: ResettableLazy = ResettableLazy { + StorageFile( null, - MusicCacheFileManager.value.newBaseDirectoryFile()!!, - MusicCacheFileManager.value + fileManager.value.newBaseDirectoryFile()!!, + fileManager.value ) } - // TODO sometimes getFromPath is called after isPathExists, but the file may be gone because it was deleted in another thread. - // Create a function where these two are merged - fun getFromPath(path: String): StorageFile { - Timber.v("StorageFile getFromPath %s", path) - val normalizedPath = normalizePath(path) - if (!normalizedPath.isUri()) { - return StorageFile( - null, - MusicCacheFileManager.value.fromPath(normalizedPath), - MusicCacheFileManager.value - ) + fun resetCaches() { + storageFilePathDictionary.clear() + notExistingPathDictionary.clear() + fileManager.value.unregisterBaseDir() + fileManager.reset() + mediaRoot.reset() + Timber.v("StorageFile caches were reset") + if (!fileManager.value.baseDirectoryExists()) { + Settings.cacheLocation = FileUtil.defaultMusicDirectory.path + Util.toast(UApp.applicationContext(), R.string.settings_cache_location_error) } - - val segments = getUriSegments(normalizedPath) - ?: throw IOException("Can't get path because the root has changed") - - var file = StorageFile(null, getMediaRoot().abstractFile, MusicCacheFileManager.value) - segments.forEach { segment -> - file = StorageFile( - file, - MusicCacheFileManager.value.findFile(file.abstractFile, segment) - ?: throw IOException("File not found"), - file.fileManager - ) - } - return file } fun getOrCreateFileFromPath(path: String): StorageFile { @@ -172,37 +169,70 @@ class StorageFile private constructor( File(normalizedPath).createNewFile() return StorageFile( null, - MusicCacheFileManager.value.fromPath(normalizedPath), - MusicCacheFileManager.value + fileManager.value.fromPath(normalizedPath), + fileManager.value ) } - val segments = getUriSegments(normalizedPath) - ?: throw IOException("Can't get path because the root has changed") + if (storageFilePathDictionary.containsKey(normalizedPath)) + return storageFilePathDictionary[normalizedPath]!! - var file = StorageFile(null, getMediaRoot().abstractFile, MusicCacheFileManager.value) - segments.forEach { segment -> - file = StorageFile( - file, - MusicCacheFileManager.value.findFile(file.abstractFile, segment) - ?: MusicCacheFileManager.value.createFile(file.abstractFile, segment)!!, - file.fileManager - ) - } + val parent = getStorageFileForParentDirectory(normalizedPath) + ?: throw IOException("Parent directory doesn't exist") + + val name = FileUtil.getNameFromPath(normalizedPath) + val file = StorageFile( + parent, + fileManager.value.findFile(parent.abstractFile, name) + ?: fileManager.value.create(parent.abstractFile, + listOf(FileSegment(name)) + )!!, + parent.fileManager + ) + storageFilePathDictionary[normalizedPath] = file + notExistingPathDictionary.remove(normalizedPath) return file } fun isPathExists(path: String): Boolean { + return getFromPath(path) != null + } + + fun getFromPath(path: String): StorageFile? { val normalizedPath = normalizePath(path) - if (!normalizedPath.isUri()) return File(normalizedPath).exists() - - val segments = getUriSegments(normalizedPath) ?: return false - - var file = getMediaRoot().abstractFile - segments.forEach { segment -> - file = MusicCacheFileManager.value.findFile(file, segment) ?: return false + if (!normalizedPath.isUri()) { + val file = fileManager.value.fromPath(normalizedPath) + if (!fileManager.value.exists(file)) return null + return StorageFile(null, file, fileManager.value) } - return true + + if (storageFilePathDictionary.containsKey(normalizedPath)) + return storageFilePathDictionary[normalizedPath]!! + if (notExistingPathDictionary.contains(normalizedPath)) return null + + val parent = getStorageFileForParentDirectory(normalizedPath) + if (parent == null) { + notExistingPathDictionary.putIfAbsent(normalizedPath, normalizedPath) + return null + } + + val fileName = FileUtil.getNameFromPath(normalizedPath) + var file: StorageFile? = null + + // Listing a bunch of files takes the same time in SAF as finding one, + // so we list and cache all of them for performance + parent.listFiles().forEach { + if (it.name == fileName) file = it + storageFilePathDictionary[it.path] = it + notExistingPathDictionary.remove(it.path) + } + + if (file == null) { + notExistingPathDictionary.putIfAbsent(normalizedPath, normalizedPath) + return null + } + + return file } fun createDirsOnPath(path: String) { @@ -215,29 +245,81 @@ class StorageFile private constructor( val segments = getUriSegments(normalizedPath) ?: throw IOException("Can't get path because the root has changed") - var file = getMediaRoot().abstractFile + var file = mediaRoot.value segments.forEach { segment -> - file = MusicCacheFileManager.value.createDir(file, segment) - ?: throw IOException("Can't create directory") + file = StorageFile( + file, + fileManager.value.create(file.abstractFile, listOf(DirectorySegment(segment))) + ?: throw IOException("Can't create directory"), + fileManager.value + ) + + notExistingPathDictionary.remove(normalizePath(file.path)) } } fun rename(pathFrom: String, pathTo: String) { val normalizedPathFrom = normalizePath(pathFrom) + val fileFrom = getFromPath(normalizedPathFrom) ?: throw IOException("File to rename doesn't exist") + rename(fileFrom, pathTo) + } + + fun rename(pathFrom: StorageFile?, pathTo: String) { val normalizedPathTo = normalizePath(pathTo) + if (pathFrom == null || !pathFrom.fileManager.exists(pathFrom.abstractFile)) throw IOException("File to rename doesn't exist") + Timber.d("Renaming from %s to %s", pathFrom.path, normalizedPathTo) - Timber.d("Renaming from %s to %s", normalizedPathFrom, normalizedPathTo) - - val fileFrom = getFromPath(normalizedPathFrom) - val parentTo = getFromPath(FileUtil.getParentPath(normalizedPathTo)!!) + val parentTo = getFromPath(FileUtil.getParentPath(normalizedPathTo)!!) ?: throw IOException("Destination folder doesn't exist") val fileTo = getFromParentAndName(parentTo, FileUtil.getNameFromPath(normalizedPathTo)) + notExistingPathDictionary.remove(normalizedPathTo) + storageFilePathDictionary.remove(normalizePath(pathFrom.path)) - MusicCacheFileManager.value.copyFileContents(fileFrom.abstractFile, fileTo.abstractFile) - fileFrom.delete() + fileManager.value.copyFileContents(pathFrom.abstractFile, fileTo.abstractFile) + pathFrom.delete() + } + + private fun getFromParentAndName(parent: StorageFile, name: String): StorageFile { + val file = parent.fileManager.findFile(parent.abstractFile, name) + ?: parent.fileManager.createFile(parent.abstractFile, name)!! + return StorageFile(parent, file, parent.fileManager) + } + + private fun getStorageFileForParentDirectory(path: String): StorageFile? { + val parentPath = FileUtil.getParentPath(path)!! + if (storageFilePathDictionary.containsKey(parentPath)) + return storageFilePathDictionary[parentPath]!! + if (notExistingPathDictionary.contains(parentPath)) return null + + val parent = findStorageFileForParentDirectory(parentPath) + if (parent == null) { + storageFilePathDictionary.remove(parentPath) + notExistingPathDictionary.putIfAbsent(parentPath, parentPath) + } else { + storageFilePathDictionary[parentPath] = parent + notExistingPathDictionary.remove(parentPath) + } + + return parent + } + + private fun findStorageFileForParentDirectory(path: String): StorageFile? { + val segments = getUriSegments(path) + ?: throw IOException("Can't get path because the root has changed") + + var file = StorageFile(null, mediaRoot.value.abstractFile, fileManager.value) + segments.forEach { segment -> + file = StorageFile( + file, + fileManager.value.findFile(file.abstractFile, segment) + ?: return null, + file.fileManager + ) + } + return file } private fun getUriSegments(uri: String): List? { - val rootPath = getMediaRoot().getPath() + val rootPath = mediaRoot.value.path if (!uri.startsWith(rootPath)) return null val pathWithoutRoot = uri.substringAfter(rootPath) return pathWithoutRoot.split('/').filter { it.isNotEmpty() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index d0604dcc..f42978b2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -756,7 +756,7 @@ object Util { fun scanMedia(file: String?) { // TODO this doesn't work for URIs MediaScannerConnection.scanFile( - UApp.applicationContext(), arrayOf(file), + applicationContext(), arrayOf(file), null, null) } diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index be3583e1..139cd13e 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -172,6 +172,7 @@ 5 seconds 1 minute 8 seconds + Use Custom Cache Location Cache Location Invalid cache location. Using default. Cache Size diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index d307bd57..42a77114 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -261,6 +261,10 @@ a:key="cacheSize" a:title="@string/settings.cache_size" app:iconSpaceReserved="false"/> +