Added file caches to speed up SAF

Updated settings page with default music cache path reset
Fixed a bunch of bugs
This commit is contained in:
Nite 2021-11-23 20:22:31 +01:00
parent 5c7cde2349
commit 5cf914f555
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
20 changed files with 381 additions and 192 deletions

View File

@ -24,6 +24,7 @@ ext.versions = [
kotlin : "1.5.31", kotlin : "1.5.31",
kotlinxCoroutines : "1.5.2-native-mt", kotlinxCoroutines : "1.5.2-native-mt",
viewModelKtx : "2.3.0", viewModelKtx : "2.3.0",
lifecycle : "2.3.1",
retrofit : "2.6.4", retrofit : "2.6.4",
jackson : "2.9.5", jackson : "2.9.5",
@ -66,6 +67,7 @@ ext.androidSupport = [
roomRuntime : "androidx.room:room-runtime:$versions.room", roomRuntime : "androidx.room:room-runtime:$versions.room",
roomKtx : "androidx.room:room-ktx:$versions.room", roomKtx : "androidx.room:room-ktx:$versions.room",
viewModelKtx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.viewModelKtx", viewModelKtx : "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.viewModelKtx",
lifecycle : "androidx.lifecycle:lifecycle-process:$versions.lifecycle",
navigationFragment : "androidx.navigation:navigation-fragment:$versions.navigation", navigationFragment : "androidx.navigation:navigation-fragment:$versions.navigation",
navigationUi : "androidx.navigation:navigation-ui:$versions.navigation", navigationUi : "androidx.navigation:navigation-ui:$versions.navigation",
navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$versions.navigation", navigationFragmentKtx : "androidx.navigation:navigation-fragment-ktx:$versions.navigation",

View File

@ -98,6 +98,7 @@ dependencies {
implementation androidSupport.navigationFragmentKtx implementation androidSupport.navigationFragmentKtx
implementation androidSupport.navigationUiKtx implementation androidSupport.navigationUiKtx
implementation androidSupport.navigationFeature implementation androidSupport.navigationFeature
implementation androidSupport.lifecycle
implementation other.kotlinStdlib implementation other.kotlinStdlib
implementation other.kotlinxCoroutines implementation other.kotlinxCoroutines

View File

@ -194,9 +194,10 @@ public class StreamProxy implements Runnable
String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile(); String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
int cbSentThisBatch = 0; 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 try
{ {

View File

@ -1,6 +1,10 @@
package org.moire.ultrasonic.app package org.moire.ultrasonic.app
import android.content.Context 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 androidx.multidex.MultiDexApplication
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin 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.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.StorageFile
import timber.log.Timber import timber.log.Timber
import timber.log.Timber.DebugTree import timber.log.Timber.DebugTree
@ -52,6 +57,8 @@ class UApp : MultiDexApplication() {
mediaPlayerModule mediaPlayerModule
) )
} }
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleListener())
} }
companion object { companion object {
@ -62,3 +69,11 @@ class UApp : MultiDexApplication() {
} }
} }
} }
class AppLifecycleListener : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onMoveToForeground() {
StorageFile.resetCaches()
}
}

View File

