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

View File

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

View File

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

View File

@ -20,7 +20,6 @@ import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import java.io.File
import kotlin.math.ceil import kotlin.math.ceil
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.java.KoinJavaComponent.get 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.TimeSpanPreferenceDialogFragmentCompat
import org.moire.ultrasonic.util.Util.toast import org.moire.ultrasonic.util.Util.toast
import timber.log.Timber import timber.log.Timber
import java.io.File
/** /**
* Shows main app settings. * Shows main app settings.
@ -167,17 +167,28 @@ class SettingsFragment :
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
if (requestCode == SELECT_CACHE_ACTIVITY && resultCode == Activity.RESULT_OK) { if (
// The result data contains a URI for the document or directory that requestCode != SELECT_CACHE_ACTIVITY ||
// the user selected. resultCode != Activity.RESULT_OK ||
resultData?.data?.also { uri -> resultData == null
// Perform operations on the document using its URI. ) return
val contentResolver = UApp.applicationContext().contentResolver
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() { 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 = cacheLocation!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener { Preference.OnPreferenceClickListener {
val isDefault = Settings.cacheLocation == defaultMusicDirectory.path val isDefault = Settings.cacheLocation == defaultMusicDirectory.path
@ -400,6 +413,7 @@ class SettingsFragment :
} }
private fun setHideMedia(hide: Boolean) { 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") val nomediaDir = File(ultrasonicDirectory, ".nomedia")
if (hide && !nomediaDir.exists()) { if (hide && !nomediaDir.exists()) {
if (!nomediaDir.mkdir()) { if (!nomediaDir.mkdir()) {
@ -425,7 +439,7 @@ class SettingsFragment :
private fun setCacheLocation(uri: Uri) { private fun setCacheLocation(uri: Uri) {
if (uri.path != null) { if (uri.path != null) {
cacheLocation!!.summary = uri.path cacheLocation!!.summary = uri.path
Settings.cacheLocation = uri.path!! Settings.cacheLocation = uri.toString()
// Clear download queue. // Clear download queue.
mediaPlayerControllerLazy.value.clear() mediaPlayerControllerLazy.value.clear()

View File

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

View File

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

View File

@ -1,6 +1,5 @@
package org.moire.ultrasonic.log package org.moire.ultrasonic.log
import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -8,6 +7,7 @@ import java.util.Locale
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
import java.io.File
/** /**
* A Timber Tree which can be used to log to a 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 android.text.TextUtils
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.io.RandomAccessFile
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider 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.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.StorageFile
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -37,9 +35,9 @@ class DownloadFile(
val song: MusicDirectory.Entry, val song: MusicDirectory.Entry,
private val save: Boolean private val save: Boolean
) : KoinComponent, Identifiable { ) : KoinComponent, Identifiable {
val partialFile: File val partialFile: String
val completeFile: File val completeFile: String
private val saveFile: File = FileUtil.getSongFile(song) private val saveFile: String = FileUtil.getSongFile(song)
private var downloadTask: CancellableTask? = null private var downloadTask: CancellableTask? = null
var isFailed = false var isFailed = false
private var retryCount = MAX_RETRIES private var retryCount = MAX_RETRIES
@ -65,8 +63,8 @@ class DownloadFile(
val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE) val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE)
init { init {
partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name)) partialFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile))
completeFile = File(saveFile.parent, FileUtil.getCompleteFile(saveFile.name)) completeFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile))
} }
/** /**
@ -91,14 +89,14 @@ class DownloadFile(
} }
} }
val completeOrSaveFile: File val completeOrSaveFile: String
get() = if (saveFile.exists()) { get() = if (StorageFile.isPathExists(saveFile)) {
saveFile saveFile
} else { } else {
completeFile completeFile
} }
val completeOrPartialFile: File val completeOrPartialFile: String
get() = if (isCompleteFileAvailable) { get() = if (isCompleteFileAvailable) {
completeOrSaveFile completeOrSaveFile
} else { } else {
@ -106,15 +104,15 @@ class DownloadFile(
} }
val isSaved: Boolean val isSaved: Boolean
get() = saveFile.exists() get() = StorageFile.isPathExists(saveFile)
@get:Synchronized @get:Synchronized
val isCompleteFileAvailable: Boolean val isCompleteFileAvailable: Boolean
get() = saveFile.exists() || completeFile.exists() get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile)
@get:Synchronized @get:Synchronized
val isWorkDone: Boolean val isWorkDone: Boolean
get() = saveFile.exists() || completeFile.exists() && !save || get() = StorageFile.isPathExists(saveFile) || StorageFile.isPathExists(completeFile) && !save ||
saveWhenDone || completeWhenDone saveWhenDone || completeWhenDone
@get:Synchronized @get:Synchronized
@ -143,36 +141,24 @@ class DownloadFile(
} }
fun unpin() { fun unpin() {
if (saveFile.exists()) { if (StorageFile.isPathExists(saveFile)) {
if (!saveFile.renameTo(completeFile)) { StorageFile.rename(saveFile, completeFile)
Timber.w(
"Renaming file failed. Original file: %s; Rename to: %s",
saveFile.name, completeFile.name
)
}
} }
} }
fun cleanup(): Boolean { fun cleanup(): Boolean {
var ok = true var ok = true
if (completeFile.exists() || saveFile.exists()) { if (StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile)) {
ok = Util.delete(partialFile) ok = Util.delete(partialFile)
} }
if (saveFile.exists()) { if (StorageFile.isPathExists(saveFile)) {
ok = ok and Util.delete(completeFile) ok = ok and Util.delete(completeFile)
} }
return ok return ok
} }
// In support of LRU caching.
fun updateModificationDate() {
updateModificationDate(saveFile)
updateModificationDate(partialFile)
updateModificationDate(completeFile)
}
fun setPlaying(isPlaying: Boolean) { fun setPlaying(isPlaying: Boolean) {
if (!isPlaying) doPendingRename() if (!isPlaying) doPendingRename()
this.isPlaying = isPlaying this.isPlaying = isPlaying
@ -208,15 +194,15 @@ class DownloadFile(
override fun execute() { override fun execute() {
var inputStream: InputStream? = null var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null var outputStream: OutputStream? = null
try { try {
if (saveFile.exists()) { if (StorageFile.isPathExists(saveFile)) {
Timber.i("%s already exists. Skipping.", saveFile) Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.DONE) status.postValue(DownloadStatus.DONE)
return return
} }
if (completeFile.exists()) { if (StorageFile.isPathExists(completeFile)) {
if (save) { if (save) {
if (isPlaying) { if (isPlaying) {
saveWhenDone = true saveWhenDone = true
@ -237,8 +223,10 @@ class DownloadFile(
val duration = song.duration val duration = song.duration
var fileLength: Long = 0 var fileLength: Long = 0
if (!partialFile.exists()) { if (!StorageFile.isPathExists(partialFile)) {
fileLength = partialFile.length() fileLength = 0
} else {
fileLength = StorageFile.getFromPath(partialFile).length()
} }
needsDownloading = ( needsDownloading = (
@ -248,20 +236,17 @@ class DownloadFile(
if (needsDownloading) { if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists. // Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, partial) = musicService.getDownloadInputStream( val (inStream, isPartial) = musicService.getDownloadInputStream(
song, partialFile.length(), desiredBitRate, save song, fileLength, desiredBitRate, save
) )
inputStream = inStream inputStream = inStream
if (partial) { if (isPartial) {
Timber.i( Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
"Executed partial HTTP GET, skipping %d bytes",
partialFile.length()
)
} }
outputStream = FileOutputStream(partialFile, partial) outputStream = StorageFile.getOrCreateFileFromPath(partialFile).getFileOutputStream(isPartial)
val len = inputStream.copyTo(outputStream) { totalBytesCopied -> val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
setProgress(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) override fun compareTo(other: Identifiable) = compareTo(other as DownloadFile)
fun compareTo(other: DownloadFile): Int { 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.PARTIAL_WAKE_LOCK
import android.os.PowerManager.WakeLock import android.os.PowerManager.WakeLock
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import java.io.File import org.moire.ultrasonic.util.StorageFile
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Locale import java.util.Locale
import kotlin.math.abs 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.StreamProxy
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
import java.io.File
/** /**
* Represents a Media Player which uses the mobile's resources for playback * Represents a Media Player which uses the mobile's resources for playback
@ -362,16 +363,17 @@ class LocalMediaPlayer : KoinComponent {
try { try {
downloadFile.setPlaying(false) downloadFile.setPlaying(false)
val file = downloadFile.completeOrPartialFile val file = StorageFile.getFromPath(downloadFile.completeOrPartialFile)
val partial = !downloadFile.isCompleteFileAvailable 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) mediaPlayer.setOnCompletionListener(null)
setPlayerState(PlayerState.IDLE) setPlayerState(PlayerState.IDLE)
setAudioAttributes(mediaPlayer) setAudioAttributes(mediaPlayer)
var dataSource = file.path var dataSource: String? = null
if (partial) { if (partial) {
if (proxy == null) { if (proxy == null) {
proxy = StreamProxy(object : Supplier<DownloadFile?>() { proxy = StreamProxy(object : Supplier<DownloadFile?>() {
@ -393,7 +395,14 @@ class LocalMediaPlayer : KoinComponent {
Timber.i("Preparing media player") 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) setPlayerState(PlayerState.PREPARING)
mediaPlayer.setOnBufferingUpdateListener { mp, percent -> mediaPlayer.setOnBufferingUpdateListener { mp, percent ->
@ -452,7 +461,7 @@ class LocalMediaPlayer : KoinComponent {
@Synchronized @Synchronized
private fun setupNext(downloadFile: DownloadFile) { private fun setupNext(downloadFile: DownloadFile) {
try { try {
val file = downloadFile.completeOrPartialFile val file = StorageFile.getFromPath(downloadFile.completeOrPartialFile)
// Release the media player if it is not our active player // Release the media player if it is not our active player
if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) { if (nextMediaPlayer != null && nextMediaPlayer != mediaPlayer) {
@ -472,7 +481,12 @@ class LocalMediaPlayer : KoinComponent {
} catch (ignored: Throwable) { } 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) setNextPlayerState(PlayerState.PREPARING)
nextMediaPlayer!!.setOnPreparedListener { nextMediaPlayer!!.setOnPreparedListener {
try { try {
@ -600,7 +614,7 @@ class LocalMediaPlayer : KoinComponent {
private val autoStart: Boolean = true private val autoStart: Boolean = true
) : CancellableTask() { ) : CancellableTask() {
private val expectedFileSize: Long private val expectedFileSize: Long
private val partialFile: File = downloadFile.partialFile private val partialFile: String = downloadFile.partialFile
override fun execute() { override fun execute() {
setPlayerState(PlayerState.DOWNLOADING) setPlayerState(PlayerState.DOWNLOADING)
@ -616,7 +630,8 @@ class LocalMediaPlayer : KoinComponent {
private fun bufferComplete(): Boolean { private fun bufferComplete(): Boolean {
val completeFileAvailable = downloadFile.isWorkDone val completeFileAvailable = downloadFile.isWorkDone
val size = partialFile.length() val size = if (!StorageFile.isPathExists(partialFile)) 0
else StorageFile.getFromPath(partialFile).length()
Timber.i( Timber.i(
"Buffering %s (%d/%d, %s)", "Buffering %s (%d/%d, %s)",
@ -649,7 +664,7 @@ class LocalMediaPlayer : KoinComponent {
private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() { private inner class CheckCompletionTask(downloadFile: DownloadFile?) : CancellableTask() {
private val downloadFile: DownloadFile? private val downloadFile: DownloadFile?
private val partialFile: File? private val partialFile: String?
override fun execute() { override fun execute() {
Thread.currentThread().name = "CheckCompletionTask" Thread.currentThread().name = "CheckCompletionTask"
if (downloadFile == null) { if (downloadFile == null) {
@ -673,7 +688,10 @@ class LocalMediaPlayer : KoinComponent {
val completeFileAvailable = downloadFile!!.isWorkDone val completeFileAvailable = downloadFile!!.isWorkDone
val state = (playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED) 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 return completeFileAvailable && state
} }

View File

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

View File

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

View File

@ -13,7 +13,6 @@ import android.os.Environment
import android.text.TextUtils import android.text.TextUtils
import android.util.Pair import android.util.Pair
import java.io.BufferedWriter import java.io.BufferedWriter
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.FileWriter import java.io.FileWriter
@ -28,6 +27,7 @@ import java.util.regex.Pattern
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.MusicDirectory
import timber.log.Timber import timber.log.Timber
import java.io.File
object FileUtil { object FileUtil {
@ -43,12 +43,12 @@ object FileUtil {
const val SUFFIX_SMALL = ".jpeg-small" const val SUFFIX_SMALL = ".jpeg-small"
private const val UNNAMED = "unnamed" private const val UNNAMED = "unnamed"
fun getSongFile(song: MusicDirectory.Entry): File { fun getSongFile(song: MusicDirectory.Entry): String {
val dir = getAlbumDirectory(song) val dir = getAlbumDirectory(song)
// Do not generate new name for offline files. Offline files will have their Path as their Id. // Do not generate new name for offline files. Offline files will have their Path as their Id.
if (!TextUtils.isEmpty(song.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 // Generate a file name for the song
@ -70,7 +70,7 @@ object FileUtil {
} else { } else {
fileName.append(song.suffix) fileName.append(song.suffix)
} }
return File(dir, fileName.toString()) return "$dir/$fileName"
} }
@JvmStatic @JvmStatic
@ -104,9 +104,9 @@ object FileUtil {
* @param entry The album entry * @param entry The album entry
* @return File object. Not guaranteed that it exists * @return File object. Not guaranteed that it exists
*/ */
fun getAlbumArtFile(entry: MusicDirectory.Entry): File { fun getAlbumArtFile(entry: MusicDirectory.Entry): String? {
val albumDir = getAlbumDirectory(entry) val albumDir = getAlbumDirectory(entry)
return getAlbumArtFile(albumDir) return getAlbumArtFileForAlbumDir(albumDir)
} }
/** /**
@ -129,7 +129,7 @@ object FileUtil {
*/ */
fun getArtistArtKey(name: String?, large: Boolean): String { fun getArtistArtKey(name: String?, large: Boolean): String {
val artist = fileSystemSafe(name) 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) return getAlbumArtKey(dir, large)
} }
@ -139,9 +139,9 @@ object FileUtil {
* @param large Whether to get the key for the large or the default image * @param large Whether to get the key for the large or the default image
* @return String The hash key * @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 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? { fun getAvatarFile(username: String?): File? {
@ -159,10 +159,9 @@ object FileUtil {
* @return File object. Not guaranteed that it exists * @return File object. Not guaranteed that it exists
*/ */
@JvmStatic @JvmStatic
fun getAlbumArtFile(albumDir: File): File { fun getAlbumArtFileForAlbumDir(albumDir: String): String? {
val albumArtDir = albumArtDirectory
val key = getAlbumArtKey(albumDir, true) 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 * @return File object. Not guaranteed that it exists
*/ */
@JvmStatic @JvmStatic
fun getAlbumArtFile(cacheKey: String?): File? { fun getAlbumArtFile(cacheKey: String?): String? {
val albumArtDir = albumArtDirectory val albumArtDir = albumArtDirectory.absolutePath
return if (cacheKey == null) { return if (cacheKey == null) {
null null
} else File(albumArtDir, cacheKey) } else "$albumArtDir/$cacheKey"
} }
val albumArtDirectory: File val albumArtDirectory: File
@ -186,36 +185,30 @@ object FileUtil {
return albumArtDir return albumArtDir
} }
fun getAlbumDirectory(entry: MusicDirectory.Entry): File { fun getAlbumDirectory(entry: MusicDirectory.Entry): String {
val dir: File val dir: String
if (!TextUtils.isEmpty(entry.path)) { if (!TextUtils.isEmpty(entry.path) && getParentPath(entry.path!!) != null) {
val f = File(fileSystemSafeDir(entry.path)) val f = fileSystemSafeDir(entry.path)
dir = File( dir = String.format(
String.format(
Locale.ROOT, Locale.ROOT,
"%s/%s", "%s/%s",
musicDirectory.path, musicDirectory.getPath(),
if (entry.isDirectory) f.path else f.parent ?: "" if (entry.isDirectory) f else getParentPath(f) ?: ""
) )
)
} else { } else {
val artist = fileSystemSafe(entry.artist) val artist = fileSystemSafe(entry.artist)
var album = fileSystemSafe(entry.album) var album = fileSystemSafe(entry.album)
if (UNNAMED == album) { if (UNNAMED == album) {
album = fileSystemSafe(entry.title) 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 return dir
} }
fun createDirectoryForParent(file: File) { fun createDirectoryForParent(path: String) {
val dir = file.parentFile val dir = getParentPath(path) ?: return
if (dir != null && !dir.exists()) { StorageFile.createDirsOnPath(dir)
if (!dir.mkdirs()) {
Timber.e("Failed to create directory %s", dir)
}
}
} }
@Suppress("SameParameterValue") @Suppress("SameParameterValue")
@ -245,13 +238,8 @@ object FileUtil {
get() = getOrCreateDirectory("music") get() = getOrCreateDirectory("music")
@JvmStatic @JvmStatic
val musicDirectory: File val musicDirectory: StorageFile
get() { get() = StorageFile.getMediaRoot()
val path = Settings.cacheLocation
val dir = File(path)
val hasAccess = ensureDirectoryExistsAndIsReadWritable(dir)
return if (hasAccess.second) dir else defaultMusicDirectory
}
@JvmStatic @JvmStatic
@Suppress("ReturnCount") @Suppress("ReturnCount")
@ -326,6 +314,16 @@ object FileUtil {
* Similar to [File.listFiles], but returns a sorted set. * Similar to [File.listFiles], but returns a sorted set.
* Never returns `null`, instead a warning is logged, and an empty set is returned. * 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 @JvmStatic
fun listFiles(dir: File): SortedSet<File> { fun listFiles(dir: File): SortedSet<File> {
val files = dir.listFiles() val files = dir.listFiles()
@ -336,7 +334,7 @@ object FileUtil {
return TreeSet(files.asList()) return TreeSet(files.asList())
} }
fun listMediaFiles(dir: File): SortedSet<File> { fun listMediaFiles(dir: StorageFile): SortedSet<StorageFile> {
val files = listFiles(dir) val files = listFiles(dir)
val iterator = files.iterator() val iterator = files.iterator()
while (iterator.hasNext()) { while (iterator.hasNext()) {
@ -348,7 +346,7 @@ object FileUtil {
return files return files
} }
private fun isMediaFile(file: File): Boolean { private fun isMediaFile(file: StorageFile): Boolean {
val extension = getExtension(file.name) val extension = getExtension(file.name)
return MUSIC_FILE_EXTENSIONS.contains(extension) || return MUSIC_FILE_EXTENSIONS.contains(extension) ||
VIDEO_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)) 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. * Returns the file name of a .complete file of the given file.
* *
@ -453,9 +460,9 @@ object FileUtil {
try { try {
fw.write("#EXTM3U\n") fw.write("#EXTM3U\n")
for (e in playlist.getChildren()) { 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 ext = getExtension(filePath)
val base = getBaseName(filePath) val base = getBaseName(filePath)
filePath = "$base.complete.$ext" 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.content.Context
import android.os.Build import android.os.Build
import java.io.File
import java.io.PrintWriter import java.io.PrintWriter
import timber.log.Timber import timber.log.Timber
import java.io.File
/** /**
* Logs the stack trace of uncaught exceptions to a file on the SD card. * 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.Canvas
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.MediaScannerConnection
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
@ -38,9 +39,6 @@ import android.widget.Toast
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import java.io.Closeable import java.io.Closeable
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.UnsupportedEncodingException import java.io.UnsupportedEncodingException
import java.security.MessageDigest import java.security.MessageDigest
@ -51,6 +49,7 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.app.UApp.Companion.applicationContext import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.domain.Bookmark import org.moire.ultrasonic.domain.Bookmark
import org.moire.ultrasonic.domain.MusicDirectory 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.domain.SearchResult
import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.DownloadFile
import timber.log.Timber import timber.log.Timber
import java.io.File
private const val LINE_LENGTH = 60 private const val LINE_LENGTH = 60
private const val DEGRADE_PRECISION_AFTER = 10 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 @JvmStatic
@Throws(IOException::class) @Throws(IOException::class)
fun renameFile(from: File, to: File) { fun renameFile(from: String, to: String) {
if (from.renameTo(to)) { StorageFile.rename(from, to)
Timber.i("Renamed %s to %s", from, to)
} else {
atomicCopy(from, to)
}
} }
@JvmStatic @JvmStatic
@ -155,6 +126,17 @@ object Util {
} }
@JvmStatic @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 { fun delete(file: File?): Boolean {
if (file != null && file.exists()) { if (file != null && file.exists()) {
if (!file.delete()) { if (!file.delete()) {
@ -513,7 +495,7 @@ object Util {
intent.putExtra("artist", song.artist) intent.putExtra("artist", song.artist)
intent.putExtra("album", song.album) intent.putExtra("album", song.album)
val albumArtFile = FileUtil.getAlbumArtFile(song) val albumArtFile = FileUtil.getAlbumArtFile(song)
intent.putExtra("coverart", albumArtFile.absolutePath) intent.putExtra("coverart", albumArtFile)
} else { } else {
intent.putExtra("title", "") intent.putExtra("title", "")
intent.putExtra("artist", "") intent.putExtra("artist", "")
@ -617,8 +599,8 @@ object Util {
if (Settings.shouldSendBluetoothAlbumArt) { if (Settings.shouldSendBluetoothAlbumArt) {
val albumArtFile = FileUtil.getAlbumArtFile(song) val albumArtFile = FileUtil.getAlbumArtFile(song)
intent.putExtra("coverart", albumArtFile.absolutePath) intent.putExtra("coverart", albumArtFile)
intent.putExtra("cover", albumArtFile.absolutePath) intent.putExtra("cover", albumArtFile)
} }
intent.putExtra("position", playerPosition.toLong()) intent.putExtra("position", playerPosition.toLong())
@ -777,10 +759,11 @@ object Util {
} }
@JvmStatic @JvmStatic
fun scanMedia(file: File?) { fun scanMedia(file: String?) {
val uri = Uri.fromFile(file) // TODO this doesn't work for URIs
val scanFileIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri) MediaScannerConnection.scanFile(
appContext().sendBroadcast(scanFileIntent) UApp.applicationContext(), arrayOf(file),
null, null)
} }
fun getResourceFromAttribute(context: Context, resId: Int): Int { 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.directory_cache_time_60">1 ora</string>
<string name="settings.disc_sort">Ordina Canzoni secondo Disco</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.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.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">Visualizza Download Durante Riproduzione</string>
<string name="settings.download_transition_summary">Passa al download quando inizia 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.directory_cache_time_60">1 hour</string>
<string name="settings.disc_sort">Sort Songs By Disc</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.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.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">Show Downloads on Play</string>
<string name="settings.download_transition_summary">Transition to download activity when starting playback</string> <string name="settings.download_transition_summary">Transition to download activity when starting playback</string>