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

514 lines
16 KiB
Kotlin
Raw Normal View History

2021-09-12 11:40:18 +02:00
/*
* FileUtil.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
2021-09-12 11:40:18 +02:00
*/
2021-09-12 11:40:18 +02:00
package org.moire.ultrasonic.util
import android.content.Context
import android.os.Build
import android.os.Environment
import android.text.TextUtils
2021-11-01 17:07:18 +01:00
import android.util.Pair
import java.io.BufferedWriter
2021-09-12 11:40:18 +02:00
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.FileWriter
import java.io.IOException
2021-09-12 11:40:18 +02:00
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.io.Serializable
import java.util.Locale
import java.util.SortedSet
import java.util.TreeSet
import java.util.regex.Pattern
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory
2021-11-03 14:01:02 +01:00
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
import java.io.File
2021-09-12 11:40:18 +02:00
2021-11-03 14:01:02 +01:00
@Suppress("TooManyFunctions")
2021-09-12 11:40:18 +02:00
object FileUtil {
2021-09-12 11:40:18 +02:00
private val FILE_SYSTEM_UNSAFE = arrayOf("/", "\\", "..", ":", "\"", "?", "*", "<", ">", "|")
private val FILE_SYSTEM_UNSAFE_DIR = arrayOf("\\", "..", ":", "\"", "?", "*", "<", ">", "|")
private val MUSIC_FILE_EXTENSIONS =
listOf("mp3", "ogg", "aac", "flac", "m4a", "wav", "wma", "opus")
2021-09-12 11:40:18 +02:00
private val VIDEO_FILE_EXTENSIONS =
listOf("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv")
2021-09-12 11:40:18 +02:00
private val PLAYLIST_FILE_EXTENSIONS = listOf("m3u")
private val TITLE_WITH_TRACK = Pattern.compile("^\\d\\d-.*")
const val SUFFIX_LARGE = ".jpeg"
const val SUFFIX_SMALL = ".jpeg-small"
private const val UNNAMED = "unnamed"
fun getSongFile(song: MusicDirectory.Entry): String {
2021-09-12 11:40:18 +02:00
val dir = getAlbumDirectory(song)
// Do not generate new name for offline files. Offline files will have their Path as their Id.
if (!TextUtils.isEmpty(song.id)) {
if (song.id.startsWith(dir)) return song.id
2021-09-12 11:40:18 +02:00
}
// Generate a file name for the song
val fileName = StringBuilder(256)
val track = song.track
// check if filename already had track number
if (song.title != null && !TITLE_WITH_TRACK.matcher(song.title!!).matches()) {
2021-09-12 11:40:18 +02:00
if (track != null) {
if (track < 10) {
fileName.append('0')
}
fileName.append(track).append('-')
}
}
fileName.append(fileSystemSafe(song.title)).append('.')
2021-09-12 11:40:18 +02:00
if (!TextUtils.isEmpty(song.transcodedSuffix)) {
fileName.append(song.transcodedSuffix)
} else {
fileName.append(song.suffix)
}
return "$dir/$fileName"
2021-09-12 11:40:18 +02:00
}
@JvmStatic
fun getPlaylistFile(server: String?, name: String?): File {
2021-09-12 11:40:18 +02:00
val playlistDir = getPlaylistDirectory(server)
return File(playlistDir, String.format(Locale.ROOT, "%s.m3u", fileSystemSafe(name)))
2021-09-12 11:40:18 +02:00
}
@JvmStatic
val playlistDirectory: File
2021-09-12 11:40:18 +02:00
get() {
val playlistDir = File(ultrasonicDirectory, "playlists")
ensureDirectoryExistsAndIsReadWritable(playlistDir)
return playlistDir
}
@JvmStatic
fun getPlaylistDirectory(server: String? = null): File {
val playlistDir: File
if (server != null) {
playlistDir = File(playlistDirectory, server)
} else {
playlistDir = playlistDirectory
}
2021-09-12 11:40:18 +02:00
ensureDirectoryExistsAndIsReadWritable(playlistDir)
return playlistDir
}
/**
* Get the album art file for a given album entry
* @param entry The album entry
* @return File object. Not guaranteed that it exists
*/
fun getAlbumArtFile(entry: MusicDirectory.Entry): String? {
2021-09-12 11:40:18 +02:00
val albumDir = getAlbumDirectory(entry)
return getAlbumArtFileForAlbumDir(albumDir)
2021-09-12 11:40:18 +02:00
}
/**
* Get the cache key for a given album entry
* @param entry The album entry
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
fun getAlbumArtKey(entry: MusicDirectory.Entry?, large: Boolean): String? {
if (entry == null) return null
2021-09-12 11:40:18 +02:00
val albumDir = getAlbumDirectory(entry)
return getAlbumArtKey(albumDir, large)
}
/**
* Get the cache key for a given artist
* @param name The artist name
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
fun getArtistArtKey(name: String?, large: Boolean): String {
val artist = fileSystemSafe(name)
val dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, UNNAMED)
return getAlbumArtKey(dir, large)
}
2021-09-12 11:40:18 +02:00
/**
* Get the cache key for a given album entry
* @param albumDir The album directory
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
private fun getAlbumArtKey(albumDirPath: String, large: Boolean): String {
2021-09-12 11:40:18 +02:00
val suffix = if (large) SUFFIX_LARGE else SUFFIX_SMALL
return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDirPath), suffix)
2021-09-12 11:40:18 +02:00
}
fun getAvatarFile(username: String?): File? {
if (username == null) {
2021-09-12 11:40:18 +02:00
return null
}
val albumArtDir = albumArtDirectory
val md5Hex = Util.md5Hex(username)
return File(albumArtDir, String.format(Locale.ROOT, "%s%s", md5Hex, SUFFIX_LARGE))
2021-09-12 11:40:18 +02:00
}
/**
* Get the album art file for a given album directory
* @param albumDir The album directory
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFileForAlbumDir(albumDir: String): String? {
2021-09-12 11:40:18 +02:00
val key = getAlbumArtKey(albumDir, true)
return getAlbumArtFile(key)
2021-09-12 11:40:18 +02:00
}
/**
* Get the album art file for a given cache key
* @param cacheKey The key (== the filename)
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFile(cacheKey: String?): String? {
val albumArtDir = albumArtDirectory.absolutePath
return if (cacheKey == null) {
2021-09-12 11:40:18 +02:00
null
} else "$albumArtDir/$cacheKey"
2021-09-12 11:40:18 +02:00
}
val albumArtDirectory: File
get() {
val albumArtDir = File(ultrasonicDirectory, "artwork")
ensureDirectoryExistsAndIsReadWritable(albumArtDir)
ensureDirectoryExistsAndIsReadWritable(File(albumArtDir, ".nomedia"))
return albumArtDir
}
fun getAlbumDirectory(entry: MusicDirectory.Entry): String {
val dir: String
if (!TextUtils.isEmpty(entry.path) && getParentPath(entry.path!!) != null) {
val f = fileSystemSafeDir(entry.path)
dir = String.format(
Locale.ROOT,
2021-09-12 11:40:18 +02:00
"%s/%s",
musicDirectory.getPath(),
if (entry.isDirectory) f else getParentPath(f) ?: ""
2021-09-12 11:40:18 +02:00
)
} else {
val artist = fileSystemSafe(entry.artist)
var album = fileSystemSafe(entry.album)
if (UNNAMED == album) {
album = fileSystemSafe(entry.title)
2021-09-12 11:40:18 +02:00
}
dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, album)
2021-09-12 11:40:18 +02:00
}
return dir
}
fun createDirectoryForParent(path: String) {
val dir = getParentPath(path) ?: return
StorageFile.createDirsOnPath(dir)
2021-09-12 11:40:18 +02:00
}
@Suppress("SameParameterValue")
2021-09-12 11:40:18 +02:00
private fun getOrCreateDirectory(name: String): File {
val dir = File(ultrasonicDirectory, name)
if (!dir.exists() && !dir.mkdirs()) {
Timber.e("Failed to create %s", name)
}
return dir
}
// After Android M, the location of the files must be queried differently.
// GetExternalFilesDir will always return a directory which Ultrasonic
// can access without any extra privileges.
@JvmStatic
val ultrasonicDirectory: File
get() {
2021-11-01 17:07:18 +01:00
@Suppress("DEPRECATION")
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) File(
Environment.getExternalStorageDirectory(),
"Android/data/org.moire.ultrasonic"
) else UApp.applicationContext().getExternalFilesDir(null)!!
}
2021-09-12 11:40:18 +02:00
@JvmStatic
val defaultMusicDirectory: File
2021-09-12 11:40:18 +02:00
get() = getOrCreateDirectory("music")
2021-09-12 11:40:18 +02:00
@JvmStatic
val musicDirectory: StorageFile
get() = StorageFile.getMediaRoot()
2021-09-12 11:40:18 +02:00
@JvmStatic
@Suppress("ReturnCount")
2021-11-01 17:07:18 +01:00
fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Pair<Boolean, Boolean> {
val noAccess = Pair(false, false)
2021-09-12 11:40:18 +02:00
if (dir == null) {
2021-11-01 17:07:18 +01:00
return noAccess
2021-09-12 11:40:18 +02:00
}
if (dir.exists()) {
if (!dir.isDirectory) {
Timber.w("%s exists but is not a directory.", dir)
2021-11-01 17:07:18 +01:00
return noAccess
2021-09-12 11:40:18 +02:00
}
} else {
if (dir.mkdirs()) {
Timber.i("Created directory %s", dir)
} else {
Timber.w("Failed to create directory %s", dir)
2021-11-01 17:07:18 +01:00
return noAccess
2021-09-12 11:40:18 +02:00
}
}
if (!dir.canRead()) {
Timber.w("No read permission for directory %s", dir)
2021-11-01 17:07:18 +01:00
return noAccess
2021-09-12 11:40:18 +02:00
}
if (!dir.canWrite()) {
Timber.w("No write permission for directory %s", dir)
2021-11-01 17:07:18 +01:00
return Pair(true, false)
2021-09-12 11:40:18 +02:00
}
2021-11-01 17:07:18 +01:00
return Pair(true, true)
2021-09-12 11:40:18 +02:00
}
/**
* Makes a given filename safe by replacing special characters like slashes ("/" and "\")
* with dashes ("-").
*
* @param name The filename in question.
2021-09-12 11:40:18 +02:00
* @return The filename with special characters replaced by hyphens.
*/
private fun fileSystemSafe(name: String?): String {
if (name == null || name.trim { it <= ' ' }.isEmpty()) {
return UNNAMED
2021-09-12 11:40:18 +02:00
}
var filename: String = name
2021-09-12 11:40:18 +02:00
for (s in FILE_SYSTEM_UNSAFE) {
filename = filename.replace(s, "-")
}
return filename
}
/**
* Makes a given filename safe by replacing special characters like colons (":")
* with dashes ("-").
*
* @param path The path of the directory in question.
* @return The the directory name with special characters replaced by hyphens.
*/
private fun fileSystemSafeDir(path: String?): String {
var filepath = path
if (filepath == null || filepath.trim { it <= ' ' }.isEmpty()) {
2021-09-12 11:40:18 +02:00
return ""
}
for (s in FILE_SYSTEM_UNSAFE_DIR) {
filepath = filepath!!.replace(s, "-")
2021-09-12 11:40:18 +02:00
}
return filepath!!
2021-09-12 11:40:18 +02:00
}
/**
* Similar to [File.listFiles], but returns a sorted set.
* Never returns `null`, instead a warning is logged, and an empty set is returned.
*/
@JvmStatic
fun listFiles(dir: StorageFile): SortedSet<StorageFile> {
val files = dir.listFiles()
if (files == null) {
Timber.w("Failed to list children for %s", dir.getPath())
return TreeSet()
}
return TreeSet(files.asList())
}
@JvmStatic
fun listFiles(dir: File): SortedSet<File> {
2021-09-12 11:40:18 +02:00
val files = dir.listFiles()
if (files == null) {
Timber.w("Failed to list children for %s", dir.path)
return TreeSet()
}
return TreeSet(files.asList())
2021-09-12 11:40:18 +02:00
}
fun listMediaFiles(dir: StorageFile): SortedSet<StorageFile> {
2021-09-12 11:40:18 +02:00
val files = listFiles(dir)
val iterator = files.iterator()
while (iterator.hasNext()) {
val file = iterator.next()
if (!file.isDirectory && !isMediaFile(file)) {
iterator.remove()
}
}
return files
}
private fun isMediaFile(file: StorageFile): Boolean {
2021-09-12 11:40:18 +02:00
val extension = getExtension(file.name)
return MUSIC_FILE_EXTENSIONS.contains(extension) ||
VIDEO_FILE_EXTENSIONS.contains(extension)
2021-09-12 11:40:18 +02:00
}
fun isPlaylistFile(file: File): Boolean {
val extension = getExtension(file.name)
return PLAYLIST_FILE_EXTENSIONS.contains(extension)
}
/**
* Returns the extension (the substring after the last dot) of the given file. The dot
* is not included in the returned extension.
*
* @param name The filename in question.
* @return The extension, or an empty string if no extension is found.
*/
fun getExtension(name: String): String {
val index = name.lastIndexOf('.')
return if (index == -1) "" else name.substring(index + 1).lowercase(Locale.ROOT)
2021-09-12 11:40:18 +02:00
}
/**
* Returns the base name (the substring before the last dot) of the given file. The dot
* is not included in the returned basename.
*
* @param name The filename in question.
* @return The base name, or an empty string if no basename is found.
*/
fun getBaseName(name: String): String {
val index = name.lastIndexOf('.')
return if (index == -1) name else name.substring(0, index)
}
/**
* Returns the file name of a .partial file of the given file.
*
* @param name The filename in question.
* @return The .partial file name
*/
fun getPartialFile(name: String): String {
return String.format(Locale.ROOT, "%s.partial.%s", getBaseName(name), getExtension(name))
2021-09-12 11:40:18 +02:00
}
fun getNameFromPath(path: String): String {
return path.substringAfterLast('/')
}
fun getParentPath(path: String): String? {
if (!path.contains('/')) return null
return path.substringBeforeLast('/')
}
2021-09-12 11:40:18 +02:00
/**
* Returns the file name of a .complete file of the given file.
*
* @param name The filename in question.
* @return The .complete file name
*/
fun getCompleteFile(name: String): String {
return String.format(Locale.ROOT, "%s.complete.%s", getBaseName(name), getExtension(name))
2021-09-12 11:40:18 +02:00
}
@JvmStatic
fun <T : Serializable?> serialize(context: Context, obj: T, fileName: String): Boolean {
2021-09-12 11:40:18 +02:00
val file = File(context.cacheDir, fileName)
var out: ObjectOutputStream? = null
return try {
out = ObjectOutputStream(FileOutputStream(file))
out.writeObject(obj)
Timber.i("Serialized object to %s", file)
true
} catch (ignored: Exception) {
2021-09-12 11:40:18 +02:00
Timber.w("Failed to serialize object to %s", file)
false
} finally {
2021-11-03 14:01:02 +01:00
out.safeClose()
2021-09-12 11:40:18 +02:00
}
}
@Suppress("UNCHECKED_CAST")
2021-09-12 11:40:18 +02:00
@JvmStatic
fun <T : Serializable?> deserialize(context: Context, fileName: String): T? {
2021-09-12 11:40:18 +02:00
val file = File(context.cacheDir, fileName)
if (!file.exists() || !file.isFile) {
return null
}
var inStream: ObjectInputStream? = null
2021-09-12 11:40:18 +02:00
return try {
inStream = ObjectInputStream(FileInputStream(file))
val readObject = inStream.readObject()
val result = readObject as T
2021-09-12 11:40:18 +02:00
Timber.i("Deserialized object from %s", file)
result
} catch (all: Throwable) {
Timber.w(all, "Failed to deserialize object from %s", file)
2021-09-12 11:40:18 +02:00
null
} finally {
2021-11-03 14:01:02 +01:00
inStream.safeClose()
}
}
fun savePlaylist(
playlistFile: File?,
playlist: MusicDirectory,
name: String
) {
val fw = FileWriter(playlistFile)
val bw = BufferedWriter(fw)
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = getSongFile(e)
if (!StorageFile.isPathExists(filePath)) {
val ext = getExtension(filePath)
val base = getBaseName(filePath)
filePath = "$base.complete.$ext"
}
fw.write(filePath + "\n")
}
} catch (e: IOException) {
Timber.w("Failed to save playlist: %s", name)
throw e
} finally {
2021-11-03 14:01:02 +01:00
bw.safeClose()
fw.safeClose()
2021-09-12 11:40:18 +02:00
}
}
2021-11-03 14:01:02 +01:00
@JvmStatic
@Throws(IOException::class)
2021-11-19 19:09:27 +01:00
fun renameFile(from: String, to: String) {
StorageFile.rename(from, to)
2021-11-03 14:01:02 +01:00
}
@JvmStatic
fun delete(file: File?): Boolean {
if (file != null && file.exists()) {
if (!file.delete()) {
Timber.w("Failed to delete file %s", file)
return false
}
Timber.i("Deleted file %s", file)
}
return true
}
2021-11-19 19:09:27 +01:00
@JvmStatic
fun delete(file: String?): Boolean {
if (file != null && StorageFile.isPathExists(file)) {
if (!StorageFile.getFromPath(file).delete()) {
Timber.w("Failed to delete file %s", file)
return false
}
Timber.i("Deleted file %s", file)
}
return true
}
}