@ -413,9 +413,11 @@ class PlayerFragment :
onCurrentChanged() onCurrentChanged()
} }
val handler = Handler() val handler = Handler()
// TODO Use Rx for Update instead of polling!
val runnable = Runnable { handler.post { update(cancellationToken) } } val runnable = Runnable { handler.post { update(cancellationToken) } }
executorService = Executors.newSingleThreadScheduledExecutor() executorService = Executors.newSingleThreadScheduledExecutor()
executorService.scheduleWithFixedDelay(runnable, 0L, 250L, TimeUnit.MILLISECONDS) executorService.scheduleWithFixedDelay(runnable, 0L, 500L, TimeUnit.MILLISECONDS)
if (mediaPlayerController.keepScreenOn) { if (mediaPlayerController.keepScreenOn) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

View File

@ -20,6 +20,7 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.github.k1rakishou.fsaf.FileChooser
import kotlin.math.ceil import kotlin.math.ceil
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.get 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.preferences
import org.moire.ultrasonic.util.Settings.shareGreeting import org.moire.ultrasonic.util.Settings.shareGreeting
import org.moire.ultrasonic.util.Settings.shouldUseId3Tags import org.moire.ultrasonic.util.Settings.shouldUseId3Tags
import org.moire.ultrasonic.util.StorageFile
import org.moire.ultrasonic.util.TimeSpanPreference import org.moire.ultrasonic.util.TimeSpanPreference
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.Util.toast
import org.moire.ultrasonic.util.isUri
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
@ -89,6 +92,7 @@ class SettingsFragment :
private var resumeOnBluetoothDevice: Preference? = null private var resumeOnBluetoothDevice: Preference? = null
private var pauseOnBluetoothDevice: Preference? = null private var pauseOnBluetoothDevice: Preference? = null
private var debugLogToFile: CheckBoxPreference? = null private var debugLogToFile: CheckBoxPreference? = null
private var customCacheLocation: CheckBoxPreference? = null
private val mediaPlayerControllerLazy = inject<MediaPlayerController>( private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
MediaPlayerController::class.java MediaPlayerController::class.java
@ -137,6 +141,8 @@ class SettingsFragment :
pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE) pauseOnBluetoothDevice = findPreference(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE)
debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE) debugLogToFile = findPreference(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE)
showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE) showArtistPicture = findPreference(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE)
customCacheLocation = findPreference(Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION)
sharingDefaultGreeting!!.text = shareGreeting sharingDefaultGreeting!!.text = shareGreeting
setupClearSearchPreference() setupClearSearchPreference()
setupFeatureFlagsPreferences() setupFeatureFlagsPreferences()
@ -186,7 +192,7 @@ class SettingsFragment :
contentResolver.takePersistableUriPermission(uri, RW_FLAG) contentResolver.takePersistableUriPermission(uri, RW_FLAG)
setCacheLocation(uri) setCacheLocation(uri.toString())
} }
} }
@ -224,6 +230,9 @@ class SettingsFragment :
Constants.PREFERENCES_KEY_THEME -> { Constants.PREFERENCES_KEY_THEME -> {
RxBus.themeChangedEventPublisher.onNext(Unit) RxBus.themeChangedEventPublisher.onNext(Unit)
} }
Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION -> {
setupCacheLocationPreference()
}
} }
} }
@ -247,12 +256,19 @@ class SettingsFragment :
} }
private fun setupCacheLocationPreference() { 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) val uri = Uri.parse(Settings.cacheLocation)
cacheLocation!!.summary = uri.path cacheLocation!!.summary = uri.path
cacheLocation!!.onPreferenceClickListener = cacheLocation!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener { Preference.OnPreferenceClickListener {
val isDefault = Settings.cacheLocation == defaultMusicDirectory.path
// Choose a directory using the system's file picker. // Choose a directory using the system's file picker.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
@ -434,14 +450,17 @@ class SettingsFragment :
sendBluetoothAlbumArt!!.isEnabled = enabled sendBluetoothAlbumArt!!.isEnabled = enabled
} }
private fun setCacheLocation(uri: Uri) { private fun setCacheLocation(path: String) {
if (uri.path != null) { if (path.isUri()) {
cacheLocation!!.summary = uri.path val uri = Uri.parse(path)
Settings.cacheLocation = uri.toString() cacheLocation!!.summary = uri.path ?: ""
// Clear download queue.
mediaPlayerControllerLazy.value.clear()
} }
Settings.cacheLocation = path
// Clear download queue.
mediaPlayerControllerLazy.value.clear()
StorageFile.resetCaches()
} }
private fun setDebugLogToFile(writeLog: Boolean) { private fun setDebugLogToFile(writeLog: Boolean) {

View File

@ -46,6 +46,7 @@ class DownloadFile(
private val desiredBitRate: Int = Settings.maxBitRate private val desiredBitRate: Int = Settings.maxBitRate
var priority = 100 var priority = 100
var downloadPrepared = false
@Volatile @Volatile
private var isPlaying = false private var isPlaying = false
@ -75,6 +76,13 @@ class DownloadFile(
return if (song.bitRate == null) desiredBitRate else song.bitRate!! 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 @Synchronized
fun download() { fun download() {
FileUtil.createDirectoryForParent(saveFile) FileUtil.createDirectoryForParent(saveFile)
@ -85,9 +93,7 @@ class DownloadFile(
@Synchronized @Synchronized
fun cancelDownload() { fun cancelDownload() {
if (downloadTask != null) { downloadTask?.cancel()
downloadTask!!.cancel()
}
} }
val completeOrSaveFile: String val completeOrSaveFile: String
@ -109,20 +115,20 @@ class DownloadFile(
@get:Synchronized @get:Synchronized
val isCompleteFileAvailable: Boolean val isCompleteFileAvailable: Boolean
get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) get() = StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile)
@get:Synchronized @get:Synchronized
val isWorkDone: Boolean val isWorkDone: Boolean
get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) && !save || get() = StorageFile.isPathExists(completeFile) && !save ||
saveWhenDone || completeWhenDone StorageFile.isPathExists(saveFile) || saveWhenDone || completeWhenDone
@get:Synchronized @get:Synchronized
val isDownloading: Boolean val isDownloading: Boolean
get() = downloadTask != null && downloadTask!!.isRunning get() = downloadPrepared || (downloadTask != null && downloadTask!!.isRunning)
@get:Synchronized @get:Synchronized
val isDownloadCancelled: Boolean val isDownloadCancelled: Boolean
get() = downloadTask != null && downloadTask!!.isCancelled get() = downloadTask != null && downloadTask!!.isCancelled
fun shouldSave(): Boolean { fun shouldSave(): Boolean {
return save return save
@ -142,9 +148,8 @@ class DownloadFile(
} }
fun unpin() { fun unpin() {
if (StorageFile.isPathExists(saveFile)) { val file = StorageFile.getFromPath(saveFile) ?: return
StorageFile.rename(saveFile, completeFile) StorageFile.rename(file, completeFile)
}
} }
fun cleanup(): Boolean { fun cleanup(): Boolean {
@ -194,6 +199,7 @@ class DownloadFile(
override fun execute() { override fun execute() {
downloadPrepared = false
var inputStream: InputStream? = null var inputStream: InputStream? = null
var outputStream: OutputStream? = null var outputStream: OutputStream? = null
try { try {
@ -222,18 +228,12 @@ class DownloadFile(
// Some devices seem to throw error on partial file which doesn't exist // Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean val needsDownloading: Boolean
val duration = song.duration val duration = song.duration
var fileLength: Long = 0 val fileLength = StorageFile.getFromPath(partialFile)?.length ?: 0
if (!StorageFile.isPathExists(partialFile)) {
fileLength = 0
} else {
fileLength = StorageFile.getFromPath(partialFile).length()
}
needsDownloading = ( needsDownloading = (
desiredBitRate == 0 || duration == null || desiredBitRate == 0 || duration == null ||
duration == 0 || fileLength == 0L duration == 0 || fileLength == 0L
) )
if (needsDownloading) { if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists. // Attempt partial HTTP GET, appending to the file if it exists.
@ -372,7 +372,7 @@ class DownloadFile(
} }
override val id: String override val id: String
get() = song.id get() = song.id
companion object { companion object {
const val MAX_RETRIES = 5 const val MAX_RETRIES = 5

View File

@ -133,6 +133,7 @@ class Downloader(
return return
} }
Timber.v("Downloader checkDownloadsInternal checking downloads")
// Check the active downloads for failures or completions and remove them // Check the active downloads for failures or completions and remove them
// Store the result in a flag to know if changes have occurred // Store the result in a flag to know if changes have occurred
var listChanged = cleanupActiveDownloads() var listChanged = cleanupActiveDownloads()
@ -183,6 +184,7 @@ class Downloader(
if (listChanged) { if (listChanged) {
updateLiveData() updateLiveData()
} }
} }
private fun updateLiveData() { private fun updateLiveData() {
@ -190,6 +192,7 @@ class Downloader(
} }
private fun startDownloadOnService(task: DownloadFile) { private fun startDownloadOnService(task: DownloadFile) {
task.prepare()
MediaPlayerService.executeOnStartedMediaPlayerService { MediaPlayerService.executeOnStartedMediaPlayerService {
task.download() task.download()
} }

View File

@ -368,7 +368,7 @@ class LocalMediaPlayer : KoinComponent {
} }
dataSource = String.format( dataSource = String.format(
Locale.getDefault(), "http://127.0.0.1:%d/%s", 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) Timber.i("Data Source: %s", dataSource)
} else if (proxy != null) { } else if (proxy != null) {
@ -379,7 +379,7 @@ class LocalMediaPlayer : KoinComponent {
Timber.i("Preparing media player") Timber.i("Preparing media player")
if (dataSource != null) mediaPlayer.setDataSource(dataSource) if (dataSource != null) mediaPlayer.setDataSource(dataSource)
else if (file.isRawFile()) mediaPlayer.setDataSource(file.getRawFilePath()) else if (file!!.isRawFile) mediaPlayer.setDataSource(file.rawFilePath)
else { else {
val descriptor = file.getDocumentFileDescriptor("r")!! val descriptor = file.getDocumentFileDescriptor("r")!!
mediaPlayer.setDataSource(descriptor.fileDescriptor) mediaPlayer.setDataSource(descriptor.fileDescriptor)
@ -465,7 +465,7 @@ class LocalMediaPlayer : KoinComponent {
} catch (ignored: Throwable) { } catch (ignored: Throwable) {
} }
if (file.isRawFile()) nextMediaPlayer!!.setDataSource(file.getRawFilePath()) if (file!!.isRawFile) nextMediaPlayer!!.setDataSource(file.rawFilePath)
else { else {
val descriptor = file.getDocumentFileDescriptor("r")!! val descriptor = file.getDocumentFileDescriptor("r")!!
nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor) nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor)
@ -614,8 +614,7 @@ class LocalMediaPlayer : KoinComponent {
private fun bufferComplete(): Boolean { private fun bufferComplete(): Boolean {
val completeFileAvailable = downloadFile.isWorkDone val completeFileAvailable = downloadFile.isWorkDone
val size = if (!StorageFile.isPathExists(partialFile)) 0 val size = StorageFile.getFromPath(partialFile)?.length ?: 0
else StorageFile.getFromPath(partialFile).length()
Timber.i( Timber.i(
"Buffering %s (%d/%d, %s)", "Buffering %s (%d/%d, %s)",
@ -672,8 +671,8 @@ class LocalMediaPlayer : KoinComponent {
val completeFileAvailable = downloadFile!!.isWorkDone val completeFileAvailable = downloadFile!!.isWorkDone
val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
val length = if (partialFile == null || !StorageFile.isPathExists(partialFile)) 0 val length = if (partialFile == null) 0
else StorageFile.getFromPath(partialFile).length() else StorageFile.getFromPath(partialFile)?.length ?: 0
Timber.i("Buffering next %s (%d)", partialFile, length) Timber.i("Buffering next %s (%d)", partialFile, length)

View File

@ -205,6 +205,9 @@ class MediaPlayerService : Service() {
@Synchronized @Synchronized
fun setNextPlaying() { fun setNextPlaying() {
// Download the next few songs if necessary
downloader.checkDownloads()
if (!Settings.gaplessPlayback) { if (!Settings.gaplessPlayback) {
localMediaPlayer.clearNextPlaying(true) localMediaPlayer.clearNextPlaying(true)
return return
@ -289,7 +292,6 @@ class MediaPlayerService : Service() {
localMediaPlayer.play(downloader.getPlaylist()[index]) localMediaPlayer.play(downloader.getPlaylist()[index])
} }
} }
downloader.checkDownloads()
setNextPlaying() setNextPlaying()
} }
} }

View File

@ -55,8 +55,8 @@ class OfflineMusicService : MusicService, KoinComponent {
val root = FileUtil.musicDirectory val root = FileUtil.musicDirectory
for (file in FileUtil.listFiles(root)) { for (file in FileUtil.listFiles(root)) {
if (file.isDirectory) { if (file.isDirectory) {
val index = Index(file.getPath()) val index = Index(file.path)
index.id = file.getPath() index.id = file.path
index.index = file.name.substring(0, 1) index.index = file.name.substring(0, 1)
index.name = file.name index.name = file.name
indexes.add(index) indexes.add(index)
@ -102,7 +102,7 @@ class OfflineMusicService : MusicService, KoinComponent {
): MusicDirectory { ): MusicDirectory {
val dir = StorageFile.getFromPath(id) val dir = StorageFile.getFromPath(id)
val result = MusicDirectory() val result = MusicDirectory()
result.name = dir.name result.name = dir?.name ?: return result
val seen: MutableCollection<String?> = HashSet() val seen: MutableCollection<String?> = HashSet()
@ -127,7 +127,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val artistName = artistFile.name val artistName = artistFile.name
if (artistFile.isDirectory) { if (artistFile.isDirectory) {
if (matchCriteria(criteria, artistName).also { closeness = it } > 0) { 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.index = artistFile.name.substring(0, 1)
artist.name = artistName artist.name = artistName
artist.closeness = closeness artist.closeness = closeness
@ -205,12 +205,10 @@ class OfflineMusicService : MusicService, KoinComponent {
var line = buffer.readLine() var line = buffer.readLine()
if ("#EXTM3U" != line) return playlist if ("#EXTM3U" != line) return playlist
while (buffer.readLine().also { line = it } != null) { while (buffer.readLine().also { line = it } != null) {
if (StorageFile.isPathExists(line)) { val entryFile = StorageFile.getFromPath(line) ?: continue
val entryFile = StorageFile.getFromPath(line) val entryName = getName(entryFile.name, entryFile.isDirectory)
val entryName = getName(entryFile.name, entryFile.isDirectory) if (entryName != null) {
if (entryName != null) { playlist.addChild(createEntry(entryFile, entryName))
playlist.addChild(createEntry(entryFile, entryName))
}
} }
} }
playlist playlist
@ -500,12 +498,12 @@ class OfflineMusicService : MusicService, KoinComponent {
@Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth") @Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth")
private fun createEntry(file: StorageFile, name: String?): MusicDirectory.Entry { 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.isDirectory = file.isDirectory
entry.parent = file.getParent()!!.getPath() entry.parent = file.parent!!.path
entry.size = if (file.isFile) file.length() else 0 entry.size = if (file.isFile) file.length else 0
val root = FileUtil.musicDirectory.getPath() val root = FileUtil.musicDirectory.path
entry.path = file.getPath().replaceFirst( entry.path = file.path.replaceFirst(
String.format(Locale.ROOT, "^%s/", root).toRegex(), "" String.format(Locale.ROOT, "^%s/", root).toRegex(), ""
) )
entry.title = name entry.title = name
@ -522,7 +520,7 @@ class OfflineMusicService : MusicService, KoinComponent {
try { try {
val mmr = MediaMetadataRetriever() val mmr = MediaMetadataRetriever()
if (file.isRawFile()) mmr.setDataSource(file.getRawFilePath()) if (file.isRawFile) mmr.setDataSource(file.rawFilePath)
else { else {
val descriptor = file.getDocumentFileDescriptor("r")!! val descriptor = file.getDocumentFileDescriptor("r")!!
mmr.setDataSource(descriptor.fileDescriptor) mmr.setDataSource(descriptor.fileDescriptor)
@ -541,8 +539,8 @@ class OfflineMusicService : MusicService, KoinComponent {
mmr.release() mmr.release()
} catch (ignored: Exception) { } catch (ignored: Exception) {
} }
entry.artist = artist ?: file.getParent()!!.getParent()!!.name entry.artist = artist ?: file.parent!!.parent!!.name
entry.album = album ?: file.getParent()!!.name entry.album = album ?: file.parent!!.name
if (title != null) { if (title != null) {
entry.title = title entry.title = title
} }

View File

@ -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() { fun clean() {
launch(exceptionHandler("clean")) { if (cleaning) return
backgroundCleanup() synchronized(lock) {
if (cleaning) return
cleaning = true
launch(exceptionHandler("clean")) {
backgroundCleanup()
}
} }
} }
fun cleanSpace() { fun cleanSpace() {
launch(exceptionHandler("cleanSpace")) { if (spaceCleaning) return
backgroundSpaceCleanup() synchronized(lock) {
if (spaceCleaning) return
spaceCleaning = true
launch(exceptionHandler("cleanSpace")) {
backgroundSpaceCleanup()
}
} }
} }
fun cleanPlaylists(playlists: List<Playlist>) { fun cleanPlaylists(playlists: List<Playlist>) {
launch(exceptionHandler("cleanPlaylists")) { if (playlistCleaning) return
backgroundPlaylistsCleanup(playlists) 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) deleteEmptyDirs(dirs, filesToNotDelete)
} catch (all: RuntimeException) { } catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.") Timber.e(all, "Error in cache cleaning.")
} finally {
cleaning = false
} }
} }
@ -82,6 +101,8 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
} }
} catch (all: RuntimeException) { } catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.") Timber.e(all, "Error in cache cleaning.")
} finally {
spaceCleaning = false
} }
} }
@ -104,27 +125,34 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
} }
} catch (all: RuntimeException) { } catch (all: RuntimeException) {
Timber.e(all, "Error in playlist cache cleaning.") Timber.e(all, "Error in playlist cache cleaning.")
} finally {
playlistCleaning = false
} }
} }
companion object { 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 const val MIN_FREE_SPACE = 500 * 1024L * 1024L
private fun deleteEmptyDirs(dirs: Iterable<StorageFile>, doNotDelete: Collection<String>) { private fun deleteEmptyDirs(dirs: Iterable<StorageFile>, doNotDelete: Collection<String>) {
for (dir in dirs) { for (dir in dirs) {
if (doNotDelete.contains(dir.getPath())) continue if (doNotDelete.contains(dir.path)) continue
var children = dir.listFiles() var children = dir.listFiles()
if (children != null) { if (children != null) {
// No songs left in the folder // 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 Artwork files
delete(getAlbumArtFile(dir.getPath())) delete(getAlbumArtFile(dir.path))
children = dir.listFiles() children = dir.listFiles()
} }
// Delete empty directory // Delete empty directory
if (children != null && children.isEmpty()) { 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 var bytesUsedBySubsonic = 0L
for (file in files) { for (file in files) {
bytesUsedBySubsonic += file.length() bytesUsedBySubsonic += file.length
} }
// Ensure that file system is not more than 95% full. // 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 bytesTotalFs: Long
val bytesAvailableFs: Long val bytesAvailableFs: Long
if (files[0].isRawFile()) { if (files[0].isRawFile) {
val stat = StatFs(files[0].getRawFilePath()) val stat = StatFs(files[0].rawFilePath)
bytesTotalFs = stat.blockCountLong * stat.blockSizeLong bytesTotalFs = stat.blockCountLong * stat.blockSizeLong
bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong
bytesUsedFs = bytesTotalFs - bytesAvailableFs bytesUsedFs = bytesTotalFs - bytesAvailableFs
@ -201,9 +229,9 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
for (file in files) { for (file in files) {
if (!deletePartials && bytesDeleted > bytesToDelete) break if (!deletePartials && bytesDeleted > bytesToDelete) break
if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) { if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) {
if (!doNotDelete.contains(file.getPath()) && file.name != Constants.ALBUM_ART_FILE) { if (!doNotDelete.contains(file.path) && file.name != Constants.ALBUM_ART_FILE) {
val size = file.length() val size = file.length
if (delete(file.getPath())) { if (delete(file.path)) {
bytesDeleted += size bytesDeleted += size
} }
} }
@ -230,7 +258,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private fun sortByAscendingModificationTime(files: MutableList<StorageFile>) { private fun sortByAscendingModificationTime(files: MutableList<StorageFile>) {
files.sortWith { a: StorageFile, b: StorageFile -> 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(downloadFile.completeOrSaveFile)
} }
filesToNotDelete.add(musicDirectory.getPath()) filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete return filesToNotDelete
} }
} }

View File

@ -64,6 +64,7 @@ object Constants {
const val PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi" const val PREFERENCES_KEY_MAX_BITRATE_WIFI = "maxBitrateWifi"
const val PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile" const val PREFERENCES_KEY_MAX_BITRATE_MOBILE = "maxBitrateMobile"
const val PREFERENCES_KEY_CACHE_SIZE = "cacheSize" 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_CACHE_LOCATION = "cacheLocation"
const val PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount" const val PREFERENCES_KEY_PRELOAD_COUNT = "preloadCount"
const val PREFERENCES_KEY_HIDE_MEDIA = "hideMedia" const val PREFERENCES_KEY_HIDE_MEDIA = "hideMedia"

View File

@ -131,7 +131,7 @@ object FileUtil {
*/ */
fun getArtistArtKey(name: String?, large: Boolean): String { fun getArtistArtKey(name: String?, large: Boolean): String {
val artist = fileSystemSafe(name) 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) return getAlbumArtKey(dir, large)
} }
@ -194,7 +194,7 @@ object FileUtil {
dir = String.format( dir = String.format(
Locale.ROOT, Locale.ROOT,
"%s/%s", "%s/%s",
musicDirectory.getPath(), musicDirectory.path,
if (entry.isDirectory) f else getParentPath(f) ?: "" if (entry.isDirectory) f else getParentPath(f) ?: ""
) )
} else { } else {
@ -203,7 +203,7 @@ object FileUtil {
if (UNNAMED == album) { if (UNNAMED == album) {
album = fileSystemSafe(entry.title) 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 return dir
} }
@ -241,7 +241,7 @@ object FileUtil {
@JvmStatic @JvmStatic
val musicDirectory: StorageFile val musicDirectory: StorageFile
get() = StorageFile.getMediaRoot() get() = StorageFile.mediaRoot.value
@JvmStatic @JvmStatic
@Suppress("ReturnCount") @Suppress("ReturnCount")
@ -320,7 +320,7 @@ object FileUtil {
fun listFiles(dir: StorageFile): SortedSet<StorageFile> { fun listFiles(dir: StorageFile): SortedSet<StorageFile> {
val files = dir.listFiles() val files = dir.listFiles()
if (files == null) { 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()
} }
return TreeSet(files.asList()) return TreeSet(files.asList())
@ -500,8 +500,9 @@ object FileUtil {
@JvmStatic @JvmStatic
fun delete(file: String?): Boolean { fun delete(file: String?): Boolean {
if (file != null && StorageFile.isPathExists(file)) { if (file != null) {
if (!StorageFile.getFromPath(file).delete()) { val storageFile = StorageFile.getFromPath(file)
if (storageFile != null && !storageFile.delete()) {
Timber.w("Failed to delete file %s", file) Timber.w("Failed to delete file %s", file)
return false return false
} }
@ -509,5 +510,4 @@ object FileUtil {
} }
return true return true
} }
} }

View File

@ -0,0 +1,25 @@
package org.moire.ultrasonic.util
import java.util.concurrent.atomic.AtomicReference
import kotlin.reflect.KProperty
class ResettableLazy<T>(private val initializer: () -> T) {
private val lazyRef: AtomicReference<Lazy<T>> = 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))
}
}

View File

@ -105,6 +105,12 @@ object Settings {
return if (cacheSize == -1) Int.MAX_VALUE else cacheSize return if (cacheSize == -1) Int.MAX_VALUE else cacheSize
} }
@JvmStatic
var customCacheLocation by BooleanSetting(
Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION,
false
)
@JvmStatic @JvmStatic
var cacheLocation by StringSetting( var cacheLocation by StringSetting(
Constants.PREFERENCES_KEY_CACHE_LOCATION, Constants.PREFERENCES_KEY_CACHE_LOCATION,

View File

@ -12,8 +12,11 @@ import android.net.Uri
import com.github.k1rakishou.fsaf.FileManager import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.document_file.CachingDocumentFile import com.github.k1rakishou.fsaf.document_file.CachingDocumentFile
import com.github.k1rakishou.fsaf.file.AbstractFile 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.file.RawFile
import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory
import org.moire.ultrasonic.R
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@ -22,19 +25,20 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/** /**
* Provides filesystem access abstraction which works * Provides filesystem access abstraction which works
* both on File based paths and Storage Access Framework Uris * both on File based paths and Storage Access Framework Uris
*/ */
class StorageFile private constructor( class StorageFile private constructor(
private var parent: StorageFile?, private var parentStorageFile: StorageFile?,
private var abstractFile: AbstractFile, private var abstractFile: AbstractFile,
private var fileManager: FileManager private var fileManager: FileManager
): Comparable<StorageFile> { ): Comparable<StorageFile> {
override fun compareTo(other: StorageFile): Int { override fun compareTo(other: StorageFile): Int {
return getPath().compareTo(other.getPath()) return path.compareTo(other.path)
} }
override fun toString(): String { override fun toString(): String {
@ -47,25 +51,28 @@ class StorageFile private constructor(
var isFile: Boolean = fileManager.isFile(abstractFile) 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<StorageFile> { fun listFiles(): Array<StorageFile> {
val fileList = fileManager.listFiles(abstractFile) val fileList = fileManager.listFiles(abstractFile)
return fileList.map { file -> StorageFile(this, file, fileManager) }.toTypedArray() 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 { 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 mode = if (append) "wa" else "w"
val descriptor = UApp.applicationContext().contentResolver.openAssetFileDescriptor( val descriptor = UApp.applicationContext().contentResolver.openAssetFileDescriptor(
abstractFile.getFileRoot<CachingDocumentFile>().holder.uri(), mode) abstractFile.getFileRoot<CachingDocumentFile>().holder.uri(), mode)
@ -74,38 +81,43 @@ class StorageFile private constructor(
} }
fun getFileInputStream(): InputStream { fun getFileInputStream(): InputStream {
if (isRawFile()) return FileInputStream(abstractFile.getFullPath()) if (isRawFile) return FileInputStream(abstractFile.getFullPath())
return fileManager.getInputStream(abstractFile) return fileManager.getInputStream(abstractFile)
?: throw IOException("Couldn't retrieve InputStream") ?: throw IOException("Couldn't retrieve InputStream")
} }
// TODO there are a few functions which could be getters val path: String
// They are functions for now to help us distinguish them from similar getters in File. These can be changed after the refactor is complete. get() {
fun getPath(): String { if (isRawFile) return abstractFile.getFullPath()
if (isRawFile()) return abstractFile.getFullPath()
if (getParent() != null) return getParent()!!.getPath() + "/" + name
return Uri.parse(abstractFile.getFullPath()).toString()
}
fun getParent(): StorageFile? { // We can't assume that the file's Uri is related to its path,
if (isRawFile()) { // so we generate our own path by concatenating the names on the path.
return StorageFile( if (parentStorageFile != null) return parentStorageFile!!.path + "/" + name
null, return Uri.parse(abstractFile.getFullPath()).toString()
fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!),
fileManager
)
} }
return parent
}
fun isRawFile(): Boolean { val parent: StorageFile?
return abstractFile is RawFile get() {
} if (isRawFile) {
return StorageFile(
null,
fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!),
fileManager
)
}
return parentStorageFile
}
fun getRawFilePath(): String? { val isRawFile: Boolean
return if (abstractFile is RawFile) abstractFile.getFullPath() get() {
else null return abstractFile is RawFile
} }
val rawFilePath: String?
get() {
return if (abstractFile is RawFile) abstractFile.getFullPath()
else null
}
fun getDocumentFileDescriptor(openMode: String): AssetFileDescriptor? { fun getDocumentFileDescriptor(openMode: String): AssetFileDescriptor? {
return if (abstractFile !is RawFile) { return if (abstractFile !is RawFile) {
@ -117,53 +129,38 @@ class StorageFile private constructor(
} }
companion object { companion object {
// TODO it would be nice to check the access rights and reset the cache directory on error // These caches are necessary because SAF is very slow, and the caching in FSAF is buggy.
private val MusicCacheFileManager: Lazy<FileManager> = lazy { // 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<String, StorageFile>()
private val notExistingPathDictionary = ConcurrentHashMap<String, String>()
private val fileManager: ResettableLazy<FileManager> = ResettableLazy {
val manager = FileManager(UApp.applicationContext()) val manager = FileManager(UApp.applicationContext())
manager.registerBaseDir<MusicCacheBaseDirectory>(MusicCacheBaseDirectory()) manager.registerBaseDir<MusicCacheBaseDirectory>(MusicCacheBaseDirectory())
manager manager
} }
fun getFromParentAndName(parent: StorageFile, name: String): StorageFile { val mediaRoot: ResettableLazy<StorageFile> = ResettableLazy {
val file = parent.fileManager.findFile(parent.abstractFile, name) StorageFile(
?: parent.fileManager.createFile(parent.abstractFile, name)!!
return StorageFile(parent, file, parent.fileManager)
}
fun getMediaRoot(): StorageFile {
return StorageFile(
null, null,
MusicCacheFileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!, fileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!,
MusicCacheFileManager.value fileManager.value
) )
} }
// TODO sometimes getFromPath is called after isPathExists, but the file may be gone because it was deleted in another thread. fun resetCaches() {
// Create a function where these two are merged storageFilePathDictionary.clear()
fun getFromPath(path: String): StorageFile { notExistingPathDictionary.clear()
Timber.v("StorageFile getFromPath %s", path) fileManager.value.unregisterBaseDir<MusicCacheBaseDirectory>()
val normalizedPath = normalizePath(path) fileManager.reset()
if (!normalizedPath.isUri()) { mediaRoot.reset()
return StorageFile( Timber.v("StorageFile caches were reset")
null, if (!fileManager.value.baseDirectoryExists<MusicCacheBaseDirectory>()) {
MusicCacheFileManager.value.fromPath(normalizedPath), Settings.cacheLocation = FileUtil.defaultMusicDirectory.path
MusicCacheFileManager.value 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 { fun getOrCreateFileFromPath(path: String): StorageFile {
@ -172,37 +169,70 @@ class StorageFile private constructor(
File(normalizedPath).createNewFile() File(normalizedPath).createNewFile()
return StorageFile( return StorageFile(
null, null,
MusicCacheFileManager.value.fromPath(normalizedPath), fileManager.value.fromPath(normalizedPath),
MusicCacheFileManager.value fileManager.value
) )
} }
val segments = getUriSegments(normalizedPath) if (storageFilePathDictionary.containsKey(normalizedPath))
?: throw IOException("Can't get path because the root has changed") return storageFilePathDictionary[normalizedPath]!!
var file = StorageFile(null, getMediaRoot().abstractFile, MusicCacheFileManager.value) val parent = getStorageFileForParentDirectory(normalizedPath)
segments.forEach { segment -> ?: throw IOException("Parent directory doesn't exist")
file = StorageFile(
file, val name = FileUtil.getNameFromPath(normalizedPath)
MusicCacheFileManager.value.findFile(file.abstractFile, segment) val file = StorageFile(
?: MusicCacheFileManager.value.createFile(file.abstractFile, segment)!!, parent,
file.fileManager fileManager.value.findFile(parent.abstractFile, name)
) ?: fileManager.value.create(parent.abstractFile,
} listOf(FileSegment(name))
)!!,
parent.fileManager
)
storageFilePathDictionary[normalizedPath] = file
notExistingPathDictionary.remove(normalizedPath)
return file return file
} }
fun isPathExists(path: String): Boolean { fun isPathExists(path: String): Boolean {
return getFromPath(path) != null
}
fun getFromPath(path: String): StorageFile? {
val normalizedPath = normalizePath(path) val normalizedPath = normalizePath(path)
if (!normalizedPath.isUri()) return File(normalizedPath).exists() if (!normalizedPath.isUri()) {
val file = fileManager.value.fromPath(normalizedPath)
val segments = getUriSegments(normalizedPath) ?: return false if (!fileManager.value.exists(file)) return null
return StorageFile(null, file, fileManager.value)
var file = getMediaRoot().abstractFile
segments.forEach { segment ->
file = MusicCacheFileManager.value.findFile(file, segment) ?: return false
} }
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) { fun createDirsOnPath(path: String) {
@ -215,29 +245,81 @@ class StorageFile private constructor(
val segments = getUriSegments(normalizedPath) val segments = getUriSegments(normalizedPath)
?: throw IOException("Can't get path because the root has changed") ?: throw IOException("Can't get path because the root has changed")
var file = getMediaRoot().abstractFile var file = mediaRoot.value
segments.forEach { segment -> segments.forEach { segment ->
file = MusicCacheFileManager.value.createDir(file, segment) file = StorageFile(
?: throw IOException("Can't create directory") 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) { fun rename(pathFrom: String, pathTo: String) {
val normalizedPathFrom = normalizePath(pathFrom) 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) 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 parentTo = getFromPath(FileUtil.getParentPath(normalizedPathTo)!!) ?: throw IOException("Destination folder doesn't exist")
val fileFrom = getFromPath(normalizedPathFrom)
val parentTo = getFromPath(FileUtil.getParentPath(normalizedPathTo)!!)
val fileTo = getFromParentAndName(parentTo, FileUtil.getNameFromPath(normalizedPathTo)) val fileTo = getFromParentAndName(parentTo, FileUtil.getNameFromPath(normalizedPathTo))
notExistingPathDictionary.remove(normalizedPathTo)
storageFilePathDictionary.remove(normalizePath(pathFrom.path))
MusicCacheFileManager.value.copyFileContents(fileFrom.abstractFile, fileTo.abstractFile) fileManager.value.copyFileContents(pathFrom.abstractFile, fileTo.abstractFile)
fileFrom.delete() 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<String>? { private fun getUriSegments(uri: String): List<String>? {
val rootPath = getMediaRoot().getPath() val rootPath = mediaRoot.value.path
if (!uri.startsWith(rootPath)) return null if (!uri.startsWith(rootPath)) return null
val pathWithoutRoot = uri.substringAfter(rootPath) val pathWithoutRoot = uri.substringAfter(rootPath)
return pathWithoutRoot.split('/').filter { it.isNotEmpty() } return pathWithoutRoot.split('/').filter { it.isNotEmpty() }

View File

@ -756,7 +756,7 @@ object Util {
fun scanMedia(file: String?) { fun scanMedia(file: String?) {
// TODO this doesn't work for URIs // TODO this doesn't work for URIs
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(
UApp.applicationContext(), arrayOf(file), applicationContext(), arrayOf(file),
null, null) null, null)
} }

View File

@ -172,6 +172,7 @@
<string name="settings.buffer_length_5">5 seconds</string> <string name="settings.buffer_length_5">5 seconds</string>
<string name="settings.buffer_length_60">1 minute</string> <string name="settings.buffer_length_60">1 minute</string>
<string name="settings.buffer_length_8">8 seconds</string> <string name="settings.buffer_length_8">8 seconds</string>
<string name="settings.custom_cache_location">Use Custom Cache Location</string>
<string name="settings.cache_location">Cache Location</string> <string name="settings.cache_location">Cache Location</string>
<string name="settings.cache_location_error">Invalid cache location. Using default.</string> <string name="settings.cache_location_error">Invalid cache location. Using default.</string>
<string name="settings.cache_size">Cache Size</string> <string name="settings.cache_size">Cache Size</string>

View File

@ -261,6 +261,10 @@
a:key="cacheSize" a:key="cacheSize"
a:title="@string/settings.cache_size" a:title="@string/settings.cache_size"
app:iconSpaceReserved="false"/> app:iconSpaceReserved="false"/>
<CheckBoxPreference
a:key="customCacheLocation"
a:title="@string/settings.custom_cache_location"
app:iconSpaceReserved="false"/>
<Preference <Preference
a:key="cacheLocation" a:key="cacheLocation"
a:title="@string/settings.cache_location" a:title="@string/settings.cache_location"