/* * FileUtil.kt * Copyright (C) 2009-2021 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ package org.moire.ultrasonic.util import android.content.Context import android.os.Build import android.os.Environment import android.text.TextUtils import android.util.Pair import java.io.BufferedWriter import java.io.FileInputStream import java.io.FileOutputStream import java.io.FileWriter import java.io.IOException 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 import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber import java.io.File @Suppress("TooManyFunctions") object FileUtil { 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") private val VIDEO_FILE_EXTENSIONS = listOf("flv", "mp4", "m4v", "wmv", "avi", "mov", "mpg", "mkv") 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 { 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 } // 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()) { if (track != null) { if (track < 10) { fileName.append('0') } fileName.append(track).append('-') } } fileName.append(fileSystemSafe(song.title)).append('.') if (!TextUtils.isEmpty(song.transcodedSuffix)) { fileName.append(song.transcodedSuffix) } else { fileName.append(song.suffix) } return "$dir/$fileName" } @JvmStatic fun getPlaylistFile(server: String?, name: String?): File { val playlistDir = getPlaylistDirectory(server) return File(playlistDir, String.format(Locale.ROOT, "%s.m3u", fileSystemSafe(name))) } @JvmStatic val playlistDirectory: File 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 } 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? { val albumDir = getAlbumDirectory(entry) return getAlbumArtFileForAlbumDir(albumDir) } /** * 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 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) } /** * 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 { val suffix = if (large) SUFFIX_LARGE else SUFFIX_SMALL return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDirPath), suffix) } fun getAvatarFile(username: String?): File? { if (username == null) { return null } val albumArtDir = albumArtDirectory val md5Hex = Util.md5Hex(username) return File(albumArtDir, String.format(Locale.ROOT, "%s%s", md5Hex, SUFFIX_LARGE)) } /** * 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? { val key = getAlbumArtKey(albumDir, true) return getAlbumArtFile(key) } /** * 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) { null } else "$albumArtDir/$cacheKey" } 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, "%s/%s", musicDirectory.getPath(), if (entry.isDirectory) f else getParentPath(f) ?: "" ) } else { val artist = fileSystemSafe(entry.artist) var album = fileSystemSafe(entry.album) if (UNNAMED == album) { album = fileSystemSafe(entry.title) } dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, album) } return dir } fun createDirectoryForParent(path: String) { val dir = getParentPath(path) ?: return StorageFile.createDirsOnPath(dir) } @Suppress("SameParameterValue") 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() { @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)!! } @JvmStatic val defaultMusicDirectory: File get() = getOrCreateDirectory("music") @JvmStatic val musicDirectory: StorageFile get() = StorageFile.getMediaRoot() @JvmStatic @Suppress("ReturnCount") fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Pair { val noAccess = Pair(false, false) if (dir == null) { return noAccess } if (dir.exists()) { if (!dir.isDirectory) { Timber.w("%s exists but is not a directory.", dir) return noAccess } } else { if (dir.mkdirs()) { Timber.i("Created directory %s", dir) } else { Timber.w("Failed to create directory %s", dir) return noAccess } } if (!dir.canRead()) { Timber.w("No read permission for directory %s", dir) return noAccess } if (!dir.canWrite()) { Timber.w("No write permission for directory %s", dir) return Pair(true, false) } return Pair(true, true) } /** * Makes a given filename safe by replacing special characters like slashes ("/" and "\") * with dashes ("-"). * * @param name The filename in question. * @return The filename with special characters replaced by hyphens. */ private fun fileSystemSafe(name: String?): String { if (name == null || name.trim { it <= ' ' }.isEmpty()) { return UNNAMED } var filename: String = name 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()) { return "" } for (s in FILE_SYSTEM_UNSAFE_DIR) { filepath = filepath!!.replace(s, "-") } return filepath!! } /** * 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 { 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 { val files = dir.listFiles() if (files == null) { Timber.w("Failed to list children for %s", dir.path) return TreeSet() } return TreeSet(files.asList()) } fun listMediaFiles(dir: StorageFile): SortedSet { 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 { val extension = getExtension(file.name) return MUSIC_FILE_EXTENSIONS.contains(extension) || VIDEO_FILE_EXTENSIONS.contains(extension) } 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) } /** * 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)) } fun getNameFromPath(path: String): String { return path.substringAfterLast('/') } fun getParentPath(path: String): String? { if (!path.contains('/')) return null return path.substringBeforeLast('/') } /** * 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)) } @JvmStatic fun serialize(context: Context, obj: T, fileName: String): Boolean { 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) { Timber.w("Failed to serialize object to %s", file) false } finally { out.safeClose() } } @Suppress("UNCHECKED_CAST") @JvmStatic fun deserialize(context: Context, fileName: String): T? { val file = File(context.cacheDir, fileName) if (!file.exists() || !file.isFile) { return null } var inStream: ObjectInputStream? = null return try { inStream = ObjectInputStream(FileInputStream(file)) val readObject = inStream.readObject() val result = readObject as T Timber.i("Deserialized object from %s", file) result } catch (all: Throwable) { Timber.w(all, "Failed to deserialize object from %s", file) null } finally { 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 { bw.safeClose() fw.safeClose() } } @JvmStatic @Throws(IOException::class) fun renameFile(from: String, to: String) { StorageFile.rename(from, to) } @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 } @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 } }