From 050161bbb01741a9053a29301e7270da37bbd3fc Mon Sep 17 00:00:00 2001 From: tzugen Date: Mon, 1 Nov 2021 17:07:18 +0100 Subject: [PATCH] API 30 --- .circleci/config.yml | 2 +- dependencies.gradle | 6 +- detekt-baseline.xml | 10 - detekt-config.yml | 2 +- ultrasonic/build.gradle | 1 - ultrasonic/lint-baseline.xml | 630 ++++++------------ ultrasonic/src/main/AndroidManifest.xml | 5 +- .../fragment/BookmarksFragment.java | 6 +- .../moire/ultrasonic/util/CacheCleaner.java | 300 --------- .../java/org/moire/ultrasonic/util/Pair.java | 47 -- .../ultrasonic/activity/NavigationActivity.kt | 9 +- .../moire/ultrasonic/di/ApplicationModule.kt | 2 - .../filepicker/FilePickerAdapter.kt | 229 ------- .../ultrasonic/filepicker/FilePickerDialog.kt | 116 ---- .../ultrasonic/filepicker/FilePickerView.kt | 67 -- .../filepicker/OnFileSelectedListener.kt | 7 - .../ultrasonic/fragment/EditServerFragment.kt | 2 +- .../ultrasonic/fragment/SettingsFragment.kt | 95 +-- .../ultrasonic/imageloader/BitmapUtils.kt | 2 +- .../service/MediaPlayerController.kt | 8 +- .../org/moire/ultrasonic/util/CacheCleaner.kt | 240 +++++++ .../org/moire/ultrasonic/util/FileUtil.kt | 31 +- .../moire/ultrasonic/util/PermissionUtil.kt | 255 ------- .../filepicker_dialog_create_folder.xml | 21 - .../res/layout/filepicker_dialog_main.xml | 41 -- .../layout/filepicker_item_file_lister.xml | 25 - ultrasonic/src/main/res/values-cs/strings.xml | 22 - ultrasonic/src/main/res/values-es/strings.xml | 21 - ultrasonic/src/main/res/values-fr/strings.xml | 21 - ultrasonic/src/main/res/values-hu/strings.xml | 21 - ultrasonic/src/main/res/values-nl/strings.xml | 21 - .../src/main/res/values-pt-rBR/strings.xml | 21 - ultrasonic/src/main/res/values-ru/strings.xml | 21 - .../src/main/res/values-zh-rCN/strings.xml | 20 - ultrasonic/src/main/res/values/strings.xml | 23 - 35 files changed, 531 insertions(+), 1819 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/Pair.java delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/PermissionUtil.kt delete mode 100644 ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml delete mode 100644 ultrasonic/src/main/res/layout/filepicker_dialog_main.xml delete mode 100644 ultrasonic/src/main/res/layout/filepicker_item_file_lister.xml diff --git a/.circleci/config.yml b/.circleci/config.yml index 24fe852b..ef2b3d17 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/dependencies.gradle b/dependencies.gradle index a18b5d87..301e7193 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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", diff --git a/detekt-baseline.xml b/detekt-baseline.xml index a85a67ab..8adb941d 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -3,10 +3,7 @@ ComplexCondition:DownloadHandler.kt$DownloadHandler.<no name provided>$!append && !playNext && !unpin && !background - ComplexCondition:FilePickerAdapter.kt$FilePickerAdapter$currentDirectory.absolutePath == "/" || currentDirectory.absolutePath == "/storage" || currentDirectory.absolutePath == "/storage/emulated" || currentDirectory.absolutePath == "/mnt" - ComplexCondition:LocalMediaPlayer.kt$LocalMediaPlayer$Util.getGaplessPlaybackPreference() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && ( playerState === PlayerState.STARTED || playerState === PlayerState.PAUSED ) ComplexMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() - ComplexMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File) ComplexMethod:SongView.kt$SongView$fun setSong(song: MusicDirectory.Entry, checkable: Boolean, draggable: Boolean) ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun enableButtons() ComplexMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) @@ -21,14 +18,12 @@ ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$String.format("BufferTask (%s)", downloadFile) ImplicitDefaultLocale:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$String.format("CheckCompletionTask (%s)", downloadFile) ImplicitDefaultLocale:ShareHandler.kt$ShareHandler$String.format("%d:%s", timeSpanAmount, timeSpanType) - ImplicitDefaultLocale:ShareHandler.kt$ShareHandler.<no name provided>$String.format("%s\n\n%s", Util.getShareGreeting(), result.url) ImplicitDefaultLocale:SongView.kt$SongView$String.format("%02d.", trackNumber) ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s ", bitRate) ImplicitDefaultLocale:SongView.kt$SongView$String.format("%s > %s", suffix, transcodedSuffix) LargeClass:TrackCollectionFragment.kt$TrackCollectionFragment : Fragment LongMethod:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() LongMethod:EditServerFragment.kt$EditServerFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?) - LongMethod:FilePickerAdapter.kt$FilePickerAdapter$private fun fileLister(currentDirectory: File) LongMethod:LocalMediaPlayer.kt$LocalMediaPlayer$@Synchronized private fun doPlay(downloadFile: DownloadFile, position: Int, start: Boolean) LongMethod:NavigationActivity.kt$NavigationActivity$override fun onCreate(savedInstanceState: Bundle?) LongMethod:ShareHandler.kt$ShareHandler$private fun showDialog( fragment: Fragment, shareDetails: ShareDetails, swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken ) @@ -39,23 +34,18 @@ LongMethod:TrackCollectionFragment.kt$TrackCollectionFragment$private fun updateInterfaceWithEntries(musicDirectory: MusicDirectory) 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) ) MagicNumber:ActiveServerProvider.kt$ActiveServerProvider$8192 - MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$10 - MagicNumber:DownloadFile.kt$DownloadFile.DownloadTask$60 MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.<no name provided>$60000 MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$100000 - MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$1024L MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8 MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$86400L MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.BufferTask$8L MagicNumber:LocalMediaPlayer.kt$LocalMediaPlayer.CheckCompletionTask$5000L - MagicNumber:MediaPlayerService.kt$MediaPlayerService$256 MagicNumber:MediaPlayerService.kt$MediaPlayerService$3 MagicNumber:MediaPlayerService.kt$MediaPlayerService$4 MagicNumber:RESTMusicService.kt$RESTMusicService$206 MagicNumber:SongView.kt$SongView$3 MagicNumber:SongView.kt$SongView$4 MagicNumber:SongView.kt$SongView$60 - MagicNumber:TrackCollectionFragment.kt$TrackCollectionFragment$10 NestedBlockDepth:DownloadFile.kt$DownloadFile.DownloadTask$override fun execute() 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 ) NestedBlockDepth:MediaPlayerService.kt$MediaPlayerService$private fun setupOnSongCompletedHandler() diff --git a/detekt-config.yml b/detekt-config.yml index c9117d88..8729b6e3 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -70,7 +70,7 @@ style: excludeImportStatements: false MagicNumber: # 100 common in percentage, 1000 in milliseconds - ignoreNumbers: ['-1', '0', '1', '2', '10', '100', '256', '512', '1000', '1024'] + ignoreNumbers: ['-1', '0', '1', '2', '5', '10', '100', '256', '512', '1000', '1024'] ignoreEnums: true ignorePropertyDeclaration: true UnnecessaryAbstractClass: diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 7835f863..16645705 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -119,7 +119,6 @@ dependencies { testImplementation testing.mockitoKotlin testImplementation testing.robolectric - implementation other.dexter implementation other.timber } diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index d74e6655..63eb8137 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -1,71 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -154,17 +89,6 @@ column="9"/> - - - - - - - - - - - - @@ -315,7 +217,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -385,51 +287,51 @@ column="13"/> @@ -444,51 +346,51 @@ column="13"/> @@ -501,6 +403,65 @@ file="src/main/res/values/strings.xml" line="131" column="13"/> + + + + + + + + + + + + + + + + + message="The resource `R.string.parser_reading` appears to be unused" + errorLine1=" <string name="parser.reading">Reading from server.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message="The resource `R.string.parser_reading_done` appears to be unused" + errorLine1=" <string name="parser.reading_done">Reading from server. Done!</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - + @@ -798,51 +700,51 @@ column="13"/> - + @@ -857,51 +759,51 @@ column="13"/> - + @@ -938,51 +840,51 @@ column="13"/> - + @@ -997,51 +899,51 @@ column="13"/> - + @@ -1056,51 +958,51 @@ column="13"/> - + @@ -1115,51 +1017,51 @@ column="13"/> - + @@ -1181,72 +1083,50 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1989,7 +1792,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2000,7 +1803,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2011,7 +1814,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2022,7 +1825,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2033,7 +1836,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2231,7 +2034,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2257,15 +2060,4 @@ column="10"/> - - - - diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 8b709fbd..0f18c785 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ - @@ -29,6 +28,7 @@ android:label="@string/common.appname" android:usesCleartextTraffic="true" android:supportsRtl="false" + android:preserveLegacyExternalStorage="true" tools:ignore="UnusedAttribute"> + android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider" + tools:ignore="ExportedContentProvider" /> result) { - MusicDirectory musicDirectory = result.getFirst(); + MusicDirectory musicDirectory = result.first; List 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); } diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java deleted file mode 100644 index e3a48c70..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CacheCleaner.java +++ /dev/null @@ -1,300 +0,0 @@ -package org.moire.ultrasonic.util; - -import android.os.AsyncTask; -import android.os.StatFs; - -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.Playlist; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.Downloader; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.SortedSet; - -import kotlin.Lazy; -import timber.log.Timber; - -import static org.koin.java.KoinJavaComponent.inject; - -/** - * Responsible for cleaning up files from the offline download cache on the filesystem. - */ -public class CacheCleaner -{ - private static final long MIN_FREE_SPACE = 500 * 1024L * 1024L; - - public CacheCleaner() - { - } - - public void clean() - { - try - { - new BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex, "Exception in CacheCleaner.clean"); - } - } - - public void cleanSpace() - { - try - { - new BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex,"Exception in CacheCleaner.cleanSpace"); - } - } - - public void cleanPlaylists(List playlists) - { - try - { - new BackgroundPlaylistsCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, playlists); - } - catch (Exception ex) - { - // If an exception is thrown, assume we execute correctly the next time - Timber.w(ex, "Exception in CacheCleaner.cleanPlaylists"); - } - } - - private static void deleteEmptyDirs(Iterable dirs, Collection doNotDelete) - { - for (File dir : dirs) - { - if (doNotDelete.contains(dir)) - { - continue; - } - - File[] children = dir.listFiles(); - - if (children != null) - { - // No songs left in the folder - if (children.length == 1 && children[0].getPath().equals(FileUtil.getAlbumArtFile(dir).getPath())) - { - // Delete Artwork files - Util.delete(FileUtil.getAlbumArtFile(dir)); - children = dir.listFiles(); - } - - // Delete empty directory - if (children != null && children.length == 0) - { - Util.delete(dir); - } - } - } - } - - private static long getMinimumDelete(List files) - { - if (files.isEmpty()) - { - return 0L; - } - - long cacheSizeBytes = Settings.getCacheSizeMB() * 1024L * 1024L; - long bytesUsedBySubsonic = 0L; - - for (File file : files) - { - bytesUsedBySubsonic += file.length(); - } - - // Ensure that file system is not more than 95% full. - StatFs stat = new StatFs(files.get(0).getPath()); - long bytesTotalFs = (long) stat.getBlockCount() * (long) stat.getBlockSize(); - long bytesAvailableFs = (long) stat.getAvailableBlocks() * (long) stat.getBlockSize(); - long bytesUsedFs = bytesTotalFs - bytesAvailableFs; - long minFsAvailability = bytesTotalFs - MIN_FREE_SPACE; - - long bytesToDeleteCacheLimit = Math.max(bytesUsedBySubsonic - cacheSizeBytes, 0L); - long bytesToDeleteFsLimit = Math.max(bytesUsedFs - minFsAvailability, 0L); - long bytesToDelete = Math.max(bytesToDeleteCacheLimit, bytesToDeleteFsLimit); - - Timber.i("File system : %s of %s available", Util.formatBytes(bytesAvailableFs), Util.formatBytes(bytesTotalFs)); - Timber.i("Cache limit : %s", Util.formatBytes(cacheSizeBytes)); - Timber.i("Cache size before : %s", Util.formatBytes(bytesUsedBySubsonic)); - Timber.i("Minimum to delete : %s", Util.formatBytes(bytesToDelete)); - - return bytesToDelete; - } - - private static void deleteFiles(Collection files, Collection doNotDelete, long bytesToDelete, boolean deletePartials) - { - if (files.isEmpty()) - { - return; - } - - long bytesDeleted = 0L; - for (File file : files) - { - if (!deletePartials && bytesDeleted > bytesToDelete) break; - - if (bytesToDelete > bytesDeleted || (deletePartials && (file.getName().endsWith(".partial") || file.getName().contains(".partial.")))) - { - if (!doNotDelete.contains(file) && !file.getName().equals(Constants.ALBUM_ART_FILE)) - { - long size = file.length(); - - if (Util.delete(file)) - { - bytesDeleted += size; - } - } - } - } - - Timber.i("Deleted: %s", Util.formatBytes(bytesDeleted)); - } - - private static void findCandidatesForDeletion(File file, List files, List dirs) - { - if (file.isFile()) - { - String name = file.getName(); - boolean isCacheFile = name.endsWith(".partial") || name.contains(".partial.") || name.endsWith(".complete") || name.contains(".complete."); - - if (isCacheFile) - { - files.add(file); - } - } - else - { - // Depth-first - for (File child : FileUtil.listFiles(file)) - { - findCandidatesForDeletion(child, files, dirs); - } - - dirs.add(file); - } - } - - private static void sortByAscendingModificationTime(List files) - { - Collections.sort(files, (a, b) -> Long.compare(a.lastModified(), b.lastModified())); - } - - private static Set findFilesToNotDelete() - { - Set filesToNotDelete = new HashSet<>(5); - - Lazy downloader = inject(Downloader.class); - - for (DownloadFile downloadFile : downloader.getValue().getAll()) - { - filesToNotDelete.add(downloadFile.getPartialFile()); - filesToNotDelete.add(downloadFile.getCompleteOrSaveFile()); - } - - filesToNotDelete.add(FileUtil.getMusicDirectory()); - return filesToNotDelete; - } - - private static class BackgroundCleanup extends AsyncTask - { - @Override - protected Void doInBackground(Void... params) - { - try - { - Thread.currentThread().setName("BackgroundCleanup"); - List files = new ArrayList<>(); - List dirs = new ArrayList<>(); - - findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs); - sortByAscendingModificationTime(files); - - Set filesToNotDelete = findFilesToNotDelete(); - - deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true); - deleteEmptyDirs(dirs, filesToNotDelete); - } - catch (RuntimeException x) - { - Timber.e(x, "Error in cache cleaning."); - } - - return null; - } - } - - private static class BackgroundSpaceCleanup extends AsyncTask - { - @Override - protected Void doInBackground(Void... params) - { - try - { - Thread.currentThread().setName("BackgroundSpaceCleanup"); - List files = new ArrayList<>(); - List dirs = new ArrayList<>(); - findCandidatesForDeletion(FileUtil.getMusicDirectory(), files, dirs); - - long bytesToDelete = getMinimumDelete(files); - if (bytesToDelete > 0L) - { - sortByAscendingModificationTime(files); - Set filesToNotDelete = findFilesToNotDelete(); - deleteFiles(files, filesToNotDelete, bytesToDelete, false); - } - } - catch (RuntimeException x) - { - Timber.e(x, "Error in cache cleaning."); - } - - return null; - } - } - - private static class BackgroundPlaylistsCleanup extends AsyncTask, Void, Void> - { - @Override - protected Void doInBackground(List... params) - { - try - { - Lazy activeServerProvider = inject(ActiveServerProvider.class); - Thread.currentThread().setName("BackgroundPlaylistsCleanup"); - String server = activeServerProvider.getValue().getActiveServer().getName(); - SortedSet playlistFiles = FileUtil.listFiles(FileUtil.getPlaylistDirectory(server)); - List playlists = params[0]; - for (Playlist playlist : playlists) - { - playlistFiles.remove(FileUtil.getPlaylistFile(server, playlist.getName())); - } - - for (File playlist : playlistFiles) - { - playlist.delete(); - } - } - catch (RuntimeException x) - { - Timber.e(x, "Error in playlist cache cleaning."); - } - - return null; - } - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Pair.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Pair.java deleted file mode 100644 index 4bedd194..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Pair.java +++ /dev/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 . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import java.io.Serializable; - -/** - * @author Sindre Mehus - */ -public class Pair 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; - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt index a92bfcc4..2af515b4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/activity/NavigationActivity.kt @@ -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() { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index e44e0774..044235b0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -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() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt deleted file mode 100644 index 5c4dcbc7..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerAdapter.kt +++ /dev/null @@ -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() { - - private var data: MutableList = 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() - val storages: List? - val storagePaths: List? - 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): LinkedList { - val fileList = LinkedList() - 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(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(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) - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt deleted file mode 100644 index 5c743136..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerDialog.kt +++ /dev/null @@ -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) - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt deleted file mode 100644 index a94f86be..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/FilePickerView.kt +++ /dev/null @@ -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() - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt deleted file mode 100644 index 54d3b5de..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/filepicker/OnFileSelectedListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.moire.ultrasonic.filepicker - -import java.io.File - -interface OnFileSelectedListener { - fun onFileSelected(file: File?, path: String?) -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index a95838cd..1addcf40 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -178,7 +178,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler { } ) .setNegativeButton(getString(R.string.common_cancel)) { - dialogInterface, i -> + dialogInterface, _ -> dialogInterface.dismiss() } .setBottomSpace(DIALOG_PADDING) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 392c04a4..6f26b174 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -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::class.java ) - private val permissionUtil = inject( - PermissionUtil::class.java - ) private val themeChangedEventDistributor = inject( ThemeChangedEventDistributor::class.java ) @@ -169,6 +167,26 @@ class SettingsFragment : update() } + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + if (requestCode == SELECT_CACHE_ACTIVITY && resultCode == Activity.RESULT_OK) { + // The result data contains a URI for the document or directory that + // the user selected. + resultData?.data?.also { uri -> + // Perform operations on the document using its URI. + val contentResolver = UApp.applicationContext().contentResolver + + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION +// Check for the freshest data. + contentResolver.takePersistableUriPermission(uri, takeFlags) + + setCacheLocation(uri) + + } + } + } + + override fun onResume() { super.onResume() val preferences = preferences @@ -229,29 +247,19 @@ class SettingsFragment : cacheLocation!!.summary = Settings.cacheLocation cacheLocation!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { - // If the user tries to change the cache location, - // we must first check to see if we have write access. - requestInitialPermission( - requireActivity() - ) { - if (it) { - val filePickerDialog = createFilePickerDialog( - requireContext() - ) - filePickerDialog.setDefaultDirectory(defaultMusicDirectory.path) - filePickerDialog.setInitialDirectory(cacheLocation!!.summary.toString()) - filePickerDialog.setOnFileSelectedListener(object : - OnFileSelectedListener { - override fun onFileSelected(file: File?, path: String?) { - if (path != null) { - Settings.cacheLocation = path - setCacheLocation(path) - } - } - }) - filePickerDialog.show() - } + val isDefault = Settings.cacheLocation == defaultMusicDirectory.path + + // Choose a directory using the system's file picker. + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + if (!isDefault && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, defaultMusicDirectory.path) } + + intent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + startActivityForResult(intent, SELECT_CACHE_ACTIVITY) + + true } } @@ -419,19 +427,14 @@ class SettingsFragment : sendBluetoothAlbumArt!!.isEnabled = enabled } - private fun setCacheLocation(path: String) { - val dir = File(path) - if (!ensureDirectoryExistsAndIsReadWritable(dir)) { - permissionUtil.value.handlePermissionFailed { - val currentPath = Settings.cacheLocation - cacheLocation!!.summary = currentPath - } - } else { - cacheLocation!!.summary = path - } + private fun setCacheLocation(uri: Uri) { + if (uri.path != null) { + cacheLocation!!.summary = uri.path + Settings.cacheLocation = uri.path!! - // Clear download queue. - mediaPlayerControllerLazy.value.clear() + // Clear download queue. + mediaPlayerControllerLazy.value.clear() + } } private fun setDebugLogToFile(writeLog: Boolean) { @@ -471,4 +474,8 @@ class SettingsFragment : .create().show() } } + + companion object { + const val SELECT_CACHE_ACTIVITY = 161161 + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt index 348e0e6f..1d899bd4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/BitmapUtils.kt @@ -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 diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 0e13666c..c24e9e88 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -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) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt new file mode 100644 index 00000000..f54673d5 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -0,0 +1,240 @@ +package org.moire.ultrasonic.util + +import android.os.AsyncTask +import android.os.StatFs +import java.io.File +import java.util.ArrayList +import java.util.HashSet +import org.koin.java.KoinJavaComponent.inject +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.Playlist +import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile +import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory +import org.moire.ultrasonic.util.FileUtil.getPlaylistFile +import org.moire.ultrasonic.util.FileUtil.listFiles +import org.moire.ultrasonic.util.FileUtil.musicDirectory +import org.moire.ultrasonic.util.Settings.cacheSizeMB +import org.moire.ultrasonic.util.Util.delete +import org.moire.ultrasonic.util.Util.formatBytes +import timber.log.Timber + +/** + * Responsible for cleaning up files from the offline download cache on the filesystem. + */ +class CacheCleaner { + fun clean() { + try { + BackgroundCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.clean") + } + } + + fun cleanSpace() { + try { + BackgroundSpaceCleanup().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.cleanSpace") + } + } + + fun cleanPlaylists(playlists: List) { + try { + BackgroundPlaylistsCleanup().executeOnExecutor( + AsyncTask.THREAD_POOL_EXECUTOR, + playlists + ) + } catch (all: Exception) { + // If an exception is thrown, assume we execute correctly the next time + Timber.w(all, "Exception in CacheCleaner.cleanPlaylists") + } + } + + private class BackgroundCleanup : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + try { + Thread.currentThread().name = "BackgroundCleanup" + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + sortByAscendingModificationTime(files) + val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true) + deleteEmptyDirs(dirs, filesToNotDelete) + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") + } + return null + } + } + + private class BackgroundSpaceCleanup : AsyncTask() { + override fun doInBackground(vararg params: Void?): Void? { + try { + Thread.currentThread().name = "BackgroundSpaceCleanup" + val files: MutableList = ArrayList() + val dirs: MutableList = ArrayList() + findCandidatesForDeletion(musicDirectory, files, dirs) + val bytesToDelete = getMinimumDelete(files) + if (bytesToDelete > 0L) { + sortByAscendingModificationTime(files) + val filesToNotDelete = findFilesToNotDelete() + deleteFiles(files, filesToNotDelete, bytesToDelete, false) + } + } catch (all: RuntimeException) { + Timber.e(all, "Error in cache cleaning.") + } + return null + } + } + + private class BackgroundPlaylistsCleanup : AsyncTask, Void?, Void?>() { + override fun doInBackground(vararg params: List): Void? { + try { + val activeServerProvider = inject( + ActiveServerProvider::class.java + ) + Thread.currentThread().name = "BackgroundPlaylistsCleanup" + val server = activeServerProvider.value.getActiveServer().name + val playlistFiles = listFiles(getPlaylistDirectory(server)) + val playlists = params[0] + for ((_, name) in playlists) { + playlistFiles.remove(getPlaylistFile(server, name)) + } + for (playlist in playlistFiles) { + playlist.delete() + } + } catch (all: RuntimeException) { + Timber.e(all, "Error in playlist cache cleaning.") + } + return null + } + } + + companion object { + private const val MIN_FREE_SPACE = 500 * 1024L * 1024L + private fun deleteEmptyDirs(dirs: Iterable, doNotDelete: Collection) { + for (dir in dirs) { + if (doNotDelete.contains(dir)) { + continue + } + var children = dir.listFiles() + if (children != null) { + // No songs left in the folder + if (children.size == 1 && children[0].path == getAlbumArtFile(dir).path) { + // Delete Artwork files + delete(getAlbumArtFile(dir)) + children = dir.listFiles() + } + + // Delete empty directory + if (children != null && children.isEmpty()) { + delete(dir) + } + } + } + } + + private fun getMinimumDelete(files: List): Long { + if (files.isEmpty()) { + return 0L + } + val cacheSizeBytes = cacheSizeMB * 1024L * 1024L + var bytesUsedBySubsonic = 0L + for (file in files) { + bytesUsedBySubsonic += file.length() + } + + // Ensure that file system is not more than 95% full. + val stat = StatFs(files[0].path) + val bytesTotalFs = stat.blockCountLong * stat.blockSizeLong + val bytesAvailableFs = stat.availableBlocksLong * stat.blockSizeLong + val bytesUsedFs = bytesTotalFs - bytesAvailableFs + val minFsAvailability = bytesTotalFs - MIN_FREE_SPACE + val bytesToDeleteCacheLimit = (bytesUsedBySubsonic - cacheSizeBytes).coerceAtLeast(0L) + val bytesToDeleteFsLimit = (bytesUsedFs - minFsAvailability).coerceAtLeast(0L) + val bytesToDelete = bytesToDeleteCacheLimit.coerceAtLeast(bytesToDeleteFsLimit) + + Timber.i( + "File system : %s of %s available", + formatBytes(bytesAvailableFs), + formatBytes(bytesTotalFs) + ) + Timber.i("Cache limit : %s", formatBytes(cacheSizeBytes)) + Timber.i("Cache size before : %s", formatBytes(bytesUsedBySubsonic)) + Timber.i("Minimum to delete : %s", formatBytes(bytesToDelete)) + return bytesToDelete + } + + private fun isPartial(file: File): Boolean { + return file.name.endsWith(".partial") || file.name.contains(".partial.") + } + + private fun isComplete(file: File): Boolean { + return file.name.endsWith(".complete") || file.name.contains(".complete.") + } + + @Suppress("NestedBlockDepth") + private fun deleteFiles( + files: Collection, + doNotDelete: Collection, + bytesToDelete: Long, + deletePartials: Boolean + ) { + if (files.isEmpty()) { + return + } + var bytesDeleted = 0L + for (file in files) { + if (!deletePartials && bytesDeleted > bytesToDelete) break + if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) { + if (!doNotDelete.contains(file) && file.name != Constants.ALBUM_ART_FILE) { + val size = file.length() + if (delete(file)) { + bytesDeleted += size + } + } + } + } + Timber.i("Deleted: %s", formatBytes(bytesDeleted)) + } + + private fun findCandidatesForDeletion( + file: File, + files: MutableList, + dirs: MutableList + ) { + if (file.isFile && (isPartial(file) || isComplete(file))) { + files.add(file) + } else { + // Depth-first + for (child in listFiles(file)) { + findCandidatesForDeletion(child, files, dirs) + } + dirs.add(file) + } + } + + private fun sortByAscendingModificationTime(files: MutableList) { + files.sortWith { a: File, b: File -> + a.lastModified().compareTo(b.lastModified()) + } + } + + private fun findFilesToNotDelete(): Set { + val filesToNotDelete: MutableSet = HashSet(5) + val downloader = inject( + Downloader::class.java + ) + for (downloadFile in downloader.value.all) { + filesToNotDelete.add(downloadFile.partialFile) + filesToNotDelete.add(downloadFile.completeOrSaveFile) + } + filesToNotDelete.add(musicDirectory) + return filesToNotDelete + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index f9a03051..2f10ebe7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -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::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 { + 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) } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/PermissionUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/PermissionUtil.kt deleted file mode 100644 index e39154c0..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/PermissionUtil.kt +++ /dev/null @@ -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, - 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, - 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() - } - } -} diff --git a/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml b/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml deleted file mode 100644 index 3737694e..00000000 --- a/ultrasonic/src/main/res/layout/filepicker_dialog_create_folder.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml b/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml deleted file mode 100644 index fb860179..00000000 --- a/ultrasonic/src/main/res/layout/filepicker_dialog_main.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - -