ultrasonic-app-subsonic-and.../ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt

360 lines
14 KiB
Kotlin

/*
* StorageFile.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.content.res.AssetFileDescriptor
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
import java.io.IOException
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 parentStorageFile: StorageFile?,
private var abstractFile: AbstractFile,
private var fileManager: FileManager
): Comparable<StorageFile> {
override fun compareTo(other: StorageFile): Int {
return path.compareTo(other.path)
}
override fun toString(): String {
return name
}
var name: String = fileManager.getName(abstractFile)
var isDirectory: Boolean = fileManager.isDirectory(abstractFile)
var isFile: Boolean = fileManager.isFile(abstractFile)
val length: Long
get() = fileManager.getLength(abstractFile)
val lastModified: Long
get() = fileManager.lastModified(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(append: Boolean): OutputStream {
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)
return descriptor?.createOutputStream()
?: throw IOException("Couldn't retrieve OutputStream")
}
fun getFileInputStream(): InputStream {
if (isRawFile) return FileInputStream(abstractFile.getFullPath())
return fileManager.getInputStream(abstractFile)
?: throw IOException("Couldn't retrieve InputStream")
}
val path: String
get() {
if (isRawFile) return abstractFile.getFullPath()
// 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()
}
val parent: StorageFile?
get() {
if (isRawFile) {
return StorageFile(
null,
fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!),
fileManager
)
}
return parentStorageFile
}
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) {
UApp.applicationContext().contentResolver.openAssetFileDescriptor(
abstractFile.getFileRoot<CachingDocumentFile>().holder.uri(),
openMode
)
} else null
}
companion object {
// 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
}
val mediaRoot: ResettableLazy<StorageFile> = ResettableLazy {
StorageFile(
null,
fileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!,
fileManager.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)
}
}
fun getOrCreateFileFromPath(path: String): StorageFile {
val normalizedPath = normalizePath(path)
if (!normalizedPath.isUri()) {
File(normalizedPath).createNewFile()
return StorageFile(
null,
fileManager.value.fromPath(normalizedPath),
fileManager.value
)
}
if (storageFilePathDictionary.containsKey(normalizedPath))
return storageFilePathDictionary[normalizedPath]!!
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()) {
val file = fileManager.value.fromPath(normalizedPath)
if (!fileManager.value.exists(file)) return null
return StorageFile(null, file, fileManager.value)
}
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) {
val normalizedPath = normalizePath(path)
if (!normalizedPath.isUri()) {
File(normalizedPath).mkdirs()
return
}
val segments = getUriSegments(normalizedPath)
?: throw IOException("Can't get path because the root has changed")
var file = mediaRoot.value
segments.forEach { segment ->
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)
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))
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 = mediaRoot.value.path
if (!uri.startsWith(rootPath)) return null
val pathWithoutRoot = uri.substringAfter(rootPath)
return pathWithoutRoot.split('/').filter { it.isNotEmpty() }
}
private fun normalizePath(path: String): String {
// FSAF replaces spaces in paths with "_", so we must do the same everywhere
// TODO paths sometimes contain double "/". These are currently replaced to single one.
// The nice solution would be to check and fix why this happens
return path.replace(' ', '_').replace(Regex("(?<!:)//"), "/")
}
}
}
class MusicCacheBaseDirectory : BaseDirectory() {
override fun getDirFile(): File {
return FileUtil.defaultMusicDirectory
}
override fun getDirUri(): Uri? {
if (!Settings.cacheLocation.isUri()) return null
return Uri.parse(Settings.cacheLocation)
}
override fun currentActiveBaseDirType(): ActiveBaseDirType {
return when {
Settings.cacheLocation.isUri() -> ActiveBaseDirType.SafBaseDir
else -> ActiveBaseDirType.JavaFileBaseDir
}
}
}
fun String.isUri(): Boolean {
// TODO is there a better way to tell apart a path and an URI?
return this.contains(':')
}