Merge branch 'develop' into refactor-events
This commit is contained in:
commit
66df5b1daf
|
@ -7,7 +7,7 @@ ext.versions = [
|
||||||
|
|
||||||
navigation : "2.3.5",
|
navigation : "2.3.5",
|
||||||
gradlePlugin : "4.2.2",
|
gradlePlugin : "4.2.2",
|
||||||
androidxcore : "1.5.0",
|
androidxcore : "1.6.0",
|
||||||
ktlint : "0.37.1",
|
ktlint : "0.37.1",
|
||||||
ktlintGradle : "10.2.0",
|
ktlintGradle : "10.2.0",
|
||||||
detekt : "1.18.1",
|
detekt : "1.18.1",
|
||||||
|
@ -23,7 +23,7 @@ ext.versions = [
|
||||||
room : "2.3.0",
|
room : "2.3.0",
|
||||||
kotlin : "1.5.31",
|
kotlin : "1.5.31",
|
||||||
kotlinxCoroutines : "1.5.2-native-mt",
|
kotlinxCoroutines : "1.5.2-native-mt",
|
||||||
viewModelKtx : "2.2.0",
|
viewModelKtx : "2.3.0",
|
||||||
|
|
||||||
retrofit : "2.6.4",
|
retrofit : "2.6.4",
|
||||||
jackson : "2.9.5",
|
jackson : "2.9.5",
|
||||||
|
@ -35,7 +35,7 @@ ext.versions = [
|
||||||
junit4 : "4.13.2",
|
junit4 : "4.13.2",
|
||||||
junit5 : "5.8.1",
|
junit5 : "5.8.1",
|
||||||
mockito : "4.0.0",
|
mockito : "4.0.0",
|
||||||
mockitoKotlin : "3.2.0",
|
mockitoKotlin : "4.0.0",
|
||||||
kluent : "1.68",
|
kluent : "1.68",
|
||||||
apacheCodecs : "1.15",
|
apacheCodecs : "1.15",
|
||||||
robolectric : "4.6.1",
|
robolectric : "4.6.1",
|
||||||
|
|
|
@ -70,7 +70,7 @@ style:
|
||||||
excludeImportStatements: false
|
excludeImportStatements: false
|
||||||
MagicNumber:
|
MagicNumber:
|
||||||
# 100 common in percentage, 1000 in milliseconds
|
# 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
|
ignoreEnums: true
|
||||||
ignorePropertyDeclaration: true
|
ignorePropertyDeclaration: true
|
||||||
UnnecessaryAbstractClass:
|
UnnecessaryAbstractClass:
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.moire.ultrasonic.fragment;
|
package org.moire.ultrasonic.fragment;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.util.Pair;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
@ -25,7 +26,6 @@ import org.moire.ultrasonic.subsonic.VideoPlayer;
|
||||||
import org.moire.ultrasonic.util.CancellationToken;
|
import org.moire.ultrasonic.util.CancellationToken;
|
||||||
import org.moire.ultrasonic.util.Constants;
|
import org.moire.ultrasonic.util.Constants;
|
||||||
import org.moire.ultrasonic.util.FragmentBackgroundTask;
|
import org.moire.ultrasonic.util.FragmentBackgroundTask;
|
||||||
import org.moire.ultrasonic.util.Pair;
|
|
||||||
import org.moire.ultrasonic.util.Util;
|
import org.moire.ultrasonic.util.Util;
|
||||||
import org.moire.ultrasonic.view.EntryAdapter;
|
import org.moire.ultrasonic.view.EntryAdapter;
|
||||||
|
|
||||||
|
@ -341,7 +341,7 @@ public class BookmarksFragment extends Fragment {
|
||||||
@Override
|
@Override
|
||||||
protected void done(Pair<MusicDirectory, Boolean> result)
|
protected void done(Pair<MusicDirectory, Boolean> result)
|
||||||
{
|
{
|
||||||
MusicDirectory musicDirectory = result.getFirst();
|
MusicDirectory musicDirectory = result.first;
|
||||||
List<MusicDirectory.Entry> entries = musicDirectory.getChildren();
|
List<MusicDirectory.Entry> entries = musicDirectory.getChildren();
|
||||||
|
|
||||||
int songCount = 0;
|
int songCount = 0;
|
||||||
|
@ -371,7 +371,7 @@ public class BookmarksFragment extends Fragment {
|
||||||
deleteButton.setVisibility(View.GONE);
|
deleteButton.setVisibility(View.GONE);
|
||||||
playNowButton.setVisibility(View.GONE);
|
playNowButton.setVisibility(View.GONE);
|
||||||
|
|
||||||
if (listSize == 0 || result.getFirst().getChildren().size() < listSize)
|
if (listSize == 0 || result.first.getChildren().size() < listSize)
|
||||||
{
|
{
|
||||||
albumButtons.setVisibility(View.GONE);
|
albumButtons.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Playlist> 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<File> dirs, Collection<File> 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<File> 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<File> files, Collection<File> 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<File> files, List<File> 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<File> files)
|
|
||||||
{
|
|
||||||
Collections.sort(files, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<File> findFilesToNotDelete()
|
|
||||||
{
|
|
||||||
Set<File> filesToNotDelete = new HashSet<>(5);
|
|
||||||
|
|
||||||
Lazy<Downloader> 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<Void, Void, Void>
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(Void... params)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Thread.currentThread().setName("BackgroundCleanup");
|
|
||||||
List<File> files = new ArrayList<>();
|
|
||||||
List<File> dirs = new ArrayList<>();
|
|
||||||
|
|
||||||
findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs);
|
|
||||||
sortByAscendingModificationTime(files);
|
|
||||||
|
|
||||||
Set<File> 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<Void, Void, Void>
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(Void... params)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Thread.currentThread().setName("BackgroundSpaceCleanup");
|
|
||||||
List<File> files = new ArrayList<>();
|
|
||||||
List<File> dirs = new ArrayList<>();
|
|
||||||
findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs);
|
|
||||||
|
|
||||||
long bytesToDelete = getMinimumDelete(files);
|
|
||||||
if (bytesToDelete > 0L)
|
|
||||||
{
|
|
||||||
sortByAscendingModificationTime(files);
|
|
||||||
Set<File> 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<List<Playlist>, Void, Void>
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(List<Playlist>... params)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Lazy<ActiveServerProvider> activeServerProvider = inject(ActiveServerProvider.class);
|
|
||||||
Thread.currentThread().setName("BackgroundPlaylistsCleanup");
|
|
||||||
String server = activeServerProvider.getValue().getActiveServer().getName();
|
|
||||||
SortedSet<File> playlistFiles = FileUtil.listFiles(FileUtil.getPlaylistDirectory(server));
|
|
||||||
List<Playlist> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
/*
|
|
||||||
This file is part of Subsonic.
|
|
||||||
|
|
||||||
Subsonic is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
Subsonic is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with Subsonic. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Copyright 2009 (C) Sindre Mehus
|
|
||||||
*/
|
|
||||||
package org.moire.ultrasonic.util;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author Sindre Mehus
|
|
||||||
*/
|
|
||||||
public class Pair<S, T> implements Serializable
|
|
||||||
{
|
|
||||||
private static final long serialVersionUID = -8903987928477888234L;
|
|
||||||
private final S first;
|
|
||||||
private final T second;
|
|
||||||
|
|
||||||
public Pair(S first, T second)
|
|
||||||
{
|
|
||||||
this.first = first;
|
|
||||||
this.second = second;
|
|
||||||
}
|
|
||||||
|
|
||||||
public S getFirst()
|
|
||||||
{
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
public T getSecond()
|
|
||||||
{
|
|
||||||
return second;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,8 +2,8 @@ package org.moire.ultrasonic.data
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -24,7 +24,7 @@ import timber.log.Timber
|
||||||
*/
|
*/
|
||||||
class ActiveServerProvider(
|
class ActiveServerProvider(
|
||||||
private val repository: ServerSettingDao
|
private val repository: ServerSettingDao
|
||||||
) {
|
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
private var cachedServer: ServerSetting? = null
|
private var cachedServer: ServerSetting? = null
|
||||||
private var cachedDatabase: MetaDatabase? = null
|
private var cachedDatabase: MetaDatabase? = null
|
||||||
private var cachedServerId: Int? = null
|
private var cachedServerId: Int? = null
|
||||||
|
@ -83,7 +83,7 @@ class ActiveServerProvider(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
launch {
|
||||||
val serverId = repository.findByIndex(index)?.id ?: 0
|
val serverId = repository.findByIndex(index)?.id ?: 0
|
||||||
setActiveServerId(serverId)
|
setActiveServerId(serverId)
|
||||||
}
|
}
|
||||||
|
@ -133,7 +133,7 @@ class ActiveServerProvider(
|
||||||
* Sets the minimum Subsonic API version of the current server.
|
* Sets the minimum Subsonic API version of the current server.
|
||||||
*/
|
*/
|
||||||
fun setMinimumApiVersion(apiVersion: String) {
|
fun setMinimumApiVersion(apiVersion: String) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
launch {
|
||||||
if (cachedServer != null) {
|
if (cachedServer != null) {
|
||||||
cachedServer!!.minimumApiVersion = apiVersion
|
cachedServer!!.minimumApiVersion = apiVersion
|
||||||
repository.update(cachedServer!!)
|
repository.update(cachedServer!!)
|
||||||
|
|
|
@ -178,7 +178,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.setNegativeButton(getString(R.string.common_cancel)) {
|
.setNegativeButton(getString(R.string.common_cancel)) {
|
||||||
dialogInterface, i ->
|
dialogInterface, _ ->
|
||||||
dialogInterface.dismiss()
|
dialogInterface.dismiss()
|
||||||
}
|
}
|
||||||
.setBottomSpace(DIALOG_PADDING)
|
.setBottomSpace(DIALOG_PADDING)
|
||||||
|
|
|
@ -31,7 +31,7 @@ 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 != null && albumArtFile.exists()) {
|
if (albumArtFile.exists()) {
|
||||||
return getBitmapFromDisk(albumArtFile.path, size, bitmap)
|
return getBitmapFromDisk(albumArtFile.path, size, bitmap)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
@ -260,12 +260,8 @@ class MediaPlayerController(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clear() {
|
@JvmOverloads
|
||||||
clear(true)
|
fun clear(serialize: Boolean = true) {
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clear(serialize: Boolean) {
|
|
||||||
val mediaPlayerService = runningInstance
|
val mediaPlayerService = runningInstance
|
||||||
if (mediaPlayerService != null) {
|
if (mediaPlayerService != null) {
|
||||||
mediaPlayerService.clear(serialize)
|
mediaPlayerService.clear(serialize)
|
||||||
|
|
|
@ -0,0 +1,252 @@
|
||||||
|
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<Playlist>) {
|
||||||
|
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<Void?, Void?, Void?>() {
|
||||||
|
override fun doInBackground(vararg params: Void?): Void? {
|
||||||
|
try {
|
||||||
|
Thread.currentThread().name = "BackgroundCleanup"
|
||||||
|
|
||||||
|
val files: MutableList<File> = ArrayList()
|
||||||
|
val dirs: MutableList<File> = 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<Void?, Void?, Void?>() {
|
||||||
|
override fun doInBackground(vararg params: Void?): Void? {
|
||||||
|
try {
|
||||||
|
Thread.currentThread().name = "BackgroundSpaceCleanup"
|
||||||
|
|
||||||
|
val files: MutableList<File> = ArrayList()
|
||||||
|
val dirs: MutableList<File> = ArrayList()
|
||||||
|
|
||||||
|
findCandidatesForDeletion(musicDirectory, files, dirs)
|
||||||
|
|
||||||
|
val bytesToDelete = getMinimumDelete(files)
|
||||||
|
if (bytesToDelete <= 0L) return null
|
||||||
|
|
||||||
|
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<List<Playlist>, Void?, Void?>() {
|
||||||
|
override fun doInBackground(vararg params: List<Playlist>): Void? {
|
||||||
|
try {
|
||||||
|
val activeServerProvider = inject<ActiveServerProvider>(
|
||||||
|
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<File>, doNotDelete: Collection<File>) {
|
||||||
|
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<File>): 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<File>,
|
||||||
|
doNotDelete: Collection<File>,
|
||||||
|
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<File>,
|
||||||
|
dirs: MutableList<File>
|
||||||
|
) {
|
||||||
|
if (file.isFile && (isPartial(file) || isComplete(file))) {
|
||||||
|
files.add(file)
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
// Depth-first
|
||||||
|
for (child in listFiles(file)) {
|
||||||
|
findCandidatesForDeletion(child, files, dirs)
|
||||||
|
}
|
||||||
|
dirs.add(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sortByAscendingModificationTime(files: MutableList<File>) {
|
||||||
|
files.sortWith { a: File, b: File ->
|
||||||
|
a.lastModified().compareTo(b.lastModified())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findFilesToNotDelete(): Set<File> {
|
||||||
|
val filesToNotDelete: MutableSet<File> = HashSet(5)
|
||||||
|
|
||||||
|
val downloader = inject<Downloader>(
|
||||||
|
Downloader::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
for (downloadFile in downloader.value.all) {
|
||||||
|
filesToNotDelete.add(downloadFile.partialFile)
|
||||||
|
filesToNotDelete.add(downloadFile.completeOrSaveFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesToNotDelete.add(musicDirectory)
|
||||||
|
return filesToNotDelete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,6 @@ package org.moire.ultrasonic.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
@ -70,17 +69,21 @@ object Settings {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val maxBitRate: Int
|
val maxBitRate: Int
|
||||||
get() {
|
get() {
|
||||||
val manager = Util.getConnectivityManager()
|
val network = Util.networkInfo()
|
||||||
val networkInfo = manager.activeNetworkInfo ?: return 0
|
|
||||||
val wifi = networkInfo.type == ConnectivityManager.TYPE_WIFI
|
if (!network.connected) return 0
|
||||||
val preferences = preferences
|
|
||||||
return preferences.getString(
|
if (network.unmetered) {
|
||||||
if (wifi) Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI
|
return maxWifiBitRate
|
||||||
else Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE,
|
} else {
|
||||||
"0"
|
return maxMobileBitRate
|
||||||
)!!.toInt()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var maxWifiBitRate by StringIntSetting(Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI)
|
||||||
|
|
||||||
|
private var maxMobileBitRate by StringIntSetting(Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val preloadCount: Int
|
val preloadCount: Int
|
||||||
get() {
|
get() {
|
||||||
|
|
|
@ -22,9 +22,13 @@ 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.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||||
|
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.net.wifi.WifiManager.WifiLock
|
import android.net.wifi.WifiManager.WifiLock
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
@ -374,14 +378,45 @@ object Util {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a usable network for downloading media is available
|
||||||
|
*
|
||||||
|
* @return Boolean
|
||||||
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isNetworkConnected(): Boolean {
|
fun isNetworkConnected(): Boolean {
|
||||||
val manager = getConnectivityManager()
|
val info = networkInfo()
|
||||||
val networkInfo = manager.activeNetworkInfo
|
val isUnmetered = info.unmetered
|
||||||
val connected = networkInfo != null && networkInfo.isConnected
|
|
||||||
val wifiConnected = connected && networkInfo!!.type == ConnectivityManager.TYPE_WIFI
|
|
||||||
val wifiRequired = Settings.isWifiRequiredForDownload
|
val wifiRequired = Settings.isWifiRequiredForDownload
|
||||||
return connected && (!wifiRequired || wifiConnected)
|
return info.connected && (!wifiRequired || isUnmetered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query connectivity status
|
||||||
|
*
|
||||||
|
* @return NetworkInfo object
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun networkInfo(): NetworkInfo {
|
||||||
|
val manager = getConnectivityManager()
|
||||||
|
val info = NetworkInfo()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network: Network? = manager.activeNetwork
|
||||||
|
val capabilities = manager.getNetworkCapabilities(network)
|
||||||
|
|
||||||
|
if (capabilities != null) {
|
||||||
|
info.unmetered = capabilities.hasCapability(NET_CAPABILITY_NOT_METERED)
|
||||||
|
info.connected = capabilities.hasCapability(NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val networkInfo = manager.activeNetworkInfo
|
||||||
|
if (networkInfo != null) {
|
||||||
|
info.unmetered = networkInfo.type == ConnectivityManager.TYPE_WIFI
|
||||||
|
info.connected = networkInfo.isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
@ -921,4 +956,9 @@ object Util {
|
||||||
val context = appContext()
|
val context = appContext()
|
||||||
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class NetworkInfo(
|
||||||
|
var connected: Boolean = false,
|
||||||
|
var unmetered: Boolean = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -339,8 +339,8 @@
|
||||||
<string name="settings.view_refresh_4000">4 seconds</string>
|
<string name="settings.view_refresh_4000">4 seconds</string>
|
||||||
<string name="settings.view_refresh_4500">4.5 seconds</string>
|
<string name="settings.view_refresh_4500">4.5 seconds</string>
|
||||||
<string name="settings.view_refresh_5000">5 seconds</string>
|
<string name="settings.view_refresh_5000">5 seconds</string>
|
||||||
<string name="settings.wifi_required_summary">Only stream media if connected to Wi-Fi</string>
|
<string name="settings.wifi_required_summary">Only download media on unmetered connections</string>
|
||||||
<string name="settings.wifi_required_title">Wi-Fi Streaming Only</string>
|
<string name="settings.wifi_required_title">Download on Wi-Fi only</string>
|
||||||
<string name="song_details.all">%1$s%2$s</string>
|
<string name="song_details.all">%1$s%2$s</string>
|
||||||
<string name="song_details.kbps">%d kbps</string>
|
<string name="song_details.kbps">%d kbps</string>
|
||||||
<string name="util.bytes_format.byte">0 B</string>
|
<string name="util.bytes_format.byte">0 B</string>
|
||||||
|
|
Loading…
Reference in New Issue