diff --git a/detekt-config.yml b/detekt-config.yml index c9117d88..8729b6e3 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -70,7 +70,7 @@ style: excludeImportStatements: false MagicNumber: # 100 common in percentage, 1000 in milliseconds - ignoreNumbers: ['-1', '0', '1', '2', '10', '100', '256', '512', '1000', '1024'] + ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024'] ignoreEnums: true ignorePropertyDeclaration: true UnnecessaryAbstractClass: diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java deleted file mode 100644 index e3a48c70..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java +++ /dev/null @@ -1,300 +0,0 @@ -package org.moire.ultrasonic.util; - -import android.os.AsyncTask; -import android.os.StatFs; - -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.Playlist; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.Downloader; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.SortedSet; - -import kotlin.Lazy; -import timber.log.Timber; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * Responsible for cleaning up files from the offline download cache on the filesystem. - */ -public class CacheCleaner -{ - private static final long MIN_FREE_SPACE = 500 * 1024L * 1024L; - - public CacheCleaner() - { - } - - public void clean() - { - try - { - new BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex, "Exception in CacheCleaner.clean"); - } - } - - public void cleanSpace() - { - try - { - new BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex,"Exception in CacheCleaner.cleanSpace"); - } - } - - public void cleanPlaylists(List playlists) - { - try - { - new BackgroundPlaylistsCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, playlists); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex, "Exception in CacheCleaner.cleanPlaylists"); - } - } - - private static void deleteEmptyDirs(Iterable dirs, Collection doNotDelete) - { - for (File dir : dirs) - { - if (doNotDelete.contains(dir)) - { - continue; - } - - File[] children = dir.listFiles(); - - if (children != null) - { - // No songs left in the folder - if (children.length == 1 && children[0].getPath().equals(FileUtil.getAlbumArtFile(dir).getPath())) - { - // Delete Artwork files - Util.delete(FileUtil.getAlbumArtFile(dir)); - children = dir.listFiles(); - } - - // Delete empty directory - if (children != null && children.length == 0) - { - Util.delete(dir); - } - } - } - } - - private static long getMinimumDelete(List files) - { - if (files.isEmpty()) - { - return 0L; - } - - long cacheSizeBytes = Settings.getCacheSizeMB() * 1024L * 1024L; - long bytesUsedBySubsonic = 0L; - - for (File file : files) - { - bytesUsedBySubsonic += file.length(); - } - - // Ensure that file system is not more than 95% full. - StatFs stat = new StatFs(files.get(0).getPath()); - long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize(); - long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); - long bytesUsedFs = bytesTotalFs - bytesAvailableFs; - long minFsAvailability = bytesTotalFs - MIN_FREE_SPACE; - - long bytesToDeleteCacheLimit = Math.max(bytesUsedBySubsonic - cacheSizeBytes, 0L); - long bytesToDeleteFsLimit = Math.max(bytesUsedFs - minFsAvailability, 0L); - long bytesToDelete = Math.max(bytesToDeleteCacheLimit, bytesToDeleteFsLimit); - - Timber.i("File system : %s of %s available", Util.formatBytes(bytesAvailableFs), Util.formatBytes(bytesTotalFs)); - Timber.i("Cache limit : %s", Util.formatBytes(cacheSizeBytes)); - Timber.i("Cache size before : %s", Util.formatBytes(bytesUsedBySubsonic)); - Timber.i("Minimum to delete : %s", Util.formatBytes(bytesToDelete)); - - return bytesToDelete; - } - - private static void deleteFiles(Collection files, Collection doNotDelete, long bytesToDelete, boolean deletePartials) - { - if (files.isEmpty()) - { - return; - } - - long bytesDeleted = 0L; - for (File file : files) - { - if (!deletePartials && bytesDeleted > bytesToDelete) break; - - if (bytesToDelete > bytesDeleted || (deletePartials && (file.getName().endsWith(".partial") || file.getName().contains(".partial.")))) - { - if (!doNotDelete.contains(file) && !file.getName().equals(Constants.ALBUM_ART_FILE)) - { - long size = file.length(); - - if (Util.delete(file)) - { - bytesDeleted += size; - } - } - } - } - - Timber.i("Deleted: %s", Util.formatBytes(bytesDeleted)); - } - - private static void findCandidatesForDeletion(File file, List files, List dirs) - { - if (file.isFile()) - { - String name = file.getName(); - boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete."); - - if (isCacheFile) - { - files.add(file); - } - } - else - { - // Depth-first - for (File child : FileUtil.listFiles(file)) - { - findCandidatesForDeletion(child, files, dirs); - } - - dirs.add(file); - } - } - - private static void sortByAscendingModificationTime(List files) - { - Collections.sort(files, (a, b) -> Long.compare(a.lastModified(), b.lastModified())); - } - - private static Set findFilesToNotDelete() - { - Set filesToNotDelete = new HashSet<>(5); - - Lazy downloader = inject(Downloader.class); - - for (DownloadFile downloadFile : downloader.getValue().getAll()) - { - filesToNotDelete.add(downloadFile.getPartialFile()); - filesToNotDelete.add(downloadFile.getCompleteOrSaveFile()); - } - - filesToNotDelete.add(FileUtil.getMusicDirectory()); - return filesToNotDelete; - } - - private static class BackgroundCleanup extends AsyncTask - { - @Override - protected Void doInBackground(Void... params) - { - try - { - Thread.currentThread().setName("BackgroundCleanup"); - List files = new ArrayList<>(); - List dirs = new ArrayList<>(); - - findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs); - sortByAscendingModificationTime(files); - - Set filesToNotDelete = findFilesToNotDelete(); - - deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true); - deleteEmptyDirs(dirs, filesToNotDelete); - } - catch (RuntimeException x) - { - Timber.e(x, "Error in cache cleaning."); - } - - return null; - } - } - - private static class BackgroundSpaceCleanup extends AsyncTask - { - @Override - protected Void doInBackground(Void... params) - { - try - { - Thread.currentThread().setName("BackgroundSpaceCleanup"); - List files = new ArrayList<>(); - List dirs = new ArrayList<>(); - findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs); - - long bytesToDelete = getMinimumDelete(files); - if (bytesToDelete > 0L) - { - sortByAscendingModificationTime(files); - Set filesToNotDelete = findFilesToNotDelete(); - deleteFiles(files, filesToNotDelete, bytesToDelete, false); - } - } - catch (RuntimeException x) - { - Timber.e(x, "Error in cache cleaning."); - } - - return null; - } - } - - private static class BackgroundPlaylistsCleanup extends AsyncTask, Void, Void> - { - @Override - protected Void doInBackground(List... params) - { - try - { - Lazy activeServerProvider = inject(ActiveServerProvider.class); - Thread.currentThread().setName("BackgroundPlaylistsCleanup"); - String server = activeServerProvider.getValue().getActiveServer().getName(); - SortedSet playlistFiles = FileUtil.listFiles(FileUtil.getPlaylistDirectory(server)); - List playlists = params[0]; - for (Playlist playlist : playlists) - { - playlistFiles.remove(FileUtil.getPlaylistFile(server, playlist.getName())); - } - - for (File playlist : playlistFiles) - { - playlist.delete(); - } - } - catch (RuntimeException x) - { - Timber.e(x, "Error in playlist cache cleaning."); - } - - return null; - } - } -} \ No newline at end of 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 new file mode 100644 index 00000000..f54673d5 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -0,0 +1,240 @@ +package org.moire.ultrasonic.util + +import android.os.AsyncTask +import android.os.StatFs +import java.io.File +import java.util.ArrayList +import java.util.HashSet +import org.koin.java.KoinJavaComponent.inject +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.Playlist +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile +import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory +import org.moire.ultrasonic.util.FileUtil.getPlaylistFile +import org.moire.ultrasonic.util.FileUtil.listFiles +import org.moire.ultrasonic.util.FileUtil.musicDirectory +import org.moire.ultrasonic.util.Settings.cacheSizeMB +import org.moire.ultrasonic.util.Util.delete +import org.moire.ultrasonic.util.Util.formatBytes +import timber.log.Timber + +/** + * Responsible for cleaning up files from the offline download cache on the filesystem. + */ +class CacheCleaner { + fun clean() { + try { + BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.clean") + } + } + + fun cleanSpace() { + try { + BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.cleanSpace") + } + } + + fun cleanPlaylists(playlists: List) { + try { + BackgroundPlaylistsCleanup().executeOnExecutor( + AsyncTask.THREAD_POOL_EXECUTOR, + playlists + ) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.cleanPlaylists") + } + } + + private class BackgroundCleanup : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + try { + Thread.currentThread().name = "BackgroundCleanup" + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + sortByAscendingModificationTime(files) + val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true) + deleteEmptyDirs(dirs, filesToNotDelete) + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") + } + return null + } + } + + private class BackgroundSpaceCleanup : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + try { + Thread.currentThread().name = "BackgroundSpaceCleanup" + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + val bytesToDelete = getMinimumDelete(files) + if (bytesToDelete > 0L) { + sortByAscendingModificationTime(files) + val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, bytesToDelete, false) + } + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") + } + return null + } + } + + private class BackgroundPlaylistsCleanup : AsyncTask, Void?, Void?>() { + override fun doInBackground(vararg params: List): Void? { + try { + val activeServerProvider = inject( + ActiveServerProvider::class.java + ) + Thread.currentThread().name = "BackgroundPlaylistsCleanup" + val server = activeServerProvider.value.getActiveServer().name + val playlistFiles = listFiles(getPlaylistDirectory(server)) + val playlists = params[0] + for ((_, name) in playlists) { + playlistFiles.remove(getPlaylistFile(server, name)) + } + for (playlist in playlistFiles) { + playlist.delete() + } + } catch (all: RuntimeException) { + Timber.e(all, "Error in playlist cache cleaning.") + } + return null + } + } + + companion object { + private const val MIN_FREE_SPACE = 500 * 1024L * 1024L + private fun deleteEmptyDirs(dirs: Iterable, doNotDelete: Collection) { + for (dir in dirs) { + if (doNotDelete.contains(dir)) { + continue + } + var children = dir.listFiles() + if (children != null) { + // No songs left in the folder + if (children.size == 1 && children[0].path == getAlbumArtFile(dir).path) { + // Delete Artwork files + delete(getAlbumArtFile(dir)) + children = dir.listFiles() + } + + // Delete empty directory + if (children != null && children.isEmpty()) { + delete(dir) + } + } + } + } + + private fun getMinimumDelete(files: List): Long { + if (files.isEmpty()) { + return 0L + } + val cacheSizeBytes = cacheSizeMB * 1024L * 1024L + var bytesUsedBySubsonic = 0L + for (file in files) { + bytesUsedBySubsonic += file.length() + } + + // 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 bytesToDeleteCacheLimit = (bytesUsedBySubsonic - cacheSizeBytes).coerceAtLeast(0L) + val bytesToDeleteFsLimit = (bytesUsedFs - minFsAvailability).coerceAtLeast(0L) + val bytesToDelete = bytesToDeleteCacheLimit.coerceAtLeast(bytesToDeleteFsLimit) + + Timber.i( + "File system : %s of %s available", + formatBytes(bytesAvailableFs), + formatBytes(bytesTotalFs) + ) + Timber.i("Cache limit : %s", formatBytes(cacheSizeBytes)) + Timber.i("Cache size before : %s", formatBytes(bytesUsedBySubsonic)) + Timber.i("Minimum to delete : %s", formatBytes(bytesToDelete)) + return bytesToDelete + } + + private fun isPartial(file: File): Boolean { + return file.name.endsWith(".partial") || file.name.contains(".partial.") + } + + private fun isComplete(file: File): Boolean { + return file.name.endsWith(".complete") || file.name.contains(".complete.") + } + + @Suppress("NestedBlockDepth") + private fun deleteFiles( + files: Collection, + doNotDelete: Collection, + bytesToDelete: Long, + deletePartials: Boolean + ) { + if (files.isEmpty()) { + return + } + var bytesDeleted = 0L + 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) { + val size = file.length() + if (delete(file)) { + bytesDeleted += size + } + } + } + } + Timber.i("Deleted: %s", formatBytes(bytesDeleted)) + } + + private fun findCandidatesForDeletion( + file: File, + files: MutableList, + dirs: MutableList + ) { + if (file.isFile && (isPartial(file) || isComplete(file))) { + files.add(file) + } else { + // Depth-first + for (child in listFiles(file)) { + findCandidatesForDeletion(child, files, dirs) + } + dirs.add(file) + } + } + + private fun sortByAscendingModificationTime(files: MutableList) { + files.sortWith { a: File, b: File -> + a.lastModified().compareTo(b.lastModified()) + } + } + + private fun findFilesToNotDelete(): Set { + val filesToNotDelete: MutableSet = HashSet(5) + val downloader = inject( + Downloader::class.java + ) + for (downloadFile in downloader.value.all) { + filesToNotDelete.add(downloadFile.partialFile) + filesToNotDelete.add(downloadFile.completeOrSaveFile) + } + filesToNotDelete.add(musicDirectory) + return filesToNotDelete + } + } +}