Implemented Storage Access Framework as Music Cache

This commit is contained in:
Nite 2021-11-19 18:43:52 +01:00
parent a6e76e9d53
commit 1d0bb944e1
No known key found for this signature in database
GPG Key ID: 1D1AD59B1C6386C1
17 changed files with 543 additions and 260 deletions

View File

@ -42,6 +42,7 @@ ext.versions = [
timber : "4.7.1",
fastScroll : "2.0.1",
colorPicker : "2.2.3",
fsaf : "1.1"
]
ext.gradlePlugins = [
@ -89,6 +90,7 @@ ext.other = [
fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll",
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",
colorPickerView : "com.github.skydoves:colorpickerview:$versions.colorPicker",
fsaf : "com.github.K1rakishou:Fuck-Storage-Access-Framework:$versions.fsaf",
]
ext.testing = [

View File

@ -106,6 +106,7 @@ dependencies {
implementation other.fastScroll
implementation other.sortListView
implementation other.colorPickerView
implementation other.fsaf
kapt androidSupport.room

View File

@ -8,8 +8,6 @@ import org.moire.ultrasonic.service.Supplier;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@ -155,8 +153,7 @@ public class StreamProxy implements Runnable
}
Timber.i("Processing request for file %s", localPath);
File file = new File(localPath);
if (!file.exists()) {
if (!StorageFile.Companion.isPathExists(localPath)) {
Timber.e("File %s does not exist", localPath);
return false;
}
@ -194,12 +191,12 @@ public class StreamProxy implements Runnable
while (isRunning && !client.isClosed())
{
// See if there's more to send
File file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
String file = downloadFile.isCompleteFileAvailable() ? downloadFile.getCompleteOrSaveFile() : downloadFile.getPartialFile();
int cbSentThisBatch = 0;
if (file.exists())
if (StorageFile.Companion.isPathExists(file))
{
FileInputStream input = new FileInputStream(file);
InputStream input = StorageFile.Companion.getFromPath(file).getFileInputStream();
try
{

View File

@ -20,7 +20,6 @@ import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import java.io.File
import kotlin.math.ceil
import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.get
@ -51,6 +50,7 @@ import org.moire.ultrasonic.util.TimeSpanPreference
import org.moire.ultrasonic.util.TimeSpanPreferenceDialogFragmentCompat
import org.moire.ultrasonic.util.Util.toast
import timber.log.Timber
import java.io.File
/**
* Shows main app settings.
@ -167,17 +167,28 @@ class SettingsFragment :
}
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (requestCode == SELECT_CACHE_ACTIVITY && resultCode == Activity.RESULT_OK) {
// The result data contains a URI for the document or directory that
// the user selected.
resultData?.data?.also { uri ->
// Perform operations on the document using its URI.
val contentResolver = UApp.applicationContext().contentResolver
if (
requestCode != SELECT_CACHE_ACTIVITY ||
resultCode != Activity.RESULT_OK ||
resultData == null
) return
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
val read = (resultData.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0
val write = (resultData.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0
val persist = (resultData.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0
setCacheLocation(uri)
}
// TODO Should we show an error?
if (!read || !write || !persist) return
// The result data contains a URI for the document or directory that
// the user selected.
resultData.data?.also { uri ->
// Perform operations on the document using its URI.
val contentResolver = UApp.applicationContext().contentResolver
contentResolver.takePersistableUriPermission(uri, RW_FLAG)
setCacheLocation(uri)
}
}
@ -238,7 +249,9 @@ class SettingsFragment :
}
private fun setupCacheLocationPreference() {
cacheLocation!!.summary = Settings.cacheLocation
// TODO add means to reset cache directory to its default value
val uri = Uri.parse(Settings.cacheLocation)
cacheLocation!!.summary = uri.path
cacheLocation!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
val isDefault = Settings.cacheLocation == defaultMusicDirectory.path
@ -400,6 +413,7 @@ class SettingsFragment :
}
private fun setHideMedia(hide: Boolean) {
// TODO this only hides the media files in the Ultrasonic dir and not in the music cache
val nomediaDir = File(ultrasonicDirectory, ".nomedia")
if (hide && !nomediaDir.exists()) {
if (!nomediaDir.mkdir()) {
@ -425,7 +439,7 @@ class SettingsFragment :
private fun setCacheLocation(uri: Uri) {
if (uri.path != null) {
cacheLocation!!.summary = uri.path
Settings.cacheLocation = uri.path!!
Settings.cacheLocation = uri.toString()
// Clear download queue.
mediaPlayerControllerLazy.value.clear()

View File

@ -5,8 +5,10 @@ import android.graphics.BitmapFactory
import android.os.Build
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.StorageFile
import org.moire.ultrasonic.util.Util
import timber.log.Timber
import java.io.File
@Suppress("UtilityClassWithPublicConstructor")
class BitmapUtils {
@ -31,8 +33,8 @@ class BitmapUtils {
if (entry == null) return null
val albumArtFile = FileUtil.getAlbumArtFile(entry)
val bitmap: Bitmap? = null
if (albumArtFile.exists()) {
return getBitmapFromDisk(albumArtFile.path, size, bitmap)
if (albumArtFile != null && File(albumArtFile).exists()) {
return getBitmapFromDisk(albumArtFile, size, bitmap)
}
return null
}
@ -43,8 +45,8 @@ class BitmapUtils {
): Bitmap? {
val albumArtFile = FileUtil.getAlbumArtFile(filename)
val bitmap: Bitmap? = null
if (albumArtFile != null && albumArtFile.exists()) {
return getBitmapFromDisk(albumArtFile.path, size, bitmap)
if (albumArtFile != null && File(albumArtFile).exists()) {
return getBitmapFromDisk(albumArtFile, size, bitmap)
}
return null
}

View File

@ -10,7 +10,6 @@ import androidx.core.content.ContextCompat
import com.squareup.picasso.LruCache
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
@ -21,8 +20,10 @@ import org.moire.ultrasonic.api.subsonic.throwOnFailure
import org.moire.ultrasonic.api.subsonic.toStreamResponse
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.StorageFile
import org.moire.ultrasonic.util.Util
import timber.log.Timber
import java.io.File
/**
* Our new image loader which uses Picasso as a backend.
@ -161,7 +162,8 @@ class ImageLoader(
val file = FileUtil.getAlbumArtFile(entry)
// Return if have a cache hit
if (file.exists()) return
if (file != null && File(file).exists()) return
File(file!!).createNewFile()
// Can't load empty string ids
val id = entry.coverArt

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.log
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
@ -8,6 +7,7 @@ import java.util.Locale
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Util
import timber.log.Timber
import java.io.File
/**
* A Timber Tree which can be used to log to a file

View File

@ -9,12 +9,9 @@ package org.moire.ultrasonic.service
import android.text.TextUtils
import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.RandomAccessFile
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
@ -25,6 +22,7 @@ import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.StorageFile
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
@ -37,9 +35,9 @@ class DownloadFile(
val song: MusicDirectory.Entry,
private val save: Boolean
) : KoinComponent, Identifiable {
val partialFile: File
val completeFile: File
private val saveFile: File = FileUtil.getSongFile(song)
val partialFile: String
val completeFile: String
private val saveFile: String = FileUtil.getSongFile(song)
private var downloadTask: CancellableTask? = null
var isFailed = false
private var retryCount = MAX_RETRIES
@ -65,8 +63,8 @@ class DownloadFile(
val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE)
init {
partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name))
completeFile = File(saveFile.parent, FileUtil.getCompleteFile(saveFile.name))
partialFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
completeFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
}
/**
@ -91,14 +89,14 @@ class DownloadFile(
}
}
val completeOrSaveFile: File
get() = if (saveFile.exists()) {
val completeOrSaveFile: String
get() = if (StorageFile.isPathExists(saveFile)) {
saveFile
} else {
completeFile
}
val completeOrPartialFile: File
val completeOrPartialFile: String
get() = if (isCompleteFileAvailable) {
completeOrSaveFile
} else {
@ -106,15 +104,15 @@ class DownloadFile(
}
val isSaved: Boolean
get() = saveFile.exists()
get() = StorageFile.isPathExists(saveFile)
@get:Synchronized
val isCompleteFileAvailable: Boolean
get() = saveFile.exists() || completeFile.exists()
get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile)
@get:Synchronized
val isWorkDone: Boolean
get() = saveFile.exists() || completeFile.exists() && !save ||
get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) && !save ||
saveWhenDone || completeWhenDone
@get:Synchronized
@ -143,36 +141,24 @@ class DownloadFile(
}
fun unpin() {
if (saveFile.exists()) {
if (!saveFile.renameTo(completeFile)) {
Timber.w(
"Renaming file failed. Original file: %s; Rename to: %s",
saveFile.name, completeFile.name
)
}
if (StorageFile.isPathExists(saveFile)) {
StorageFile.rename(saveFile, completeFile)
}
}
fun cleanup(): Boolean {
var ok = true
if (completeFile.exists() || saveFile.exists()) {
if (StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile)) {
ok = Util.delete(partialFile)
}
if (saveFile.exists()) {
if (StorageFile.isPathExists(saveFile)) {
ok = ok and Util.delete(completeFile)
}
return ok
}
// In support of LRU caching.
fun updateModificationDate() {
updateModificationDate(saveFile)
updateModificationDate(partialFile)
updateModificationDate(completeFile)
}
fun setPlaying(isPlaying: Boolean) {
if (!isPlaying) doPendingRename()
this.isPlaying = isPlaying
@ -208,15 +194,15 @@ class DownloadFile(
override fun execute() {
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
var outputStream: OutputStream? = null
try {
if (saveFile.exists()) {
if (StorageFile.isPathExists(saveFile)) {
Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.DONE)
return
}
if (completeFile.exists()) {
if (StorageFile.isPathExists(completeFile)) {
if (save) {
if (isPlaying) {
saveWhenDone = true
@ -237,8 +223,10 @@ class DownloadFile(
val duration = song.duration
var fileLength: Long = 0
if (!partialFile.exists()) {
fileLength = partialFile.length()
if (!StorageFile.isPathExists(partialFile)) {
fileLength = 0
} else {
fileLength = StorageFile.getFromPath(partialFile).length()
}
needsDownloading = (
@ -248,20 +236,17 @@ class DownloadFile(
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, partial) = musicService.getDownloadInputStream(
song, partialFile.length(), desiredBitRate, save
val (inStream, isPartial) = musicService.getDownloadInputStream(
song, fileLength, desiredBitRate, save
)
inputStream = inStream
if (partial) {
Timber.i(
"Executed partial HTTP GET, skipping %d bytes",
partialFile.length()
)
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = FileOutputStream(partialFile, partial)
outputStream = StorageFile.getOrCreateFileFromPath(partialFile).getFileOutputStream(isPartial)
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
setProgress(totalBytesCopied)
@ -379,30 +364,6 @@ class DownloadFile(
}
}
private fun updateModificationDate(file: File) {
if (file.exists()) {
val ok = file.setLastModified(System.currentTimeMillis())
if (!ok) {
Timber.i(
"Failed to set last-modified date on %s, trying alternate method",
file
)
try {
// Try alternate method to update last modified date to current time
// Found at https://code.google.com/p/android/issues/detail?id=18624
// According to the bug, this was fixed in Android 8.0 (API 26)
val raf = RandomAccessFile(file, "rw")
val length = raf.length()
raf.setLength(length + 1)
raf.setLength(length)
raf.close()
} catch (e: Exception) {
Timber.w(e, "Failed to set last-modified date on %s", file)
}
}
}
}
override fun compareTo(other: Identifiable) = compareTo(other as DownloadFile)
fun compareTo(other: DownloadFile): Int {

View File

@ -19,7 +19,7 @@ import android.os.PowerManager
import android.os.PowerManager.PARTIAL_WAKE_LOCK
import android.os.PowerManager.WakeLock
import androidx.lifecycle.MutableLiveData
import java.io.File
import org.moire.ultrasonic.util.StorageFile
import java.net.URLEncoder
import java.util.Locale
import kotlin.math.abs
@ -37,6 +37,7 @@ import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.StreamProxy
import org.moire.ultrasonic.util.Util
import timber.log.Timber
import java.io.File
/**
* Represents a Media Player which uses the mobile's resources for playback
@ -362,16 +363,17 @@ class LocalMediaPlayer : KoinComponent {
try {
downloadFile.setPlaying(false)
val file = downloadFile.completeOrPartialFile
val file = StorageFile.getFromPath(downloadFile.completeOrPartialFile)
val partial = !downloadFile.isCompleteFileAvailable
downloadFile.updateModificationDate()
// TODO this won't work with SAF, we should use something else, e.g. a recent list
// downloadFile.updateModificationDate()
mediaPlayer.setOnCompletionListener(null)
setPlayerState(PlayerState.IDLE)
setAudioAttributes(mediaPlayer)
var dataSource = file.path
var dataSource: String? = null
if (partial) {
if (proxy == null) {
proxy = StreamProxy(object : Supplier<DownloadFile?>() {
@ -393,7 +395,14 @@ class LocalMediaPlayer : KoinComponent {
Timber.i("Preparing media player")
mediaPlayer.setDataSource(dataSource)
if (dataSource != null) mediaPlayer.setDataSource(dataSource)
else if (file.isRawFile()) mediaPlayer.setDataSource(file.getRawFilePath())
else {
val descriptor = file.getDocumentFileDescriptor("r")!!
mediaPlayer.setDataSource(descriptor.fileDescriptor)
descriptor.close()
}
setPlayerState(PlayerState.PREPARING)
mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
@ -452,7 +461,7 @@ class LocalMediaPlayer : KoinComponent {
@Synchronized
private fun setupNext(downloadFile: DownloadFile) {
try {
val file = downloadFile.completeOrPartialFile
val file = StorageFile.getFromPath(downloadFile.completeOrPartialFile)
// Release the media player if it is not our active player
if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) {
@ -472,7 +481,12 @@ class LocalMediaPlayer : KoinComponent {
} catch (ignored: Throwable) {
}
nextMediaPlayer!!.setDataSource(file.path)
if (file.isRawFile()) nextMediaPlayer!!.setDataSource(file.getRawFilePath())
else {
val descriptor = file.getDocumentFileDescriptor("r")!!
nextMediaPlayer!!.setDataSource(descriptor.fileDescriptor)
descriptor.close()
}
setNextPlayerState(PlayerState.PREPARING)
nextMediaPlayer!!.setOnPreparedListener {
try {
@ -600,7 +614,7 @@ class LocalMediaPlayer : KoinComponent {
private val autoStart: Boolean = true
) : CancellableTask() {
private val expectedFileSize: Long
private val partialFile: File = downloadFile.partialFile
private val partialFile: String = downloadFile.partialFile
override fun execute() {
setPlayerState(PlayerState.DOWNLOADING)
@ -616,7 +630,8 @@ class LocalMediaPlayer : KoinComponent {
private fun bufferComplete(): Boolean {
val completeFileAvailable = downloadFile.isWorkDone
val size = partialFile.length()
val size = if (!StorageFile.isPathExists(partialFile)) 0
else StorageFile.getFromPath(partialFile).length()
Timber.i(
"Buffering %s (%d/%d, %s)",
@ -649,7 +664,7 @@ class LocalMediaPlayer : KoinComponent {
private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() {
private val downloadFile: DownloadFile?
private val partialFile: File?
private val partialFile: String?
override fun execute() {
Thread.currentThread().name = "CheckCompletionTask"
if (downloadFile == null) {
@ -673,7 +688,10 @@ class LocalMediaPlayer : KoinComponent {
val completeFileAvailable = downloadFile!!.isWorkDone
val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED)
Timber.i("Buffering next %s (%d)", partialFile, partialFile!!.length())
val length = if (partialFile == null || !StorageFile.isPathExists(partialFile)) 0
else StorageFile.getFromPath(partialFile).length()
Timber.i("Buffering next %s (%d)", partialFile, length)
return completeFileAvailable && state
}

View File

@ -9,9 +9,7 @@ package org.moire.ultrasonic.service
import android.media.MediaMetadataRetriever
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import org.moire.ultrasonic.util.StorageFile
import java.io.InputStream
import java.io.Reader
import java.lang.Math.min
@ -43,6 +41,8 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Util
import timber.log.Timber
import java.io.FileReader
import java.io.FileWriter
// TODO: There are quite a number of deeply nested and complicated functions in this class..
// Simplify them :)
@ -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.path)
index.id = file.path
val index = Index(file.getPath())
index.id = file.getPath()
index.index = file.name.substring(0, 1)
index.name = file.name
indexes.add(index)
@ -100,14 +100,14 @@ class OfflineMusicService : MusicService, KoinComponent {
name: String?,
refresh: Boolean
): MusicDirectory {
val dir = File(id)
val dir = StorageFile.getFromPath(id)
val result = MusicDirectory()
result.name = dir.name
val seen: MutableCollection<String?> = HashSet()
for (file in FileUtil.listMediaFiles(dir)) {
val filename = getName(file)
val filename = getName(file.name, file.isDirectory)
if (filename != null && !seen.contains(filename)) {
seen.add(filename)
result.addChild(createEntry(file, filename))
@ -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.path)
val artist = Artist(artistFile.getPath())
artist.index = artistFile.name.substring(0, 1)
artist.name = artistName
artist.closeness = closeness
@ -205,10 +205,12 @@ class OfflineMusicService : MusicService, KoinComponent {
var line = buffer.readLine()
if ("#EXTM3U" != line) return playlist
while (buffer.readLine().also { line = it } != null) {
val entryFile = File(line)
val entryName = getName(entryFile)
if (entryFile.exists() && entryName != null) {
playlist.addChild(createEntry(entryFile, entryName))
if (StorageFile.isPathExists(line)) {
val entryFile = StorageFile.getFromPath(line)
val entryName = getName(entryFile.name, entryFile.isDirectory)
if (entryName != null) {
playlist.addChild(createEntry(entryFile, entryName))
}
}
}
playlist
@ -228,8 +230,8 @@ class OfflineMusicService : MusicService, KoinComponent {
try {
fw.write("#EXTM3U\n")
for (e in entries) {
var filePath = FileUtil.getSongFile(e).absolutePath
if (!File(filePath).exists()) {
var filePath = FileUtil.getSongFile(e)
if (!StorageFile.isPathExists(filePath)) {
val ext = FileUtil.getExtension(filePath)
val base = FileUtil.getBaseName(filePath)
filePath = "$base.complete.$ext"
@ -251,7 +253,7 @@ class OfflineMusicService : MusicService, KoinComponent {
override fun getRandomSongs(size: Int): MusicDirectory {
val root = FileUtil.musicDirectory
val children: MutableList<File> = LinkedList()
val children: MutableList<StorageFile> = LinkedList()
listFilesRecursively(root, children)
val result = MusicDirectory()
if (children.isEmpty()) {
@ -261,7 +263,7 @@ class OfflineMusicService : MusicService, KoinComponent {
val finalSize: Int = min(children.size, size)
for (i in 0 until finalSize) {
val file = children[i % children.size]
result.addChild(createEntry(file, getName(file)))
result.addChild(createEntry(file, getName(file.name, file.isDirectory)))
}
return result
}
@ -483,28 +485,27 @@ class OfflineMusicService : MusicService, KoinComponent {
companion object {
private val COMPILE = Pattern.compile(" ")
private fun getName(file: File): String? {
var name = file.name
if (file.isDirectory) {
return name
private fun getName(fileName: String, isDirectory: Boolean): String? {
if (isDirectory) {
return fileName
}
if (name.endsWith(".partial") || name.contains(".partial.") ||
name == Constants.ALBUM_ART_FILE
if (fileName.endsWith(".partial") || fileName.contains(".partial.") ||
fileName == Constants.ALBUM_ART_FILE
) {
return null
}
name = name.replace(".complete", "")
val name = fileName.replace(".complete", "")
return FileUtil.getBaseName(name)
}
@Suppress("TooGenericExceptionCaught", "ComplexMethod", "LongMethod", "NestedBlockDepth")
private fun createEntry(file: File, name: String?): MusicDirectory.Entry {
val entry = MusicDirectory.Entry(file.path)
private fun createEntry(file: StorageFile, name: String?): MusicDirectory.Entry {
val entry = MusicDirectory.Entry(file.getPath())
entry.isDirectory = file.isDirectory
entry.parent = file.parent
entry.size = file.length()
val root = FileUtil.musicDirectory.path
entry.path = file.path.replaceFirst(
entry.parent = file.getParent()!!.getPath()
entry.size = if (file.isFile) file.length() else 0
val root = FileUtil.musicDirectory.getPath()
entry.path = file.getPath().replaceFirst(
String.format(Locale.ROOT, "^%s/", root).toRegex(), ""
)
entry.title = name
@ -520,7 +521,14 @@ class OfflineMusicService : MusicService, KoinComponent {
var hasVideo: String? = null
try {
val mmr = MediaMetadataRetriever()
mmr.setDataSource(file.path)
if (file.isRawFile()) mmr.setDataSource(file.getRawFilePath())
else {
val descriptor = file.getDocumentFileDescriptor("r")!!
mmr.setDataSource(descriptor.fileDescriptor)
descriptor.close()
}
artist = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
album = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
title = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
@ -533,8 +541,8 @@ class OfflineMusicService : MusicService, KoinComponent {
mmr.release()
} catch (ignored: Exception) {
}
entry.artist = artist ?: file.parentFile!!.parentFile!!.name
entry.album = album ?: file.parentFile!!.name
entry.artist = artist ?: file.getParent()!!.getParent()!!.name
entry.album = album ?: file.getParent()!!.name
if (title != null) {
entry.title = title
}
@ -589,8 +597,8 @@ class OfflineMusicService : MusicService, KoinComponent {
}
entry.suffix = FileUtil.getExtension(file.name.replace(".complete", ""))
val albumArt = FileUtil.getAlbumArtFile(entry)
if (albumArt.exists()) {
entry.coverArt = albumArt.path
if (albumArt != null && StorageFile.isPathExists(albumArt)) {
entry.coverArt = albumArt
}
return entry
}
@ -598,7 +606,7 @@ class OfflineMusicService : MusicService, KoinComponent {
@Suppress("NestedBlockDepth")
private fun recursiveAlbumSearch(
artistName: String,
file: File,
file: StorageFile,
criteria: SearchCriteria,
albums: MutableList<MusicDirectory.Entry>,
songs: MutableList<MusicDirectory.Entry>
@ -606,7 +614,7 @@ class OfflineMusicService : MusicService, KoinComponent {
var closeness: Int
for (albumFile in FileUtil.listMediaFiles(file)) {
if (albumFile.isDirectory) {
val albumName = getName(albumFile)
val albumName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, albumName).also { closeness = it } > 0) {
val album = createEntry(albumFile, albumName)
album.artist = artistName
@ -614,7 +622,7 @@ class OfflineMusicService : MusicService, KoinComponent {
albums.add(album)
}
for (songFile in FileUtil.listMediaFiles(albumFile)) {
val songName = getName(songFile)
val songName = getName(songFile.name, songFile.isDirectory)
if (songFile.isDirectory) {
recursiveAlbumSearch(artistName, songFile, criteria, albums, songs)
} else if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
@ -626,7 +634,7 @@ class OfflineMusicService : MusicService, KoinComponent {
}
}
} else {
val songName = getName(albumFile)
val songName = getName(albumFile.name, albumFile.isDirectory)
if (matchCriteria(criteria, songName).also { closeness = it } > 0) {
val song = createEntry(albumFile, songName)
song.artist = artistName
@ -655,7 +663,7 @@ class OfflineMusicService : MusicService, KoinComponent {
return closeness
}
private fun listFilesRecursively(parent: File, children: MutableList<File>) {
private fun listFilesRecursively(parent: StorageFile, children: MutableList<StorageFile>) {
for (file in FileUtil.listMediaFiles(parent)) {
if (file.isFile) {
children.add(file)

View File

@ -1,8 +1,9 @@
package org.moire.ultrasonic.util
import android.net.Uri
import android.os.AsyncTask
import android.os.StatFs
import java.io.File
import android.system.Os
import java.util.ArrayList
import java.util.HashSet
import org.koin.java.KoinJavaComponent.inject
@ -57,8 +58,8 @@ class CacheCleaner {
override fun doInBackground(vararg params: Void?): Void? {
try {
Thread.currentThread().name = "BackgroundCleanup"
val files: MutableList<File> = ArrayList()
val dirs: MutableList<File> = ArrayList()
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
findCandidatesForDeletion(musicDirectory, files, dirs)
sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete()
@ -75,8 +76,8 @@ class CacheCleaner {
override fun doInBackground(vararg params: Void?): Void? {
try {
Thread.currentThread().name = "BackgroundSpaceCleanup"
val files: MutableList<File> = ArrayList()
val dirs: MutableList<File> = ArrayList()
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
findCandidatesForDeletion(musicDirectory, files, dirs)
val bytesToDelete = getMinimumDelete(files)
if (bytesToDelete > 0L) {
@ -116,29 +117,29 @@ class CacheCleaner {
companion object {
private const val MIN_FREE_SPACE = 500 * 1024L * 1024L
private fun deleteEmptyDirs(dirs: Iterable<File>, doNotDelete: Collection<File>) {
private fun deleteEmptyDirs(dirs: Iterable<StorageFile>, doNotDelete: Collection<String>) {
for (dir in dirs) {
if (doNotDelete.contains(dir)) {
if (doNotDelete.contains(dir.getPath())) {
continue
}
var children = dir.listFiles()
if (children != null) {
// No songs left in the folder
if (children.size == 1 && children[0].path == getAlbumArtFile(dir).path) {
if (children.size == 1 && children[0].getPath() == getAlbumArtFile(dir.getPath())) {
// Delete Artwork files
delete(getAlbumArtFile(dir))
delete(getAlbumArtFile(dir.getPath()))
children = dir.listFiles()
}
// Delete empty directory
if (children != null && children.isEmpty()) {
delete(dir)
delete(dir.getPath())
}
}
}
}
private fun getMinimumDelete(files: List<File>): Long {
private fun getMinimumDelete(files: List<StorageFile>): Long {
if (files.isEmpty()) {
return 0L
}
@ -149,11 +150,25 @@ class CacheCleaner {
}
// Ensure that file system is not more than 95% full.
val stat = StatFs(files[0].path)
val bytesTotalFs = stat.blockCountLong * stat.blockSizeLong
val bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong
val bytesUsedFs = bytesTotalFs - bytesAvailableFs
val minFsAvailability = bytesTotalFs - MIN_FREE_SPACE
val bytesUsedFs: Long
val minFsAvailability: Long
val bytesTotalFs: Long
val bytesAvailableFs: Long
if (files[0].isRawFile()) {
val stat = StatFs(files[0].getRawFilePath())
bytesTotalFs = stat.blockCountLong * stat.blockSizeLong
bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong
bytesUsedFs = bytesTotalFs - bytesAvailableFs
minFsAvailability = bytesTotalFs - MIN_FREE_SPACE
} else {
val descriptor = files[0].getDocumentFileDescriptor("r")!!
val stat = Os.fstatvfs(descriptor.fileDescriptor)
bytesTotalFs = stat.f_blocks * stat.f_bsize
bytesAvailableFs = stat.f_bfree * stat.f_bsize
bytesUsedFs = bytesTotalFs - bytesAvailableFs
minFsAvailability = bytesTotalFs - MIN_FREE_SPACE
descriptor.close()
}
val bytesToDeleteCacheLimit = (bytesUsedBySubsonic - cacheSizeBytes).coerceAtLeast(0L)
val bytesToDeleteFsLimit = (bytesUsedFs - minFsAvailability).coerceAtLeast(0L)
val bytesToDelete = bytesToDeleteCacheLimit.coerceAtLeast(bytesToDeleteFsLimit)
@ -169,18 +184,18 @@ class CacheCleaner {
return bytesToDelete
}
private fun isPartial(file: File): Boolean {
private fun isPartial(file: StorageFile): Boolean {
return file.name.endsWith(".partial") || file.name.contains(".partial.")
}
private fun isComplete(file: File): Boolean {
private fun isComplete(file: StorageFile): Boolean {
return file.name.endsWith(".complete") || file.name.contains(".complete.")
}
@Suppress("NestedBlockDepth")
private fun deleteFiles(
files: Collection<File>,
doNotDelete: Collection<File>,
files: Collection<StorageFile>,
doNotDelete: Collection<String>,
bytesToDelete: Long,
deletePartials: Boolean
) {
@ -191,9 +206,9 @@ class CacheCleaner {
for (file in files) {
if (!deletePartials && bytesDeleted > bytesToDelete) break
if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) {
if (!doNotDelete.contains(file) && file.name != Constants.ALBUM_ART_FILE) {
if (!doNotDelete.contains(file.getPath()) && file.name != Constants.ALBUM_ART_FILE) {
val size = file.length()
if (delete(file)) {
if (delete(file.getPath())) {
bytesDeleted += size
}
}
@ -203,9 +218,9 @@ class CacheCleaner {
}
private fun findCandidatesForDeletion(
file: File,
files: MutableList<File>,
dirs: MutableList<File>
file: StorageFile,
files: MutableList<StorageFile>,
dirs: MutableList<StorageFile>
) {
if (file.isFile && (isPartial(file) || isComplete(file))) {
files.add(file)
@ -218,14 +233,14 @@ class CacheCleaner {
}
}
private fun sortByAscendingModificationTime(files: MutableList<File>) {
files.sortWith { a: File, b: File ->
private fun sortByAscendingModificationTime(files: MutableList<StorageFile>) {
files.sortWith { a: StorageFile, b: StorageFile ->
a.lastModified().compareTo(b.lastModified())
}
}
private fun findFilesToNotDelete(): Set<File> {
val filesToNotDelete: MutableSet<File> = HashSet(5)
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val downloader = inject<Downloader>(
Downloader::class.java
)
@ -233,7 +248,7 @@ class CacheCleaner {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeOrSaveFile)
}
filesToNotDelete.add(musicDirectory)
filesToNotDelete.add(musicDirectory.getPath())
return filesToNotDelete
}
}

View File

@ -13,7 +13,6 @@ import android.os.Environment
import android.text.TextUtils
import android.util.Pair
import java.io.BufferedWriter
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.FileWriter
@ -28,6 +27,7 @@ import java.util.regex.Pattern
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber
import java.io.File
object FileUtil {
@ -43,12 +43,12 @@ object FileUtil {
const val SUFFIX_SMALL = ".jpeg-small"
private const val UNNAMED = "unnamed"
fun getSongFile(song: MusicDirectory.Entry): File {
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.absolutePath)) return File(song.id)
if (song.id.startsWith(dir)) return song.id
}
// Generate a file name for the song
@ -70,7 +70,7 @@ object FileUtil {
} else {
fileName.append(song.suffix)
}
return File(dir, fileName.toString())
return "$dir/$fileName"
}
@JvmStatic
@ -104,9 +104,9 @@ object FileUtil {
* @param entry The album entry
* @return File object. Not guaranteed that it exists
*/
fun getAlbumArtFile(entry: MusicDirectory.Entry): File {
fun getAlbumArtFile(entry: MusicDirectory.Entry): String? {
val albumDir = getAlbumDirectory(entry)
return getAlbumArtFile(albumDir)
return getAlbumArtFileForAlbumDir(albumDir)
}
/**
@ -129,7 +129,7 @@ object FileUtil {
*/
fun getArtistArtKey(name: String?, large: Boolean): String {
val artist = fileSystemSafe(name)
val dir = File(String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, UNNAMED))
val dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, UNNAMED)
return getAlbumArtKey(dir, large)
}
@ -139,9 +139,9 @@ object FileUtil {
* @param large Whether to get the key for the large or the default image
* @return String The hash key
*/
private fun getAlbumArtKey(albumDir: File, large: Boolean): String {
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(albumDir.path), suffix)
return String.format(Locale.ROOT, "%s%s", Util.md5Hex(albumDirPath), suffix)
}
fun getAvatarFile(username: String?): File? {
@ -159,10 +159,9 @@ object FileUtil {
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFile(albumDir: File): File {
val albumArtDir = albumArtDirectory
fun getAlbumArtFileForAlbumDir(albumDir: String): String? {
val key = getAlbumArtKey(albumDir, true)
return File(albumArtDir, key)
return getAlbumArtFile(key)
}
/**
@ -171,11 +170,11 @@ object FileUtil {
* @return File object. Not guaranteed that it exists
*/
@JvmStatic
fun getAlbumArtFile(cacheKey: String?): File? {
val albumArtDir = albumArtDirectory
fun getAlbumArtFile(cacheKey: String?): String? {
val albumArtDir = albumArtDirectory.absolutePath
return if (cacheKey == null) {
null
} else File(albumArtDir, cacheKey)
} else "$albumArtDir/$cacheKey"
}
val albumArtDirectory: File
@ -186,36 +185,30 @@ object FileUtil {
return albumArtDir
}
fun getAlbumDirectory(entry: MusicDirectory.Entry): File {
val dir: File
if (!TextUtils.isEmpty(entry.path)) {
val f = File(fileSystemSafeDir(entry.path))
dir = File(
String.format(
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.path,
if (entry.isDirectory) f.path else f.parent ?: ""
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 = File(String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.path, artist, album))
dir = String.format(Locale.ROOT, "%s/%s/%s", musicDirectory.getPath(), artist, album)
}
return dir
}
fun createDirectoryForParent(file: File) {
val dir = file.parentFile
if (dir != null && !dir.exists()) {
if (!dir.mkdirs()) {
Timber.e("Failed to create directory %s", dir)
}
}
fun createDirectoryForParent(path: String) {
val dir = getParentPath(path) ?: return
StorageFile.createDirsOnPath(dir)
}
@Suppress("SameParameterValue")
@ -245,13 +238,8 @@ object FileUtil {
get() = getOrCreateDirectory("music")
@JvmStatic
val musicDirectory: File
get() {
val path = Settings.cacheLocation
val dir = File(path)
val hasAccess = ensureDirectoryExistsAndIsReadWritable(dir)
return if (hasAccess.second) dir else defaultMusicDirectory
}
val musicDirectory: StorageFile
get() = StorageFile.getMediaRoot()
@JvmStatic
@Suppress("ReturnCount")
@ -326,6 +314,16 @@ object FileUtil {
* 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> {
val files = dir.listFiles()
@ -336,7 +334,7 @@ object FileUtil {
return TreeSet(files.asList())
}
fun listMediaFiles(dir: File): SortedSet<File> {
fun listMediaFiles(dir: StorageFile): SortedSet<StorageFile> {
val files = listFiles(dir)
val iterator = files.iterator()
while (iterator.hasNext()) {
@ -348,7 +346,7 @@ object FileUtil {
return files
}
private fun isMediaFile(file: File): Boolean {
private fun isMediaFile(file: StorageFile): Boolean {
val extension = getExtension(file.name)
return MUSIC_FILE_EXTENSIONS.contains(extension) ||
VIDEO_FILE_EXTENSIONS.contains(extension)
@ -393,6 +391,15 @@ object FileUtil {
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.
*
@ -453,9 +460,9 @@ object FileUtil {
try {
fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) {
var filePath = getSongFile(e).absolutePath
var filePath = getSongFile(e)
if (!File(filePath).exists()) {
if (!StorageFile.isPathExists(filePath)) {
val ext = getExtension(filePath)
val base = getBaseName(filePath)
filePath = "$base.complete.$ext"

View File

@ -0,0 +1,273 @@
/*
* 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.RawFile
import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory
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
/**
* 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 abstractFile: AbstractFile,
private var fileManager: FileManager
): Comparable<StorageFile> {
override fun compareTo(other: StorageFile): Int {
return getPath().compareTo(other.getPath())
}
var name: String = fileManager.getName(abstractFile)
var isDirectory: Boolean = fileManager.isDirectory(abstractFile)
var isFile: Boolean = fileManager.isFile(abstractFile)
fun length(): Long = fileManager.getLength(abstractFile)
fun lastModified(): Long = fileManager.lastModified(abstractFile)
fun delete(): Boolean = fileManager.delete(abstractFile)
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)
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")
}
// 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()
}
fun getParent(): StorageFile? {
if (isRawFile()) {
return StorageFile(
null,
fileManager.fromRawFile(File(abstractFile.getFullPath()).parentFile!!),
fileManager
)
}
return parent
}
fun isRawFile(): Boolean {
return abstractFile is RawFile
}
fun getRawFilePath(): String? {
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 {
// TODO it would be nice to check the access rights and reset the cache directory on error
private val MusicCacheFileManager: Lazy<FileManager> = lazy {
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(
null,
MusicCacheFileManager.value.newBaseDirectoryFile<MusicCacheBaseDirectory>()!!,
MusicCacheFileManager.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
)
}
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 {
val normalizedPath = normalizePath(path)
if (!normalizedPath.isUri()) {
File(normalizedPath).createNewFile()
return StorageFile(
null,
MusicCacheFileManager.value.fromPath(normalizedPath),
MusicCacheFileManager.value
)
}
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)
?: MusicCacheFileManager.value.createFile(file.abstractFile, segment)!!,
file.fileManager
)
}
return file
}
fun isPathExists(path: String): Boolean {
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
}
return true
}
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 = getMediaRoot().abstractFile
segments.forEach { segment ->
file = MusicCacheFileManager.value.createDir(file, segment)
?: throw IOException("Can't create directory")
}
}
fun rename(pathFrom: String, pathTo: String) {
val normalizedPathFrom = normalizePath(pathFrom)
val normalizedPathTo = normalizePath(pathTo)
Timber.d("Renaming from %s to %s", normalizedPathFrom, normalizedPathTo)
val fileFrom = getFromPath(normalizedPathFrom)
val parentTo = getFromPath(FileUtil.getParentPath(normalizedPathTo)!!)
val fileTo = getFromParentAndName(parentTo, FileUtil.getNameFromPath(normalizedPathTo))
MusicCacheFileManager.value.copyFileContents(fileFrom.abstractFile, fileTo.abstractFile)
fileFrom.delete()
}
private fun getUriSegments(uri: String): List<String>? {
val rootPath = getMediaRoot().getPath()
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(':')
}

View File

@ -2,9 +2,9 @@ package org.moire.ultrasonic.util
import android.content.Context
import android.os.Build
import java.io.File
import java.io.PrintWriter
import timber.log.Timber
import java.io.File
/**
* Logs the stack trace of uncaught exceptions to a file on the SD card.

View File

@ -21,6 +21,7 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.media.MediaScannerConnection
import android.net.ConnectivityManager
import android.net.Uri
import android.net.wifi.WifiManager
@ -38,9 +39,6 @@ import android.widget.Toast
import androidx.annotation.AnyRes
import androidx.media.utils.MediaConstants
import java.io.Closeable
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.UnsupportedEncodingException
import java.security.MessageDigest
@ -51,6 +49,7 @@ import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.MusicDirectory
@ -58,6 +57,7 @@ import org.moire.ultrasonic.domain.PlayerState
import org.moire.ultrasonic.domain.SearchResult
import org.moire.ultrasonic.service.DownloadFile
import timber.log.Timber
import java.io.File
private const val LINE_LENGTH = 60
private const val DEGRADE_PRECISION_AFTER = 10
@ -110,39 +110,10 @@ object Util {
}
}
@Throws(IOException::class)
fun atomicCopy(from: File, to: File) {
val tmp = File(String.format(Locale.ROOT, "%s.tmp", to.path))
val input = FileInputStream(from)
val out = FileOutputStream(tmp)
try {
input.channel.transferTo(0, from.length(), out.channel)
out.close()
if (!tmp.renameTo(to)) {
throw IOException(
String.format(Locale.ROOT, "Failed to rename %s to %s", tmp, to)
)
}
Timber.i("Copied %s to %s", from, to)
} catch (x: IOException) {
close(out)
delete(to)
throw x
} finally {
close(input)
close(out)
delete(tmp)
}
}
@JvmStatic
@Throws(IOException::class)
fun renameFile(from: File, to: File) {
if (from.renameTo(to)) {
Timber.i("Renamed %s to %s", from, to)
} else {
atomicCopy(from, to)
}
fun renameFile(from: String, to: String) {
StorageFile.rename(from, to)
}
@JvmStatic
@ -155,6 +126,17 @@ object Util {
}
@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
}
fun delete(file: File?): Boolean {
if (file != null && file.exists()) {
if (!file.delete()) {
@ -513,7 +495,7 @@ object Util {
intent.putExtra("artist", song.artist)
intent.putExtra("album", song.album)
val albumArtFile = FileUtil.getAlbumArtFile(song)
intent.putExtra("coverart", albumArtFile.absolutePath)
intent.putExtra("coverart", albumArtFile)
} else {
intent.putExtra("title", "")
intent.putExtra("artist", "")
@ -617,8 +599,8 @@ object Util {
if (Settings.shouldSendBluetoothAlbumArt) {
val albumArtFile = FileUtil.getAlbumArtFile(song)
intent.putExtra("coverart", albumArtFile.absolutePath)
intent.putExtra("cover", albumArtFile.absolutePath)
intent.putExtra("coverart", albumArtFile)
intent.putExtra("cover", albumArtFile)
}
intent.putExtra("position", playerPosition.toLong())
@ -777,10 +759,11 @@ object Util {
}
@JvmStatic
fun scanMedia(file: File?) {
val uri = Uri.fromFile(file)
val scanFileIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)
appContext().sendBroadcast(scanFileIntent)
fun scanMedia(file: String?) {
// TODO this doesn't work for URIs
MediaScannerConnection.scanFile(
UApp.applicationContext(), arrayOf(file),
null, null)
}
fun getResourceFromAttribute(context: Context, resId: Int): Int {

View File

@ -191,7 +191,7 @@
<string name="settings.directory_cache_time_60">1 ora</string>
<string name="settings.disc_sort">Ordina Canzoni secondo Disco</string>
<string name="settings.disc_sort_summary">Ordina lista canzoni secondo il numero disco e traccia</string>
<string name="settings.display_bitrate">Visualizza Bitrate Ed Estensione File</string>
<string name="settings.display_bitrate">Visualizza Bitrate Ed Estensione FileAdapter</string>
<string name="settings.display_bitrate_summary">Aggiungi nome artista con bitrare ed estensione file</string>
<string name="settings.download_transition">Visualizza Download Durante Riproduzione</string>
<string name="settings.download_transition_summary">Passa al download quando inizia riproduzione</string>

View File

@ -214,7 +214,7 @@
<string name="settings.directory_cache_time_60">1 hour</string>
<string name="settings.disc_sort">Sort Songs By Disc</string>
<string name="settings.disc_sort_summary">Sort song list by disc number and track number</string>
<string name="settings.display_bitrate">Display Bitrate and File Suffix</string>
<string name="settings.display_bitrate">Display Bitrate and FileAdapter Suffix</string>
<string name="settings.display_bitrate_summary">Append artist name with bitrate and file suffix</string>
<string name="settings.download_transition">Show Downloads on Play</string>
<string name="settings.download_transition_summary">Transition to download activity when starting playback</string>