From 1d0bb944e173263a47439f1f201b88bbfb3d60f7 Mon Sep 17 00:00:00 2001 From: Nite Date: Fri, 19 Nov 2021 18:43:52 +0100 Subject: [PATCH] Implemented Storage Access Framework as Music Cache --- dependencies.gradle | 2 + ultrasonic/build.gradle | 1 + .../moire/ultrasonic/util/StreamProxy.java | 11 +- .../ultrasonic/fragment/SettingsFragment.kt | 38 ++- .../ultrasonic/imageloader/BitmapUtils.kt | 10 +- .../ultrasonic/imageloader/ImageLoader.kt | 6 +- .../moire/ultrasonic/log/FileLoggerTree.kt | 2 +- .../moire/ultrasonic/service/DownloadFile.kt | 95 ++---- .../ultrasonic/service/LocalMediaPlayer.kt | 40 ++- .../ultrasonic/service/OfflineMusicService.kt | 86 +++--- .../org/moire/ultrasonic/util/CacheCleaner.kt | 75 +++-- .../org/moire/ultrasonic/util/FileUtil.kt | 93 +++--- .../org/moire/ultrasonic/util/StorageFile.kt | 273 ++++++++++++++++++ .../util/SubsonicUncaughtExceptionHandler.kt | 2 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 65 ++--- ultrasonic/src/main/res/values-it/strings.xml | 2 +- ultrasonic/src/main/res/values/strings.xml | 2 +- 17 files changed, 543 insertions(+), 260 deletions(-) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt diff --git a/dependencies.gradle b/dependencies.gradle index 301e7193..1abb8e94 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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 = [ diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 16645705..017fa798 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -106,6 +106,7 @@ dependencies { implementation other.fastScroll implementation other.sortListView implementation other.colorPickerView + implementation other.fsaf kapt androidSupport.room diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java index 8dfb1cd5..7ce9eae6 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/StreamProxy.java @@ -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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index d481d11c..ab5170d0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -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() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt index 1d899bd4..a6b36b15 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt @@ -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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index 4b28e82c..654a2558 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt index 0b8d894b..38b5a907 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/log/FileLoggerTree.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt index 86b9aeab..c5b0f120 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt @@ -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 = 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 { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 0b0a1b42..2dc5539b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -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() { @@ -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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index d705119a..c214776d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -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 = 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 = LinkedList() + val children: MutableList = 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, songs: MutableList @@ -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) { + private fun listFilesRecursively(parent: StorageFile, children: MutableList) { for (file in FileUtil.listMediaFiles(parent)) { if (file.isFile) { children.add(file) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index f54673d5..e89f8271 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -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 = ArrayList() - val dirs: MutableList = ArrayList() + val files: MutableList = ArrayList() + val dirs: MutableList = 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 = ArrayList() - val dirs: MutableList = ArrayList() + val files: MutableList = ArrayList() + val dirs: MutableList = 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, doNotDelete: Collection) { + private fun deleteEmptyDirs(dirs: Iterable, doNotDelete: Collection) { 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): Long { + private fun getMinimumDelete(files: List): 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, - doNotDelete: Collection, + files: Collection, + doNotDelete: Collection, 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, - dirs: MutableList + file: StorageFile, + files: MutableList, + dirs: MutableList ) { if (file.isFile && (isPartial(file) || isComplete(file))) { files.add(file) @@ -218,14 +233,14 @@ class CacheCleaner { } } - private fun sortByAscendingModificationTime(files: MutableList) { - files.sortWith { a: File, b: File -> + private fun sortByAscendingModificationTime(files: MutableList) { + files.sortWith { a: StorageFile, b: StorageFile -> a.lastModified().compareTo(b.lastModified()) } } - private fun findFilesToNotDelete(): Set { - val filesToNotDelete: MutableSet = HashSet(5) + private fun findFilesToNotDelete(): Set { + val filesToNotDelete: MutableSet = HashSet(5) val downloader = inject( 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 } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index c384309c..83534321 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -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 { + val files = dir.listFiles() + if (files == null) { + Timber.w("Failed to list children for %s", dir.getPath()) + return TreeSet() + } + return TreeSet(files.asList()) + } + @JvmStatic fun listFiles(dir: File): SortedSet { val files = dir.listFiles() @@ -336,7 +334,7 @@ object FileUtil { return TreeSet(files.asList()) } - fun listMediaFiles(dir: File): SortedSet { + fun listMediaFiles(dir: StorageFile): SortedSet { 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" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt new file mode 100644 index 00000000..74d292d5 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt @@ -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 { + + 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 { + 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().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().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 = lazy { + val manager = FileManager(UApp.applicationContext()) + manager.registerBaseDir(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()!!, + 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? { + 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("(? 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(':') +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt index 658d2943..4231c34c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/SubsonicUncaughtExceptionHandler.kt @@ -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. diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index c6a8af0b..e2d7b259 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -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 { diff --git a/ultrasonic/src/main/res/values-it/strings.xml b/ultrasonic/src/main/res/values-it/strings.xml index a37ab73c..ffc11685 100644 --- a/ultrasonic/src/main/res/values-it/strings.xml +++ b/ultrasonic/src/main/res/values-it/strings.xml @@ -191,7 +191,7 @@ 1 ora Ordina Canzoni secondo Disco Ordina lista canzoni secondo il numero disco e traccia - Visualizza Bitrate Ed Estensione File + Visualizza Bitrate Ed Estensione FileAdapter Aggiungi nome artista con bitrare ed estensione file Visualizza Download Durante Riproduzione Passa al download quando inizia riproduzione diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index d886cd68..5ddee5ce 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -214,7 +214,7 @@ 1 hour Sort Songs By Disc Sort song list by disc number and track number - Display Bitrate and File Suffix + Display Bitrate and FileAdapter Suffix Append artist name with bitrate and file suffix Show Downloads on Play Transition to download activity when starting playback