API 30
This commit is contained in:
parent
7ed91db250
commit
050161bbb0
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -3,10 +3,7 @@
|
|||
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !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() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && ( 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.<no name provided>$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 > %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<ServerSetting>, private val model: ServerSettingsModel, private val activeServerProvider: ActiveServerProvider, private val manageMode: Boolean, private val serverDeletedCallback: ((Int) -> Unit), private val serverEditRequestedCallback: ((Int) -> 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.<no name provided>$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>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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() }
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package org.moire.ultrasonic.filepicker
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface OnFileSelectedListener {
|
||||
fun onFileSelected(file: File?, path: String?)
|
||||
}
|
|
@ -178,7 +178,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
|
|||
}
|
||||
)
|
||||
.setNegativeButton(getString(R.string.common_cancel)) {
|
||||
dialogInterface, i ->
|
||||
dialogInterface, _ ->
|
||||
dialogInterface.dismiss()
|
||||
}
|
||||
.setBottomSpace(DIALOG_PADDING)
|
||||
|
|
|
@ -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,20 +427,15 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDebugLogToFile(writeLog: Boolean) {
|
||||
if (writeLog) {
|
||||
|
@ -471,4 +474,8 @@ class SettingsFragment :
|
|||
.create().show()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SELECT_CACHE_ACTIVITY = 161161
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 l’application. 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 l’application : %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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue