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 26b0bbb4..3455efbe 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/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 a3fa41b0..fef81695 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -15,17 +15,6 @@ file="../../../../.gradle/caches/transforms-3/e9d816753daf5450613abd98ccf3b80c/transformed/jetified-timber-4.7.1/jars/lint.jar"/> - - - - - - - - - - - - @@ -1678,17 +1645,6 @@ column="22"/> - - - - - @@ -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" /> () { - - 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/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 392c04a4..a2e4eb97 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,15 @@ package org.moire.ultrasonic.fragment +import android.app.Activity import android.app.AlertDialog import android.content.DialogInterface +import android.content.Intent 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 @@ -22,10 +26,9 @@ 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 +40,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 @@ -55,6 +55,7 @@ import timber.log.Timber /** * Shows main app settings. */ +@Suppress("TooManyFunctions") class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeListener, @@ -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,21 @@ 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 + + contentResolver.takePersistableUriPermission(uri, RW_FLAG) + + setCacheLocation(uri) + } + } + } + override fun onResume() { super.onResume() val preferences = preferences @@ -229,29 +242,20 @@ 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(RW_FLAG) + intent.addFlags(PERSISTABLE_FLAG) + + startActivityForResult(intent, SELECT_CACHE_ACTIVITY) + true } } @@ -419,19 +423,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 +470,11 @@ class SettingsFragment : .create().show() } } + + companion object { + const val SELECT_CACHE_ACTIVITY = 161161 + const val RW_FLAG = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + const val PERSISTABLE_FLAG = Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + } } 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..c384309c 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,7 +25,6 @@ import java.util.Locale import java.util.SortedSet import java.util.TreeSet import java.util.regex.Pattern -import org.koin.java.KoinJavaComponent import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.domain.MusicDirectory import timber.log.Timber @@ -43,10 +43,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 +233,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 +250,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 @@ - - - - - - - -