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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String?> = 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
}

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() {
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<Playlist>) {
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<StorageFile>, doNotDelete: Collection<String>) {
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<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(musicDirectory.getPath())
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
}

View File

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

View File

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

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
}
@JvmStatic
var customCacheLocation by BooleanSetting(
Constants.PREFERENCES_KEY_CUSTOM_CACHE_LOCATION,
false
)
@JvmStatic
var cacheLocation by StringSetting(
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.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<StorageFile> {
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<StorageFile> {
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<CachingDocumentFile>().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<FileManager> = 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<String, StorageFile>()
private val notExistingPathDictionary = ConcurrentHashMap<String, String>()
private val fileManager: ResettableLazy<FileManager> = ResettableLazy {
val manager = FileManager(UApp.applicationContext())
manager.registerBaseDir<MusicCacheBaseDirectory>(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<StorageFile> = ResettableLazy {
StorageFile(
null,
MusicCacheFileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!,
MusicCacheFileManager.value
fileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!,
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<MusicCacheBaseDirectory>()
fileManager.reset()
mediaRoot.reset()
Timber.v("StorageFile caches were reset")
if (!fileManager.value.baseDirectoryExists<MusicCacheBaseDirectory>()) {
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<String>? {
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() }

View File

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

View File

@ -172,6 +172,7 @@
<string name="settings.buffer_length_5">5 seconds</string>
<string name="settings.buffer_length_60">1 minute</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_error">Invalid cache location. Using default.</string>
<string name="settings.cache_size">Cache Size</string>

View File

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