This commit is contained in:
tzugen 2021-11-01 17:07:18 +01:00
parent 7ed91db250
commit 050161bbb0
No known key found for this signature in database
GPG Key ID: 61E9C34BC10EC930
35 changed files with 531 additions and 1819 deletions

View File

@ -2,7 +2,7 @@ version: 3
jobs:
build:
docker:
- image: circleci/android:api-29
- image: circleci/android:api-30
working_directory: ~/ultrasonic
environment:
JVM_OPTS: -Xmx3200m

View File

@ -1,7 +1,7 @@
ext.versions = [
minSdk : 21,
targetSdk : 29,
compileSdk : 29,
targetSdk : 30,
compileSdk : 30,
// You need to run ./gradlew wrapper after updating the version
gradle : '7.2',
@ -39,7 +39,6 @@ ext.versions = [
kluent : "1.68",
apacheCodecs : "1.15",
robolectric : "4.6.1",
dexter : "6.2.3",
timber : "4.7.1",
fastScroll : "2.0.1",
colorPicker : "2.2.3",
@ -86,7 +85,6 @@ ext.other = [
koinAndroid : "io.insert-koin:koin-android:$versions.koin",
koinViewModel : "io.insert-koin:koin-android-viewmodel:$versions.koin",
picasso : "com.squareup.picasso:picasso:$versions.picasso",
dexter : "com.karumi:dexter:$versions.dexter",
timber : "com.jakewharton.timber:timber:$versions.timber",
fastScroll : "com.simplecityapps:recyclerview-fastscroll:$versions.fastScroll",
sortListView : "com.github.tzugen:drag-sort-listview:$versions.sortListView",

View File

@ -3,10 +3,7 @@
<ManuallySuppressedIssues></ManuallySuppressedIssues>
<CurrentIssues>
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.&lt;no name provided&gt;$!append &amp;&amp; !playNext &amp;&amp; !unpin &amp;&amp; !background</ID>
<ID>ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt"</ID>
<ID>ComplexCondition:LocalMediaPlayer.kt$LocalMediaPlayer$Util.getGaplessPlaybackPreference() &amp;&amp; Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.JELLY_BEAN &amp;&amp; ( playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED )</ID>
<ID>ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>ComplexMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File)</ID>
<ID>ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean)</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons()</ID>
<ID>ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
@ -21,14 +18,12 @@
<ID>ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$String.format("BufferTask (%s)", downloadFile)</ID>
<ID>ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$String.format("CheckCompletionTask (%s)", downloadFile)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType)</ID>
<ID>ImplicitDefaultLocale:ShareHandler.kt$ShareHandler.&lt;no name provided&gt;$String.format("%s\n\n%s", Util.getShareGreeting(), result.url)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%02d.", trackNumber)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s ", bitRate)</ID>
<ID>ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s &gt; %s", suffix, transcodedSuffix)</ID>
<ID>LargeClass:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment</ID>
<ID>LongMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)</ID>
<ID>LongMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File)</ID>
<ID>LongMethod:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean)</ID>
<ID>LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
<ID>LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken )</ID>
@ -39,23 +34,18 @@
<ID>LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory)</ID>
<ID>LongParameterList:ServerRowAdapter.kt$ServerRowAdapter$( private var context: Context, private var data: Array&lt;ServerSetting&gt;, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -&gt; Unit), private val serverEditRequestedCallback: ((Int) -&gt; Unit) )</ID>
<ID>MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192</ID>
<ID>MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$10</ID>
<ID>MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$60</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.&lt;no name provided&gt;$60000</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$1024L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$86400L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8L</ID>
<ID>MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$5000L</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$256</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$3</ID>
<ID>MagicNumber:MediaPlayerService.kt$MediaPlayerService$4</ID>
<ID>MagicNumber:RESTMusicService.kt$RESTMusicService$206</ID>
<ID>MagicNumber:SongView.kt$SongView$3</ID>
<ID>MagicNumber:SongView.kt$SongView$4</ID>
<ID>MagicNumber:SongView.kt$SongView$60</ID>
<ID>MagicNumber:TrackCollectionFragment.kt$TrackCollectionFragment$10</ID>
<ID>NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute()</ID>
<ID>NestedBlockDepth:DownloadHandler.kt$DownloadHandler$private fun downloadRecursively( fragment: Fragment, id: String, name: String?, isShare: Boolean, isDirectory: Boolean, save: Boolean, append: Boolean, autoPlay: Boolean, shuffle: Boolean, background: Boolean, playNext: Boolean, unpin: Boolean, isArtist: Boolean )</ID>
<ID>NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler()</ID>

View File

@ -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:

View File

@ -119,7 +119,6 @@ dependencies {
testImplementation testing.mockitoKotlin
testImplementation testing.robolectric
implementation other.dexter
implementation other.timber
}

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
@ -29,6 +28,7 @@
android:label="@string/common.appname"
android:usesCleartextTraffic="true"
android:supportsRtl="false"
android:preserveLegacyExternalStorage="true"
tools:ignore="UnusedAttribute">
<meta-data android:name="com.google.android.gms.car.application"
@ -145,7 +145,8 @@
<provider
android:name=".provider.SearchSuggestionProvider"
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"/>
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
tools:ignore="ExportedContentProvider" />
<receiver
android:name=".receiver.A2dpIntentReceiver"

View File

@ -1,6 +1,7 @@
package org.moire.ultrasonic.fragment;
import android.os.Bundle;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
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.Constants;
import org.moire.ultrasonic.util.FragmentBackgroundTask;
import org.moire.ultrasonic.util.Pair;
import org.moire.ultrasonic.util.Util;
import org.moire.ultrasonic.view.EntryAdapter;
@ -341,7 +341,7 @@ public class BookmarksFragment extends Fragment {
@Override
protected void done(Pair<MusicDirectory, Boolean> result)
{
MusicDirectory musicDirectory = result.getFirst();
MusicDirectory musicDirectory = result.first;
List<MusicDirectory.Entry> entries = musicDirectory.getChildren();
int songCount = 0;
@ -371,7 +371,7 @@ public class BookmarksFragment extends Fragment {
deleteButton.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);
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -48,7 +48,6 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.NowPlayingEventDistributor
import org.moire.ultrasonic.util.NowPlayingEventListener
import org.moire.ultrasonic.util.PermissionUtil
import org.moire.ultrasonic.util.ServerColor
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.SubsonicUncaughtExceptionHandler
@ -60,6 +59,7 @@ import timber.log.Timber
/**
* The main Activity of Ultrasonic which loads all other screens as Fragments
*/
@Suppress("TooManyFunctions")
class NavigationActivity : AppCompatActivity() {
private var chatMenuItem: MenuItem? = null
private var bookmarksMenuItem: MenuItem? = null
@ -83,7 +83,6 @@ class NavigationActivity : AppCompatActivity() {
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val nowPlayingEventDistributor: NowPlayingEventDistributor by inject()
private val themeChangedEventDistributor: ThemeChangedEventDistributor by inject()
private val permissionUtil: PermissionUtil by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val serverRepository: ServerSettingDao by inject()
@ -93,7 +92,6 @@ class NavigationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setUncaughtExceptionHandler()
permissionUtil.onForegroundApplicationStarted(this)
Util.applyTheme(this)
super.onCreate(savedInstanceState)
@ -240,7 +238,6 @@ class NavigationActivity : AppCompatActivity() {
nowPlayingEventDistributor.unsubscribe(nowPlayingEventListener)
themeChangedEventDistributor.unsubscribe(themeChangedEventListener)
imageLoaderProvider.clearImageLoader()
permissionUtil.onForegroundApplicationStopped()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
@ -353,10 +350,6 @@ class NavigationActivity : AppCompatActivity() {
private fun loadSettings() {
PreferenceManager.setDefaultValues(this, R.xml.settings, false)
val preferences = Settings.preferences
if (!preferences.contains(Constants.PREFERENCES_KEY_CACHE_LOCATION)) {
Settings.cacheLocation = FileUtil.defaultMusicDirectory.path
}
}
private fun exit() {

View File

@ -7,7 +7,6 @@ import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.MediaSessionEventDistributor
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.NowPlayingEventDistributor
import org.moire.ultrasonic.util.PermissionUtil
import org.moire.ultrasonic.util.ThemeChangedEventDistributor
/**
@ -16,7 +15,6 @@ import org.moire.ultrasonic.util.ThemeChangedEventDistributor
val applicationModule = module {
single { ActiveServerProvider(get()) }
single { ImageLoaderProvider(androidContext()) }
single { PermissionUtil(androidContext()) }
single { NowPlayingEventDistributor() }
single { ThemeChangedEventDistributor() }
single { MediaSessionEventDistributor() }

View File

@ -1,229 +0,0 @@
package org.moire.ultrasonic.filepicker
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Environment
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.recyclerview.widget.RecyclerView
import java.io.File
import java.util.LinkedList
import org.moire.ultrasonic.R
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* Adapter for the RecyclerView which handles listing, navigating and picking files
* @author this implementation is loosely based on the work of Yogesh Sundaresan,
* original license: http://www.apache.org/licenses/LICENSE-2.0
*/
internal class FilePickerAdapter(view: FilePickerView) :
RecyclerView.Adapter<FilePickerAdapter.FileListHolder>() {
private var data: MutableList<FileListItem> = LinkedList()
var defaultDirectory: File = Environment.getExternalStorageDirectory()
var initialDirectory: File = Environment.getExternalStorageDirectory()
lateinit var selectedDirectoryChanged: (String, Boolean) -> Unit
var selectedDirectory: File = defaultDirectory
private set
private var context: Context? = null
private var listerView: FilePickerView? = view
private var isRealDirectory: Boolean = false
private var folderIcon: Drawable? = null
private var upIcon: Drawable? = null
private var sdIcon: Drawable? = null
init {
this.context = view.context
upIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_subdirectory_up)
folderIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_folder)
sdIcon = Util.getDrawableFromAttribute(context, R.attr.filepicker_sd_card)
}
fun start() {
fileLister(initialDirectory)
}
private fun fileLister(currentDirectory: File) {
var fileList = LinkedList<FileListItem>()
val storages: List<File>?
val storagePaths: List<String>?
storages = context!!.getExternalFilesDirs(null).filterNotNull()
storagePaths = storages.map { i -> i.absolutePath }
if (currentDirectory.absolutePath == "/" ||
currentDirectory.absolutePath == "/storage" ||
currentDirectory.absolutePath == "/storage/emulated" ||
currentDirectory.absolutePath == "/mnt"
) {
isRealDirectory = false
fileList = getKitKatStorageItems(storages)
} else {
isRealDirectory = true
val files = currentDirectory.listFiles()
files?.forEach { file ->
if (file.isDirectory) {
fileList.add(FileListItem(file, file.name, folderIcon!!))
}
}
}
data = LinkedList(fileList)
data.sortWith { f1, f2 ->
if (f1.file!!.isDirectory && f2.file!!.isDirectory)
f1.name.compareTo(f2.name, ignoreCase = true)
else if (f1.file!!.isDirectory && !f2.file!!.isDirectory)
-1
else if (!f1.file!!.isDirectory && f2.file!!.isDirectory)
1
else if (!f1.file!!.isDirectory && !f2.file!!.isDirectory)
f1.name.compareTo(f2.name, ignoreCase = true)
else
0
}
selectedDirectory = currentDirectory
selectedDirectoryChanged.invoke(
if (isRealDirectory) selectedDirectory.absolutePath
else context!!.getString(R.string.filepicker_available_drives),
isRealDirectory
)
// Add the "Up" navigation to the list
if (currentDirectory.absolutePath != "/" && isRealDirectory) {
// If we are on KitKat or later, only the default App folder is usable, so we can't
// navigate the SD card. Jump to the root if "Up" is selected.
if (storagePaths.indexOf(currentDirectory.absolutePath) > 0)
data.add(0, FileListItem(File("/"), "..", upIcon!!))
else
data.add(0, FileListItem(selectedDirectory.parentFile!!, "..", upIcon!!))
}
notifyDataSetChanged()
listerView!!.scrollToPosition(0)
}
private fun getKitKatStorageItems(storages: List<File>): LinkedList<FileListItem> {
val fileList = LinkedList<FileListItem>()
if (storages.isNotEmpty()) {
for ((index, file) in storages.withIndex()) {
var path = file.absolutePath
path = path.replace("/Android/data/([a-zA-Z_][.\\w]*)/files".toRegex(), "")
if (index == 0) {
fileList.add(
FileListItem(
File(path),
context!!.getString(R.string.filepicker_internal, path),
sdIcon!!
)
)
} else {
fileList.add(
FileListItem(
file,
context!!.getString(R.string.filepicker_default_app_folder, path),
sdIcon!!
)
)
}
}
}
return fileList
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileListHolder {
return FileListHolder(
LayoutInflater.from(context).inflate(
R.layout.filepicker_item_file_lister, listerView, false
)
)
}
override fun onBindViewHolder(holder: FileListHolder, position: Int) {
val actualFile = data[position]
holder.name.text = actualFile.name
holder.icon.setImageDrawable(actualFile.icon)
}
override fun getItemCount(): Int {
return data.size
}
fun goToDefault() {
fileLister(defaultDirectory)
}
fun createNewFolder() {
val view = View.inflate(context, R.layout.filepicker_dialog_create_folder, null)
val editText = view.findViewById<AppCompatEditText>(R.id.edittext)
val builder = AlertDialog.Builder(context!!)
.setView(view)
.setTitle(context!!.getString(R.string.filepicker_enter_folder_name))
.setPositiveButton(context!!.getString(R.string.filepicker_create)) { _, _ -> }
val dialog = builder.create()
dialog.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val name = editText.text!!.toString()
if (TextUtils.isEmpty(name)) {
Util.toast(context!!, context!!.getString(R.string.filepicker_name_invalid))
} else {
val file = File(selectedDirectory, name)
if (file.exists()) {
Util.toast(context!!, context!!.getString(R.string.filepicker_already_exists))
} else {
dialog.dismiss()
if (file.mkdirs()) {
fileLister(file)
} else {
Util.toast(
context!!,
context!!.getString(R.string.filepicker_create_folder_failed)
)
}
}
}
}
}
internal inner class FileListItem(
fileParameter: File,
nameParameter: String,
iconParameter: Drawable
) {
var file: File? = fileParameter
var name: String = nameParameter
var icon: Drawable? = iconParameter
}
internal inner class FileListHolder(
itemView: View
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
var name: TextView = itemView.findViewById(R.id.name)
var icon: ImageView = itemView.findViewById(R.id.icon)
init {
itemView.findViewById<View>(R.id.layout).setOnClickListener(this)
}
override fun onClick(v: View) {
val clickedFile = data[adapterPosition]
selectedDirectory = clickedFile.file!!
fileLister(clickedFile.file!!)
Timber.d(clickedFile.file!!.absolutePath)
}
}
}

View File

@ -1,116 +0,0 @@
package org.moire.ultrasonic.filepicker
import android.content.Context
import android.content.DialogInterface.BUTTON_NEGATIVE
import android.content.DialogInterface.BUTTON_NEUTRAL
import android.content.DialogInterface.BUTTON_POSITIVE
import android.view.LayoutInflater
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import org.moire.ultrasonic.R
/**
* This dialog can be used to pick a file / folder from the filesystem.
* Currently only supports folders.
* @author this implementation is loosely based on the work of Yogesh Sundaresan,
* original license: http://www.apache.org/licenses/LICENSE-2.0
*/
class FilePickerDialog {
private var alertDialog: AlertDialog? = null
private var filePickerView: FilePickerView? = null
private var onFileSelectedListener: OnFileSelectedListener? = null
private var currentPath: TextView? = null
private var newFolderButton: Button? = null
private constructor(context: Context) {
alertDialog = AlertDialog.Builder(context).create()
initialize(context)
}
private constructor(context: Context, themeResId: Int) {
alertDialog = AlertDialog.Builder(context, themeResId).create()
initialize(context)
}
private fun initialize(context: Context) {
val view = LayoutInflater.from(context).inflate(R.layout.filepicker_dialog_main, null)
alertDialog!!.setView(view)
filePickerView = view.findViewById(R.id.file_list_view)
currentPath = view.findViewById(R.id.current_path)
newFolderButton = view.findViewById(R.id.filepicker_create_folder)
newFolderButton!!.setOnClickListener { filePickerView!!.createNewFolder() }
alertDialog!!.setTitle(context.getString(R.string.filepicker_select_folder))
alertDialog!!.setButton(BUTTON_POSITIVE, context.getString(R.string.filepicker_select)) {
dialogInterface, _ ->
dialogInterface.dismiss()
if (onFileSelectedListener != null)
onFileSelectedListener!!.onFileSelected(
filePickerView!!.selected, filePickerView!!.selected.absolutePath
)
}
alertDialog!!.setButton(BUTTON_NEUTRAL, context.getString(R.string.filepicker_default)) {
_, _ ->
filePickerView!!.goToDefaultDirectory()
}
alertDialog!!.setButton(BUTTON_NEGATIVE, context.getString(R.string.common_cancel)) {
dialogInterface, _ ->
dialogInterface.dismiss()
}
}
/**
* Display the FilePickerDialog
*/
fun show() {
filePickerView!!.start { currentDirectory, isRealPath ->
run {
currentPath?.text = currentDirectory
newFolderButton!!.isEnabled = isRealPath
}
}
alertDialog!!.show()
alertDialog!!.getButton(BUTTON_NEUTRAL).setOnClickListener {
filePickerView!!.goToDefaultDirectory()
}
}
/**
* Listener to know which file/directory is selected
*
* @param onFileSelectedListener Instance of the Listener
*/
fun setOnFileSelectedListener(onFileSelectedListener: OnFileSelectedListener) {
this.onFileSelectedListener = onFileSelectedListener
}
/**
* Set the initial directory to show the list of files in that directory
*
* @param path String denoting to the directory
*/
fun setDefaultDirectory(path: String) {
filePickerView!!.setDefaultDirectory(path)
}
fun setInitialDirectory(path: String) {
filePickerView!!.setInitialDirectory(path)
}
companion object {
/**
* Creates a default instance of FilePickerDialog
*
* @param context Context of the App
* @return Instance of FileListerDialog
*/
fun createFilePickerDialog(context: Context): FilePickerDialog {
return FilePickerDialog(context)
}
}
}

View File

@ -1,67 +0,0 @@
package org.moire.ultrasonic.filepicker
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import java.io.File
/**
* RecyclerView containing the file list of a directory
* @author this implementation is loosely based on the work of Yogesh Sundaresan,
* original license: http://www.apache.org/licenses/LICENSE-2.0
*/
internal class FilePickerView : RecyclerView {
private var adapter: FilePickerAdapter? = null
val selected: File
get() = adapter!!.selectedDirectory
constructor(context: Context) : super(context) {
initialize()
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
initialize()
}
constructor(
context: Context,
attrs: AttributeSet?,
defStyle: Int
) : super(context, attrs, defStyle) {
initialize()
}
private fun initialize() {
layoutManager = LinearLayoutManager(context, VERTICAL, false)
adapter = FilePickerAdapter(this)
}
fun start(selectedDirectoryChangedListener: (String, Boolean) -> Unit) {
setAdapter(adapter)
adapter?.selectedDirectoryChanged = selectedDirectoryChangedListener
adapter!!.start()
}
fun setDefaultDirectory(file: File) {
adapter!!.defaultDirectory = file
}
fun setDefaultDirectory(path: String) {
setDefaultDirectory(File(path))
}
fun setInitialDirectory(path: String) {
adapter!!.initialDirectory = File(path)
}
fun goToDefaultDirectory() {
adapter!!.goToDefault()
}
fun createNewFolder() {
adapter!!.createNewFolder()
}
}

View File

@ -1,7 +0,0 @@
package org.moire.ultrasonic.filepicker
import java.io.File
interface OnFileSelectedListener {
fun onFileSelected(file: File?, path: String?)
}

View File

@ -178,7 +178,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
}
)
.setNegativeButton(getString(R.string.common_cancel)) {
dialogInterface, i ->
dialogInterface, _ ->
dialogInterface.dismiss()
}
.setBottomSpace(DIALOG_PADDING)

View File

@ -1,11 +1,16 @@
package org.moire.ultrasonic.fragment
import android.app.Activity
import android.app.AlertDialog
import android.content.DialogInterface
import android.content.Intent
import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.provider.SearchRecentSuggestions
import android.view.View
import androidx.annotation.StringRes
@ -16,16 +21,13 @@ 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
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.featureflags.Feature
import org.moire.ultrasonic.featureflags.FeatureStorage
import org.moire.ultrasonic.filepicker.FilePickerDialog.Companion.createFilePickerDialog
import org.moire.ultrasonic.filepicker.OnFileSelectedListener
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
import org.moire.ultrasonic.log.FileLoggerTree
import org.moire.ultrasonic.log.FileLoggerTree.Companion.deleteLogFiles
@ -37,11 +39,8 @@ import org.moire.ultrasonic.provider.SearchSuggestionProvider
import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory
import org.moire.ultrasonic.util.FileUtil.ensureDirectoryExistsAndIsReadWritable
import org.moire.ultrasonic.util.FileUtil.ultrasonicDirectory
import org.moire.ultrasonic.util.MediaSessionHandler
import org.moire.ultrasonic.util.PermissionUtil
import org.moire.ultrasonic.util.PermissionUtil.Companion.requestInitialPermission
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Settings.preferences
import org.moire.ultrasonic.util.Settings.shareGreeting
@ -51,6 +50,8 @@ 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
import kotlin.math.ceil
/**
* Shows main app settings.
@ -92,9 +93,6 @@ class SettingsFragment :
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
MediaPlayerController::class.java
)
private val permissionUtil = inject<PermissionUtil>(
PermissionUtil::class.java
)
private val themeChangedEventDistributor = inject<ThemeChangedEventDistributor>(
ThemeChangedEventDistributor::class.java
)
@ -169,6 +167,26 @@ class SettingsFragment :
update()
}
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
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)
setCacheLocation(uri)
}
}
}
override fun onResume() {
super.onResume()
val preferences = preferences
@ -229,29 +247,19 @@ class SettingsFragment :
cacheLocation!!.summary = Settings.cacheLocation
cacheLocation!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
// If the user tries to change the cache location,
// we must first check to see if we have write access.
requestInitialPermission(
requireActivity()
) {
if (it) {
val filePickerDialog = createFilePickerDialog(
requireContext()
)
filePickerDialog.setDefaultDirectory(defaultMusicDirectory.path)
filePickerDialog.setInitialDirectory(cacheLocation!!.summary.toString())
filePickerDialog.setOnFileSelectedListener(object :
OnFileSelectedListener {
override fun onFileSelected(file: File?, path: String?) {
if (path != null) {
Settings.cacheLocation = path
setCacheLocation(path)
}
}
})
filePickerDialog.show()
}
val isDefault = Settings.cacheLocation == defaultMusicDirectory.path
// Choose a directory using the system's file picker.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (!isDefault && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, defaultMusicDirectory.path)
}
intent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
startActivityForResult(intent, SELECT_CACHE_ACTIVITY)
true
}
}
@ -419,19 +427,14 @@ class SettingsFragment :
sendBluetoothAlbumArt!!.isEnabled = enabled
}
private fun setCacheLocation(path: String) {
val dir = File(path)
if (!ensureDirectoryExistsAndIsReadWritable(dir)) {
permissionUtil.value.handlePermissionFailed {
val currentPath = Settings.cacheLocation
cacheLocation!!.summary = currentPath
}
} else {
cacheLocation!!.summary = path
}
private fun setCacheLocation(uri: Uri) {
if (uri.path != null) {
cacheLocation!!.summary = uri.path
Settings.cacheLocation = uri.path!!
// Clear download queue.
mediaPlayerControllerLazy.value.clear()
// Clear download queue.
mediaPlayerControllerLazy.value.clear()
}
}
private fun setDebugLogToFile(writeLog: Boolean) {
@ -471,4 +474,8 @@ class SettingsFragment :
.create().show()
}
}
companion object {
const val SELECT_CACHE_ACTIVITY = 161161
}
}

View File

@ -31,7 +31,7 @@ class BitmapUtils {
if (entry == null) return null
val albumArtFile = FileUtil.getAlbumArtFile(entry)
val bitmap: Bitmap? = null
if (albumArtFile != null && albumArtFile.exists()) {
if (albumArtFile.exists()) {
return getBitmapFromDisk(albumArtFile.path, size, bitmap)
}
return null

View File

@ -260,12 +260,8 @@ class MediaPlayerController(
}
@Synchronized
fun clear() {
clear(true)
}
@Synchronized
fun clear(serialize: Boolean) {
@JvmOverloads
fun clear(serialize: Boolean = true) {
val mediaPlayerService = runningInstance
if (mediaPlayerService != null) {
mediaPlayerService.clear(serialize)

View File

@ -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<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) {
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 {
// 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
}
}
}

View File

@ -11,6 +11,7 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import android.text.TextUtils
import android.util.Pair
import java.io.BufferedWriter
import java.io.File
import java.io.FileInputStream
@ -24,9 +25,10 @@ import java.util.Locale
import java.util.SortedSet
import java.util.TreeSet
import java.util.regex.Pattern
import org.koin.java.KoinJavaComponent
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.app.UApp
import org.moire.ultrasonic.domain.MusicDirectory
import org.moire.ultrasonic.service.MediaPlayerController
import timber.log.Timber
object FileUtil {
@ -43,10 +45,6 @@ object FileUtil {
const val SUFFIX_SMALL = ".jpeg-small"
private const val UNNAMED = "unnamed"
private val permissionUtil = KoinJavaComponent.inject<PermissionUtil>(
PermissionUtil::class.java
)
fun getSongFile(song: MusicDirectory.Entry): File {
val dir = getAlbumDirectory(song)
@ -237,15 +235,13 @@ object FileUtil {
@JvmStatic
val ultrasonicDirectory: File
get() {
@Suppress("DEPRECATION")
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) File(
Environment.getExternalStorageDirectory(),
"Android/data/org.moire.ultrasonic"
) else UApp.applicationContext().getExternalFilesDir(null)!!
}
// After Android M, the location of the files must be queried differently.
// GetExternalFilesDir will always return a directory which Ultrasonic
// can access without any extra privileges.
@JvmStatic
val defaultMusicDirectory: File
get() = getOrCreateDirectory("music")
@ -256,38 +252,39 @@ object FileUtil {
val path = Settings.cacheLocation
val dir = File(path)
val hasAccess = ensureDirectoryExistsAndIsReadWritable(dir)
if (!hasAccess) permissionUtil.value.handlePermissionFailed(null)
return if (hasAccess) dir else defaultMusicDirectory
return if (hasAccess.second) dir else defaultMusicDirectory
}
@JvmStatic
@Suppress("ReturnCount")
fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Boolean {
fun ensureDirectoryExistsAndIsReadWritable(dir: File?): Pair<Boolean, Boolean> {
val noAccess = Pair(false, false)
if (dir == null) {
return false
return noAccess
}
if (dir.exists()) {
if (!dir.isDirectory) {
Timber.w("%s exists but is not a directory.", dir)
return false
return noAccess
}
} else {
if (dir.mkdirs()) {
Timber.i("Created directory %s", dir)
} else {
Timber.w("Failed to create directory %s", dir)
return false
return noAccess
}
}
if (!dir.canRead()) {
Timber.w("No read permission for directory %s", dir)
return false
return noAccess
}
if (!dir.canWrite()) {
Timber.w("No write permission for directory %s", dir)
return false
return Pair(true, false)
}
return true
return Pair(true, true)
}
/**

View File

@ -1,255 +0,0 @@
package org.moire.ultrasonic.util
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.core.content.PermissionChecker
import com.karumi.dexter.Dexter
import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.PermissionToken
import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
import org.moire.ultrasonic.R
import org.moire.ultrasonic.util.FileUtil.defaultMusicDirectory
import timber.log.Timber
/**
* Contains static functions for Permission handling
*/
class PermissionUtil(private val applicationContext: Context) {
private var activityContext: Context? = null
fun onForegroundApplicationStarted(context: Context?) {
activityContext = context
}
fun onForegroundApplicationStopped() {
activityContext = null
}
/**
* This function can be used to handle file access permission failures.
*
* It will check if the failure is because the necessary permissions aren't available,
* and it will request them, if necessary.
*
* @param callback callback function to execute after the permission request is finished
*/
fun handlePermissionFailed(callback: ((Boolean) -> Unit)?) {
val currentCachePath = Settings.cacheLocation
val defaultCachePath = defaultMusicDirectory.path
// Ultrasonic can do nothing about this error when the Music Directory is already set to the default.
if (currentCachePath.compareTo(defaultCachePath) == 0) return
if (PermissionChecker.checkSelfPermission(
applicationContext,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PermissionChecker.PERMISSION_DENIED ||
PermissionChecker.checkSelfPermission(
applicationContext,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PermissionChecker.PERMISSION_DENIED
) {
// While we request permission, the Music Directory is temporarily reset to its default location
Settings.cacheLocation = defaultMusicDirectory.path
// If the application is not running, we can't notify the user
if (activityContext == null) return
requestFailedPermission(activityContext!!, currentCachePath, callback)
} else {
Settings.cacheLocation = defaultMusicDirectory.path
// If the application is not running, we can't notify the user
if (activityContext != null) {
Handler(Looper.getMainLooper()).post {
showWarning(
activityContext!!,
activityContext!!.getString(R.string.permissions_message_box_title),
activityContext!!.getString(R.string.permissions_access_error),
null
)
}
}
callback?.invoke(false)
}
}
companion object {
/**
* This function requests permission to access the filesystem.
* It can be used to request the permission initially, e.g. when the user decides to
* use a non-default folder for the cache
* @param context context for the operation
* @param callback callback function to execute after the permission request is finished
*/
@JvmStatic
fun requestInitialPermission(
context: Context,
callback: ((Boolean) -> Unit)?
) {
Dexter.withContext(context)
.withPermissions(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
)
.withListener(object : MultiplePermissionsListener {
override fun onPermissionsChecked(report: MultiplePermissionsReport) {
if (report.areAllPermissionsGranted()) {
Timber.i("R/W permission granted for external storage")
callback?.invoke(true)
return
}
if (report.isAnyPermissionPermanentlyDenied) {
Timber.i(
"R/W permission is permanently denied for external storage"
)
showSettingsDialog(context)
callback?.invoke(false)
return
}
Timber.i("R/W permission is missing for external storage")
showWarning(
context,
context.getString(R.string.permissions_message_box_title),
context.getString(R.string.permissions_rationale_description_initial),
null
)
callback?.invoke(false)
}
override fun onPermissionRationaleShouldBeShown(
permissions: List<PermissionRequest>,
token: PermissionToken
) {
showWarning(
context,
context.getString(R.string.permissions_rationale_title),
context.getString(R.string.permissions_rationale_description_initial),
token
)
}
}).withErrorListener { error ->
Timber.e(
"An error has occurred during checking permissions with Dexter: %s",
error.toString()
)
}
.check()
}
private fun requestFailedPermission(
context: Context,
cacheLocation: String?,
callback: ((Boolean) -> Unit)?
) {
Dexter.withContext(context)
.withPermissions(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
)
.withListener(object : MultiplePermissionsListener {
override fun onPermissionsChecked(report: MultiplePermissionsReport) {
if (report.areAllPermissionsGranted()) {
Timber.i("Permission granted to use cache directory %s", cacheLocation)
if (cacheLocation != null) {
Settings.cacheLocation = cacheLocation
}
callback?.invoke(true)
return
}
if (report.isAnyPermissionPermanentlyDenied) {
Timber.i(
"R/W permission for cache directory %s was permanently denied",
cacheLocation
)
showSettingsDialog(context)
callback?.invoke(false)
return
}
Timber.i(
"At least one permission is missing to use directory %s ",
cacheLocation
)
Settings.cacheLocation = defaultMusicDirectory.path
showWarning(
context, context.getString(R.string.permissions_message_box_title),
context.getString(R.string.permissions_permission_missing), null
)
callback?.invoke(false)
}
override fun onPermissionRationaleShouldBeShown(
permissions: List<PermissionRequest>,
token: PermissionToken
) {
showWarning(
context,
context.getString(R.string.permissions_rationale_title),
context.getString(R.string.permissions_rationale_description_failed),
token
)
}
}).withErrorListener { error ->
Timber.e(
"An error has occurred during checking permissions with Dexter: %s",
error.toString()
)
}
.check()
}
private fun showSettingsDialog(ctx: Context) {
val builder = Util.createDialog(
context = ctx,
android.R.drawable.ic_dialog_alert,
ctx.getString(R.string.permissions_permanent_denial_title),
ctx.getString(R.string.permissions_permanent_denial_description)
)
builder.setPositiveButton(ctx.getString(R.string.permissions_open_settings)) {
dialog, _ ->
dialog.cancel()
openSettings(ctx)
}
builder.setNegativeButton(ctx.getString(R.string.common_cancel)) { dialog, _ ->
Settings.cacheLocation = defaultMusicDirectory.path
dialog.cancel()
}
builder.show()
}
private fun openSettings(context: Context) {
val i = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
i.addCategory(Intent.CATEGORY_DEFAULT)
i.data = Uri.parse("package:" + context.packageName)
context.startActivity(i)
}
private fun showWarning(
context: Context,
title: String,
text: String,
token: PermissionToken?
) {
val builder = Util.createDialog(
context = context,
android.R.drawable.ic_dialog_alert,
title,
text
)
builder.setPositiveButton(context.getString(R.string.common_ok)) { dialog, _ ->
dialog.cancel()
token?.continuePermissionRequest()
}
builder.show()
}
}
}

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/edittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:orientation="vertical">
<TextView
a:id="@+id/current_path"
a:layout_width="match_parent"
a:layout_height="wrap_content"
a:layout_alignParentTop="true"
a:layout_margin="20dp"
a:gravity="center_vertical"
a:text=""
a:textSize="18sp" />
<org.moire.ultrasonic.filepicker.FilePickerView
a:id="@+id/file_list_view"
a:layout_width="match_parent"
a:layout_height="match_parent"
a:layout_below="@id/current_path"
a:layout_above="@id/filepicker_create_folder"
a:scrollbars="vertical" />
<Button
a:id="@+id/filepicker_create_folder"
style="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:layout_alignParentBottom="true"
a:layout_marginStart="10dp"
a:layout_marginTop="10dp"
a:layout_marginEnd="10dp"
a:layout_marginBottom="10dp"
a:drawableStart="?attr/filepicker_create_new_folder"
a:drawableLeft="?attr/filepicker_create_new_folder"
a:drawablePadding="10dp"
a:gravity="start|center_vertical"
a:text="@string/filepicker.create_folder" />
</RelativeLayout>

View File

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:a="http://schemas.android.com/apk/res/android"
a:id="@+id/layout"
a:padding="5dp"
a:layout_width="match_parent"
a:layout_height="wrap_content"
a:minHeight="?android:attr/listPreferredItemHeight"
a:orientation="horizontal">
<ImageView
a:id="@+id/icon"
a:layout_width="36dp"
a:layout_height="36dp"
a:layout_gravity="center_vertical" />
<TextView
a:id="@+id/name"
a:layout_width="wrap_content"
a:layout_height="wrap_content"
a:layout_gravity="center_vertical"
a:layout_marginStart="20dp"
a:gravity="center_vertical"
a:text=""
a:textSize="18sp" />
</LinearLayout>

View File

@ -380,28 +380,6 @@
<string name="settings.debug.log_keep">Zachovat soubory</string>
<string name="settings.debug.log_delete">Smazat soubory</string>
<string name="settings.debug.log_deleted">Smazat soubory logů.</string>
<string name="permissions.access_error">Ultrasonic nemá přístup k odkládacím souborům hudby. Umístění odkládacího adresáře bylo změněno na výchozí hodnotu.</string>
<string name="permissions.message_box_title">Varování</string>
<string name="permissions.permission_missing">Ultrasonic vyžaduje práva čtení/zápisu do hudebního odkládacího adresáře. Umístění odkládacího adresáře bylo změněno na výchozí hodnotu.</string>
<string name="permissions.rationale_title">Vyžádání oprávnění</string>
<string name="permissions.rationale_description_failed">Ultrasonic vyžaduje práva čtení/zápisu do hudebního odkládacího adresáře.\nPovolte aplikaci Ultrasonic přístup do souborového systému.</string>
<string name="permissions.permanent_denial_title">Oprávnění dlouhodobě zamítnuto</string>
<string name="permissions.permanent_denial_description">Ultrasonic vyžaduje práva čtení/zápisu do hudebního odkládacího adresáře. Tyto zle povolit v nastavení aplikace. Pokud tuto žádost zamítnete, bude použit výchozí odkládací adresář.</string>
<string name="permissions.open_settings">Otevřít nastavení</string>
<string name="permissions.rationale_description_initial">Pro změnu umístění odkládacího adresáře potřebuje Ultrasonic práva čtení/zápisu do souborového systému.</string>
<string name="filepicker.select_folder">Vybrat adresář</string>
<string name="filepicker.create_folder">Vytvořit nový adresář</string>
<string name="filepicker.create_folder_failed">Selhání vytvoření nového adresáře</string>
<string name="filepicker.internal">%1$s (interní)</string>
<string name="filepicker.default_app_folder">Výchozí adresář aplikace na %1$s (externí)</string>
<string name="filepicker.enter_folder_name">Zadat jméno adresáře</string>
<string name="filepicker.create">Vytvořit</string>
<string name="filepicker.name_invalid">Zadejte platné jméno adresáře</string>
<string name="filepicker.already_exists">Tento adresář již existuje.\nZadejte prosím jiné jméno adresáře</string>
<string name="filepicker.select">Vybrat</string>
<string name="filepicker.default">Použít výchozí</string>
<string name="filepicker.available_drives">Dostupná úložiště:</string>
<string name="server_selector.label">Nakonfigurované servery</string>
<string name="server_selector.delete_confirmation">Opravdu chcete odebrat server?</string>

View File

@ -405,28 +405,7 @@
<string name="settings.debug.log_deleted">Archivos de registro eliminados.</string>
<string name="notification.downloading_title">Descargando medios en segundo plano…</string>
<string name="permissions.access_error">Ultrasonic no puede acceder a la caché de los ficheros de música. La ubicación de la caché se restableció a la ruta predeterminada.</string>
<string name="permissions.message_box_title">Atención</string>
<string name="permissions.permission_missing">Ultrasonic necesita permiso de lectura / escritura para el directorio caché de música. El directorio caché se restableció a su valor predeterminado.</string>
<string name="permissions.rationale_title">Solicitud de permisos</string>
<string name="permissions.rationale_description_failed">Ultrasonic necesita permiso de lectura / escritura para el directorio caché de música. Por favor permite a Ultrasonic acceder al sistema de ficheros.</string>
<string name="permissions.permanent_denial_title">Permisos denegados permanentemente</string>
<string name="permissions.permanent_denial_description">Ultrasonic necesita acceso de lectura / escritura a la ubicación de la caché. Puedes otorgarlos en la configuración de la aplicación. Si rechazas esta solicitud, la ubicación de la caché se restablecerá a su valor predeterminado.</string>
<string name="permissions.open_settings">Abrir configuración</string>
<string name="permissions.rationale_description_initial">Para poder cambiar la ubicación de la caché, Ultrasonic necesita permiso de lectura / escritura en el sistema de archivos.</string>
<string name="filepicker.select_folder">Selecciona una carpeta</string>
<string name="filepicker.create_folder">Crear nueva carpeta</string>
<string name="filepicker.create_folder_failed">Fallo al crear una nueva carpeta</string>
<string name="filepicker.internal">%1$s (Almacenamiento interno)</string>
<string name="filepicker.default_app_folder">Carpeta predeterminada de la aplicación en %1$s (Almacenamiento externo)</string>
<string name="filepicker.enter_folder_name">Introduce el nombre de la carpeta</string>
<string name="filepicker.create">Crear</string>
<string name="filepicker.name_invalid">Por favor introduce un nombre de carpeta válido</string>
<string name="filepicker.already_exists">Esta carpeta ya existe.\nPor favor proporciona otro nombre para la carpeta</string>
<string name="filepicker.select">Seleccionar</string>
<string name="filepicker.default">Usar predeterminado</string>
<string name="filepicker.available_drives">Unidades disponibles:</string>
<string name="server_selector.label">Servidores configurados</string>
<string name="server_selector.delete_confirmation">¿Seguro que deseas borrar el servidor?</string>

View File

@ -394,28 +394,7 @@
<string name="settings.debug.log_keep">Conserver les fichiers</string>
<string name="settings.debug.log_delete">Supprimer les fichiers</string>
<string name="settings.debug.log_deleted">Fichiers de log supprimés</string>
<string name="permissions.access_error">Ultrasonic ne peut pas accéder au cache. Le répertoire de cache a été réinitialisé sur le chemin par défaut.</string>
<string name="permissions.message_box_title">Attention</string>
<string name="permissions.permission_missing">Ultrasonic requiert les droits de lecture/écriture sur le répertoire de cache. Le répertoire de cache a été réinitialisé à la valeur par défaut.</string>
<string name="permissions.rationale_title">Demande de permission</string>
<string name="permissions.rationale_description_failed">Ultrasonic requiert les droits de lecture/écriture sur le répertoire de cache. Veuillez autoriser Ultrasonic à accéder au système de fichiers.</string>
<string name="permissions.permanent_denial_title">Permissions refusées de manière permanente</string>
<string name="permissions.permanent_denial_description">Ultrasonic requiert les droits de lecture/écriture sur le répertoire de cache. Vous pouvez les activer dans les paramètres Android de lapplication. Si vous rejetez cette permission, le répertoire par défaut sera utilisé pour le cache.</string>
<string name="permissions.open_settings">Ouvrir les paramètres</string>
<string name="permissions.rationale_description_initial">Afin de pouvoir modifier le répertoire de cache, Ultrasonic requiert les droits de lecture/écriture sur le système de fichiers.</string>
<string name="filepicker.select_folder">Sélectionner un dossier</string>
<string name="filepicker.create_folder">Créer un dossier</string>
<string name="filepicker.create_folder_failed">Impossible de créer un dossier</string>
<string name="filepicker.internal">%1$s (Interne)</string>
<string name="filepicker.default_app_folder">Répertoire par défaut de lapplication : %1$s (Mémoire externe)</string>
<string name="filepicker.enter_folder_name">Saisir le nom du dossier</string>
<string name="filepicker.create">Créer</string>
<string name="filepicker.name_invalid">Veuillez entrer un nom de dossier valide</string>
<string name="filepicker.already_exists">Ce dossier existe déjà.\nVeuillez donner un autre nom</string>
<string name="filepicker.select">Sélectionner</string>
<string name="filepicker.default">Utiliser la valeur par défaut</string>
<string name="filepicker.available_drives">Emplacements de stockage disponibles:</string>
<string name="server_selector.label">Serveurs configurés</string>
<string name="server_selector.delete_confirmation">Êtes-vous sûr de vouloir supprimer ce serveur?</string>

View File

@ -392,28 +392,7 @@
<string name="settings.debug.log_keep">Fájlok megtartása</string>
<string name="settings.debug.log_delete">Fájlok törlése</string>
<string name="settings.debug.log_deleted">Naplófájlok törölve.</string>
<string name="permissions.access_error">Az Ultrasonic nem éri el a zenei fájl gyorsítótárat. A gyorsítótár helye visszaállítva az alapbeállításra.</string>
<string name="permissions.message_box_title">Figyelem</string>
<string name="permissions.permission_missing">Az Ultrasonic működéséhez írás/olvasás hozzáférés szükséges a zenei fájl gyorsítótárhoz. A gyorsítótár helye visszaállítva az alapbeállításra.</string>
<string name="permissions.rationale_title">Jogosultság kérés</string>
<string name="permissions.rationale_description_failed">Az Ultrasonic működéséhez írás/olvasás hozzáférés szükséges a zenei fájl gyorsítótárhoz.\nKérlek, adj hozzáférést az Ultrasonicnak a fájlrendszerhez.</string>
<string name="permissions.permanent_denial_title">A jogosultság visszautasítva</string>
<string name="permissions.permanent_denial_description">Az Ultrasonic működéséhez írás/olvasás hozzáférés szükséges a zenei fájl gyorsítótárhoz. Ez a beállítás az alkalmazásbeállítások között módosítható. Ha elutasítod ezt a kérést, a gyorsítótár helye visszaáll az alapbeállításra.</string>
<string name="permissions.open_settings">Beállítások megnyitása</string>
<string name="permissions.rationale_description_initial">A gyorsítótár helyének megváltoztatásához az Ultrasonicnak írás/olvasás hozzáférésre van szüksége a fájlrendszerhez.</string>
<string name="filepicker.select_folder">Mappa kiválasztása</string>
<string name="filepicker.create_folder">Új mappa létrehozása</string>
<string name="filepicker.create_folder_failed">Az új mappa létrehozása nem sikerült</string>
<string name="filepicker.internal">%1$s (Belső)</string>
<string name="filepicker.default_app_folder">Alapértelmezett alkalmazásmappa a %1$s tárolón (Külső)</string>
<string name="filepicker.enter_folder_name">A mappa neve</string>
<string name="filepicker.create">Létrehoz</string>
<string name="filepicker.name_invalid">Kérjük, adj meg egy érvényes mappanevet</string>
<string name="filepicker.already_exists">Ilyen nevű mappa már létezik.\nKérjük, adj meg más nevet.</string>
<string name="filepicker.select">Választ</string>
<string name="filepicker.default">Alapért.</string>
<string name="filepicker.available_drives">Elérhető tárolók:</string>
<string name="server_selector.label">Beállított szerverek</string>
<string name="server_selector.delete_confirmation">Biztosan törölni szeretnéd a szervert?</string>

View File

@ -405,28 +405,7 @@
<string name="settings.debug.log_deleted">De logboeken zijn verwijderd.</string>
<string name="notification.downloading_title">Bezig met downloaden van media op de achtergrond…</string>
<string name="permissions.access_error">Ultrasonic heeft geen toegang tot de muziekcache. De cachelocatie is teruggezet op de standaardlocatie.</string>
<string name="permissions.message_box_title">Waarschuwing</string>
<string name="permissions.permission_missing">Ultrasonic heeft lees- en schrijfrechten nodig op de muziekcachemap. De cachemap is teruggezet op de standaardmap.</string>
<string name="permissions.rationale_title">Rechtenverzoek</string>
<string name="permissions.rationale_description_failed">Ultrasonic heeft lees- en schrijfrechten nodig op de muziekcachemap.\nGeef Ultrasonic toegang tot het bestandssysteem.</string>
<string name="permissions.permanent_denial_title">De rechten zijn afgewezen</string>
<string name="permissions.permanent_denial_description">Ultrasonic heeft lees- en schrijfrechten nodig op de cachemap. Je kunt deze rechten verlenen in de appinstellingen. Als je dit verzoek afwijst, wordt de standaardmap gebruikt.</string>
<string name="permissions.open_settings">Instellingen openen</string>
<string name="permissions.rationale_description_initial">Ultrasonic heeft lees- en schrijfrechten nodig op het bestandssysteem om de cachemap te kunnen wijzigen.</string>
<string name="filepicker.select_folder">Kies een map</string>
<string name="filepicker.create_folder">Nieuwe map maken</string>
<string name="filepicker.create_folder_failed">De map kan niet worden aangemaakt</string>
<string name="filepicker.internal">%1$s (intern)</string>
<string name="filepicker.default_app_folder">De standaard appmap op %1$s (extern)</string>
<string name="filepicker.enter_folder_name">Voer een mapnaam in</string>
<string name="filepicker.create">Maken</string>
<string name="filepicker.name_invalid">Voer een geldige mapnaam in</string>
<string name="filepicker.already_exists">Deze map bestaat al.\nGeef de map een andere naam.</string>
<string name="filepicker.select">Kiezen</string>
<string name="filepicker.default">Standaard gebruiken</string>
<string name="filepicker.available_drives">Beschikbare schijven:</string>
<string name="server_selector.label">Ingestelde servers:</string>
<string name="server_selector.delete_confirmation">Weet je zeker dat je deze server wilt verwijderen?</string>

View File

@ -398,28 +398,7 @@
<string name="settings.debug.log_deleted">Arquivos de log excluídos.</string>
<string name="notification.downloading_title">Baixado mídia em segundo plano…</string>
<string name="permissions.access_error">O Ultrasonic não pôde acessar o cache dos arquivos de música. O local do cache foi redefinido para o caminho padrão.</string>
<string name="permissions.message_box_title">Atenção</string>
<string name="permissions.permission_missing">O Ultrasonic precisa de permissão de leitura/escrita no diretório de cache das músicas. O diretório de cache foi redefinido para seu valor padrão.</string>
<string name="permissions.rationale_title">Pedido de permissão</string>
<string name="permissions.rationale_description_failed">O Ultrasonic precisa de permissão de leitura/escrita no diretório de cache das músicas.\nPermita que o Ultrasonic acesse o sistema de arquivos.</string>
<string name="permissions.permanent_denial_title">Permissões negadas permanentemente</string>
<string name="permissions.permanent_denial_description">O Ultrasonic precisa de acesso de leitura/escrita no local do cache. Você pode concedê-los nas configurações do aplicativo. Se você rejeitar esta solicitação, a pasta padrão será usada como local do cache.</string>
<string name="permissions.open_settings">Abrir configurações</string>
<string name="permissions.rationale_description_initial">Para poder alterar a localização do cache, o Ultrasonic precisa de permissão de leitura/escrita no sistema de arquivos.</string>
<string name="filepicker.select_folder">Selecionar uma pasta</string>
<string name="filepicker.create_folder">Criar uma nova pasta</string>
<string name="filepicker.create_folder_failed">Erro ao criar a nova pasta</string>
<string name="filepicker.internal">%1$s (Interno)</string>
<string name="filepicker.default_app_folder">Pasta padrão do aplicativo %1$s (Externo)</string>
<string name="filepicker.enter_folder_name">Digite o nome da pasta</string>
<string name="filepicker.create">Criar</string>
<string name="filepicker.name_invalid">Digite um nome válido para a pasta</string>
<string name="filepicker.already_exists">Esta pasta já existe.\nDigite outro nome para a pasta</string>
<string name="filepicker.select">Selecionar</string>
<string name="filepicker.default">Usar o padrão</string>
<string name="filepicker.available_drives">Unidades disponíveis:</string>
<string name="server_selector.label">Servidores Configurados</string>
<string name="server_selector.delete_confirmation">Quer realmente excluir o servidor?</string>

View File

@ -394,28 +394,7 @@
<string name="settings.debug.log_keep">Сохранить файлы</string>
<string name="settings.debug.log_delete">Удалить файлы</string>
<string name="settings.debug.log_deleted">Удаленные файлы журналов.</string>
<string name="permissions.access_error">Ultrasonic не может получить доступ к кэшу музыкальных файлов. Местоположение кэша было сброшено на путь по умолчанию.</string>
<string name="permissions.message_box_title">Внимание</string>
<string name="permissions.permission_missing">Ultrasonic требуется разрешение на чтение/запись в директории музыкального кэша. Каталог кэша был сброшен на значение по умолчанию.</string>
<string name="permissions.rationale_title">Запрос разрешения</string>
<string name="permissions.rationale_description_failed">Ultrasonic требуется разрешение на чтение/запись в директории музыкального кэша.\nПожалуйста, разрешите Ultrasonic доступ к файловой системе.</string>
<string name="permissions.permanent_denial_title">Разрешениях навсегда отклонены</string>
<string name="permissions.permanent_denial_description">Ultrasonic необходим доступ на чтение/запись к местоположению кэша. Вы можете предоставить их в настройках приложения. Если вы отклоните этот запрос, в качестве местоположения кэша будет использоваться папка по умолчанию.</string>
<string name="permissions.open_settings">Открыть настройки</string>
<string name="permissions.rationale_description_initial">Чтобы иметь возможность изменять местоположение кэша, Ultrasonic необходимо разрешение на чтение/запись в файловой системе.</string>
<string name="filepicker.select_folder">Выберите папку</string>
<string name="filepicker.create_folder">Создать новую папку</string>
<string name="filepicker.create_folder_failed">Не удалось создать новую папку</string>
<string name="filepicker.internal">%1$s(Внутренний) </string>
<string name="filepicker.default_app_folder">Папка приложения по умолчанию %1$s (Внешняя)</string>
<string name="filepicker.enter_folder_name">Введите имя папки</string>
<string name="filepicker.create">Создать</string>
<string name="filepicker.name_invalid">Пожалуйста, введите правильное имя папки</string>
<string name="filepicker.already_exists">Эта папка уже существует.\nПожалуйста, укажите другое имя для папки</string>
<string name="filepicker.select">Выбрать</string>
<string name="filepicker.default">Использовать по умолчанию</string>
<string name="filepicker.available_drives">Доступные диски:</string>
<string name="server_selector.label">Настроенные серверы</string>
<string name="server_selector.delete_confirmation">Вы уверены, что хотите удалить сервер?</string>

View File

@ -394,27 +394,7 @@
<string name="settings.debug.log_deleted">删除日志文件</string>
<string name="notification.downloading_title">在后台下载媒体…</string>
<string name="permissions.access_error">Ultrasonic 无法访问音乐文件缓存,缓存位置已重置为默认路径。</string>
<string name="permissions.message_box_title">警告</string>
<string name="permissions.permission_missing">Ultrasonic 需要对音乐缓存目录的读/写权限,缓存位置已重置为默认路径。</string>
<string name="permissions.rationale_title">需要权限</string>
<string name="permissions.rationale_description_failed">Ultrasonic 需要对音乐缓存目录具有读/写权限。\n请允许 Ultrasonic 访问文件系统。</string>
<string name="permissions.permanent_denial_description">Ultrasonic 需要对音乐缓存目录具有读/写权限。您可以在应用程序设置中授予该权限,否则将以默认路径作为缓存目录。</string>
<string name="permissions.open_settings">打开设置</string>
<string name="permissions.rationale_description_initial">为了更改缓存位置Ultrasonic 需要对文件系统具有读/写权限。</string>
<string name="filepicker.select_folder">选择文件夹</string>
<string name="filepicker.create_folder">创建文件夹</string>
<string name="filepicker.create_folder_failed">无法创建文件夹</string>
<string name="filepicker.internal">%1$s (内置)</string>
<string name="filepicker.default_app_folder">默认应用文件夹 %1$s (外置)</string>
<string name="filepicker.enter_folder_name">输入文件夹名称</string>
<string name="filepicker.create">创建</string>
<string name="filepicker.name_invalid">请输入一个有效的文件夹名称</string>
<string name="filepicker.already_exists">该文件夹已存在。\n请为该文件夹提供另一个名称</string>
<string name="filepicker.select">选择</string>
<string name="filepicker.default">使用默认值</string>
<string name="filepicker.available_drives">可用驱动器:</string>
<string name="server_selector.label">配置服务器</string>
<string name="server_selector.delete_confirmation">您确定要删除此服务器吗?</string>

View File

@ -411,29 +411,6 @@
<string name="settings.debug.log_deleted">Deleted log files.</string>
<string name="notification.downloading_title">Downloading media in the background…</string>
<string name="permissions.access_error">Ultrasonic can\'t access the music file cache. Cache location was reset to the default path.</string>
<string name="permissions.message_box_title">Warning</string>
<string name="permissions.permission_missing">Ultrasonic needs read/write permission to the music cache directory. Cache directory was reset to its default value.</string>
<string name="permissions.rationale_title">Permission request</string>
<string name="permissions.rationale_description_failed">Ultrasonic needs read/write permission to the music cache directory.\nPlease allow Ultrasonic to access the filesystem.</string>
<string name="permissions.permanent_denial_title">Permissions permanently denied</string>
<string name="permissions.permanent_denial_description">Ultrasonic needs Read/Write access to the cache location. You can grant them in app settings. If you reject this request, the default folder will be used as the cache location.</string>
<string name="permissions.open_settings">Open settings</string>
<string name="permissions.rationale_description_initial">To be able to change the cache location, Ultrasonic needs read/write permission to the filesystem.</string>
<string name="filepicker.select_folder">Select a folder</string>
<string name="filepicker.create_folder">Create new folder</string>
<string name="filepicker.create_folder_failed">Failed to create new folder</string>
<string name="filepicker.internal">%1$s (Internal)</string>
<string name="filepicker.default_app_folder">Default app folder on %1$s (External)</string>
<string name="filepicker.enter_folder_name">Enter the folder name</string>
<string name="filepicker.create">Create</string>
<string name="filepicker.name_invalid">Please enter a valid folder name</string>
<string name="filepicker.already_exists">This folder already exists.\nPlease provide another name for the folder</string>
<string name="filepicker.select">Select</string>
<string name="filepicker.default">Use default</string>
<string name="filepicker.available_drives">Available drives:</string>
<string name="server_selector.label">Configured servers</string>
<string name="server_selector.delete_confirmation">Are you sure you want to delete the server?</string>
<string name="server_editor.label">Editing server</string>