/* * Copyright (c) 2020 Hemanth Savarala. * * Licensed under the GNU General Public License v3 * * This 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. * * This software 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. */ package code.name.monkey.retromusic.fragments.folder import android.app.Dialog import android.content.Context import android.media.MediaScannerConnection import android.os.Bundle import android.os.Environment import android.text.Html import android.view.* import android.webkit.MimeTypeMap import android.widget.PopupMenu import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.loader.app.LoaderManager import androidx.loader.content.Loader import androidx.navigation.Navigation.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import code.name.monkey.appthemehelper.ThemeStore.Companion.accentColor import code.name.monkey.appthemehelper.common.ATHToolbarActivity import code.name.monkey.appthemehelper.util.ATHUtil.resolveColor import code.name.monkey.appthemehelper.util.ToolbarContentTintHelper import code.name.monkey.retromusic.App import code.name.monkey.retromusic.R import code.name.monkey.retromusic.adapter.SongFileAdapter import code.name.monkey.retromusic.adapter.Storage import code.name.monkey.retromusic.adapter.StorageAdapter import code.name.monkey.retromusic.adapter.StorageClickListener import code.name.monkey.retromusic.databinding.FragmentFolderBinding import code.name.monkey.retromusic.extensions.drawNextToNavbar import code.name.monkey.retromusic.extensions.surfaceColor import code.name.monkey.retromusic.fragments.base.AbsMainActivityFragment import code.name.monkey.retromusic.fragments.folder.FoldersFragment.ListPathsAsyncTask.OnPathsListedCallback import code.name.monkey.retromusic.fragments.folder.FoldersFragment.ListSongsAsyncTask.OnSongsListedCallback import code.name.monkey.retromusic.helper.MusicPlayerRemote.openQueue import code.name.monkey.retromusic.helper.MusicPlayerRemote.playingQueue import code.name.monkey.retromusic.helper.menu.SongMenuHelper.handleMenuClick import code.name.monkey.retromusic.helper.menu.SongsMenuHelper import code.name.monkey.retromusic.interfaces.ICabCallback import code.name.monkey.retromusic.interfaces.ICabHolder import code.name.monkey.retromusic.interfaces.ICallbacks import code.name.monkey.retromusic.interfaces.IMainActivityFragmentCallbacks import code.name.monkey.retromusic.misc.DialogAsyncTask import code.name.monkey.retromusic.misc.UpdateToastMediaScannerCompletionListener import code.name.monkey.retromusic.misc.WrappedAsyncTaskLoader import code.name.monkey.retromusic.model.Song import code.name.monkey.retromusic.providers.BlacklistStore import code.name.monkey.retromusic.util.* import code.name.monkey.retromusic.util.DensityUtil.dip2px import code.name.monkey.retromusic.util.PreferenceUtil.startDirectory import code.name.monkey.retromusic.util.ThemedFastScroller.create import code.name.monkey.retromusic.views.BreadCrumbLayout.Crumb import code.name.monkey.retromusic.views.BreadCrumbLayout.SelectionCallback import code.name.monkey.retromusic.views.ScrollingViewOnApplyWindowInsetsListener import com.afollestad.materialcab.attached.AttachedCab import com.afollestad.materialcab.attached.destroy import com.afollestad.materialcab.attached.isActive import com.afollestad.materialcab.createCab import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialSharedAxis import java.io.* import java.lang.ref.WeakReference import java.util.* class FoldersFragment : AbsMainActivityFragment(R.layout.fragment_folder), IMainActivityFragmentCallbacks, ICabHolder, SelectionCallback, ICallbacks, LoaderManager.LoaderCallbacks>, StorageClickListener { private var _binding: FragmentFolderBinding? = null private val binding get() = _binding!! private var adapter: SongFileAdapter? = null private var storageAdapter: StorageAdapter? = null private var cab: AttachedCab? = null private val fileComparator = Comparator { lhs: File, rhs: File -> if (lhs.isDirectory && !rhs.isDirectory) { return@Comparator -1 } else if (!lhs.isDirectory && rhs.isDirectory) { return@Comparator 1 } else { return@Comparator lhs.name.compareTo(rhs.name, ignoreCase = true) } } private var storageItems = ArrayList() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentFolderBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { mainActivity.addMusicServiceEventListener(libraryViewModel) mainActivity.setSupportActionBar(binding.toolbar) mainActivity.supportActionBar?.title = null setUpBreadCrumbs() setUpRecyclerView() setUpAdapter() setUpTitle() requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (!handleBackPress()) { remove() requireActivity().onBackPressed() } } }) binding.toolbarContainer.drawNextToNavbar() binding.appBarLayout.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(requireContext()) } private fun setUpTitle() { binding.toolbar.setNavigationOnClickListener { v: View? -> exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true).setDuration(300) reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false).setDuration(300) findNavController(v!!).navigate(R.id.searchFragment, null, navOptions) } binding.appNameText.text = resources.getString(R.string.folders) } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) setHasOptionsMenu(true) if (savedInstanceState == null) { switchToFileAdapter() setCrumb( Crumb( FileUtil.safeGetCanonicalFile(startDirectory) ), true ) } else { binding.breadCrumbs.restoreFromStateWrapper(savedInstanceState.getParcelable(CRUMBS)) LoaderManager.getInstance(this).initLoader(LOADER_ID, null, this) } } override fun onPause() { super.onPause() saveScrollPosition() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) if (_binding != null) { outState.putParcelable(CRUMBS, binding.breadCrumbs.stateWrapper) } } override fun handleBackPress(): Boolean { if (cab != null && cab!!.isActive()) { cab?.destroy() return true } if (binding.breadCrumbs.popHistory()) { setCrumb(binding.breadCrumbs.lastHistory(), false) return true } return false } override fun onCreateLoader(id: Int, args: Bundle?): Loader> { return AsyncFileLoader(this) } override fun onCrumbSelection(crumb: Crumb, index: Int) { setCrumb(crumb, true) } override fun onFileMenuClicked(file: File, view: View) { val popupMenu = PopupMenu(activity, view) if (file.isDirectory) { popupMenu.inflate(R.menu.menu_item_directory) popupMenu.setOnMenuItemClickListener { item: MenuItem -> when (val itemId = item.itemId) { R.id.action_play_next, R.id.action_add_to_current_playing, R.id.action_add_to_playlist, R.id.action_delete_from_device -> { ListSongsAsyncTask( activity, null, object : OnSongsListedCallback { override fun onSongsListed(songs: List, extra: Any?) { if (songs.isNotEmpty()) { SongsMenuHelper.handleMenuClick( requireActivity(), songs, itemId ) } } }) .execute( ListSongsAsyncTask.LoadingInfo( toList(file), AUDIO_FILE_FILTER, fileComparator ) ) return@setOnMenuItemClickListener true } R.id.action_add_to_blacklist -> { BlacklistStore.getInstance(App.getContext()).addPath(file) return@setOnMenuItemClickListener true } R.id.action_set_as_start_directory -> { startDirectory = file Toast.makeText( activity, String.format(getString(R.string.new_start_directory), file.path), Toast.LENGTH_SHORT ) .show() return@setOnMenuItemClickListener true } R.id.action_scan -> { ListPathsAsyncTask( activity, object : OnPathsListedCallback { override fun onPathsListed(paths: Array) { scanPaths(paths) } }) .execute(ListPathsAsyncTask.LoadingInfo(file, AUDIO_FILE_FILTER)) return@setOnMenuItemClickListener true } } false } } else { popupMenu.inflate(R.menu.menu_item_file) popupMenu.setOnMenuItemClickListener { item: MenuItem -> when (val itemId = item.itemId) { R.id.action_play_next, R.id.action_add_to_current_playing, R.id.action_add_to_playlist, R.id.action_go_to_album, R.id.action_go_to_artist, R.id.action_share, R.id.action_tag_editor, R.id.action_details, R.id.action_set_as_ringtone, R.id.action_delete_from_device -> { ListSongsAsyncTask( activity, null, object : OnSongsListedCallback { override fun onSongsListed(songs: List, extra: Any?) { handleMenuClick( requireActivity(), songs[0], itemId ) } }) .execute( ListSongsAsyncTask.LoadingInfo( toList(file), AUDIO_FILE_FILTER, fileComparator ) ) return@setOnMenuItemClickListener true } R.id.action_scan -> { ListPathsAsyncTask( activity, object : OnPathsListedCallback { override fun onPathsListed(paths: Array) { scanPaths(paths) } }) .execute(ListPathsAsyncTask.LoadingInfo(file, AUDIO_FILE_FILTER)) return@setOnMenuItemClickListener true } } false } } popupMenu.show() } override fun onFileSelected(file: File) { var mFile = file mFile = tryGetCanonicalFile(mFile) // important as we compare the path value later if (mFile.isDirectory) { setCrumb(Crumb(mFile), true) } else { val fileFilter = FileFilter { pathname: File -> !pathname.isDirectory && AUDIO_FILE_FILTER.accept(pathname) } ListSongsAsyncTask( activity, mFile, object : OnSongsListedCallback { override fun onSongsListed(songs: List, extra: Any?) { val file1 = extra as File var startIndex = -1 for (i in songs.indices) { if (file1 .path == songs[i].data ) { // path is already canonical here startIndex = i break } } if (startIndex > -1) { openQueue(songs, startIndex, true) } else { Snackbar.make( binding.root, Html.fromHtml( String.format( getString(R.string.not_listed_in_media_store), file1.name ) ), Snackbar.LENGTH_LONG ) .setAction( R.string.action_scan ) { ListPathsAsyncTask( requireActivity(), object : OnPathsListedCallback { override fun onPathsListed(paths: Array) { scanPaths(paths) } }) .execute( ListPathsAsyncTask.LoadingInfo( file1, AUDIO_FILE_FILTER ) ) } .setActionTextColor(accentColor(requireActivity())) .show() } } }) .execute( ListSongsAsyncTask.LoadingInfo( toList(mFile.parentFile), fileFilter, fileComparator ) ) } } override fun onLoadFinished(loader: Loader>, data: List) { updateAdapter(data) } override fun onLoaderReset(loader: Loader>) { updateAdapter(LinkedList()) } override fun onMultipleItemAction(item: MenuItem, files: ArrayList) { val itemId = item.itemId ListSongsAsyncTask( activity, null, object : OnSongsListedCallback { override fun onSongsListed(songs: List, extra: Any?) { SongsMenuHelper.handleMenuClick( requireActivity(), songs, itemId ) } }) .execute(ListSongsAsyncTask.LoadingInfo(files, AUDIO_FILE_FILTER, fileComparator)) } override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) ToolbarContentTintHelper.handleOnPrepareOptionsMenu(requireActivity(), binding.toolbar) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) menu.add(0, R.id.action_scan, 0, R.string.scan_media) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) menu.add(0, R.id.action_go_to_start_directory, 1, R.string.action_go_to_start_directory) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) menu.removeItem(R.id.action_grid_size) menu.removeItem(R.id.action_layout_type) menu.removeItem(R.id.action_sort_order) ToolbarContentTintHelper.handleOnCreateOptionsMenu( requireContext(), binding.toolbar, menu, ATHToolbarActivity.getToolbarBackgroundColor( binding.toolbar ) ) } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_go_to_start_directory -> { setCrumb( Crumb( tryGetCanonicalFile(startDirectory) ), true ) return true } R.id.action_scan -> { val crumb = activeCrumb if (crumb != null) { ListPathsAsyncTask( activity, object : OnPathsListedCallback { override fun onPathsListed(paths: Array) { scanPaths(paths) } }) .execute(ListPathsAsyncTask.LoadingInfo(crumb.file, AUDIO_FILE_FILTER)) } return true } } return super.onOptionsItemSelected(item) } override fun onQueueChanged() { super.onQueueChanged() checkForPadding() } override fun onServiceConnected() { super.onServiceConnected() checkForPadding() } override fun openCab(menuRes: Int, callback: ICabCallback): AttachedCab { if (cab != null && cab!!.isActive()) { cab?.destroy() } cab = createCab(R.id.toolbar_container) { menu(menuRes) closeDrawable(R.drawable.ic_close) backgroundColor(literal = RetroColorUtil.shiftBackgroundColor(surfaceColor())) slideDown() onCreate { cab, menu -> callback.onCabCreated(cab, menu) } onSelection { callback.onCabItemClicked(it) } onDestroy { callback.onCabFinished(it) } } return cab as AttachedCab } private fun checkForPadding() { val count = adapter?.itemCount ?: 0 if (_binding != null) { val params = binding.root.layoutParams as ViewGroup.MarginLayoutParams params.bottomMargin = if (count > 0 && playingQueue.isNotEmpty()) dip2px( requireContext(), 104f ) else dip2px(requireContext(), 54f) binding.root.layoutParams = params } } private fun checkIsEmpty() { if (_binding != null) { binding.emptyEmoji.text = getEmojiByUnicode(0x1F631) binding.empty.visibility = if (adapter == null || adapter!!.itemCount == 0) View.VISIBLE else View.GONE } } private val activeCrumb: Crumb? get() = if (_binding != null) { if (binding.breadCrumbs.size() > 0) binding.breadCrumbs.getCrumb(binding.breadCrumbs.activeIndex) else null } else null private fun getEmojiByUnicode(unicode: Int): String { return String(Character.toChars(unicode)) } private fun saveScrollPosition() { val crumb = activeCrumb if (crumb != null) { crumb.scrollPosition = (binding.recyclerView.layoutManager as LinearLayoutManager?)!!.findFirstVisibleItemPosition() } } private fun scanPaths(toBeScanned: Array) { if (activity == null) { return } if (toBeScanned.isEmpty()) { Toast.makeText(activity, R.string.nothing_to_scan, Toast.LENGTH_SHORT).show() } else { MediaScannerConnection.scanFile( requireContext(), toBeScanned, null, UpdateToastMediaScannerCompletionListener(activity, listOf(*toBeScanned)) ) } } private fun setCrumb(crumb: Crumb?, addToHistory: Boolean) { if (crumb == null) { return } val path = crumb.file.path if (path == "/" || path == "/storage" || path == "/storage/emulated") { switchToStorageAdapter() } else { saveScrollPosition() binding.breadCrumbs.setActiveOrAdd(crumb, false) if (addToHistory) { binding.breadCrumbs.addHistory(crumb) } LoaderManager.getInstance(this).restartLoader(LOADER_ID, null, this) } } private fun setUpAdapter() { switchToFileAdapter() } private fun setUpBreadCrumbs() { binding.breadCrumbs.setActivatedContentColor( resolveColor(requireContext(), android.R.attr.textColorPrimary) ) binding.breadCrumbs.setDeactivatedContentColor( resolveColor(requireContext(), android.R.attr.textColorSecondary) ) binding.breadCrumbs.setCallback(this) } private fun setUpRecyclerView() { binding.recyclerView.layoutManager = LinearLayoutManager( activity ) val fastScroller = create( binding.recyclerView ) binding.recyclerView.setOnApplyWindowInsetsListener( ScrollingViewOnApplyWindowInsetsListener(binding.recyclerView, fastScroller) ) } private fun toList(file: File): ArrayList { val files = ArrayList(1) files.add(file) return files } private fun updateAdapter(files: List) { adapter?.swapDataSet(files) val crumb = activeCrumb if (crumb != null) { (binding.recyclerView.layoutManager as LinearLayoutManager?) ?.scrollToPositionWithOffset(crumb.scrollPosition, 0) } } override fun onDestroyView() { super.onDestroyView() _binding = null } class ListPathsAsyncTask(context: Context?, callback: OnPathsListedCallback) : ListingFilesDialogAsyncTask>( context ) { private val onPathsListedCallbackWeakReference: WeakReference = WeakReference(callback) override fun doInBackground(vararg params: LoadingInfo): Array { return try { if (isCancelled || checkCallbackReference() == null) { return arrayOf() } val info = params[0] val paths: Array if (info.file.isDirectory) { val files = FileUtil.listFilesDeep(info.file, info.fileFilter) if (isCancelled || checkCallbackReference() == null) { return arrayOf() } paths = arrayOfNulls(files.size) for (i in files.indices) { val f = files[i] paths[i] = FileUtil.safeGetCanonicalPath(f) if (isCancelled || checkCallbackReference() == null) { return arrayOf() } } } else { paths = arrayOfNulls(1) paths[0] = info.file.path } paths } catch (e: Exception) { e.printStackTrace() cancel(false) arrayOf() } } override fun onPostExecute(paths: Array) { super.onPostExecute(paths) checkCallbackReference()?.onPathsListed(paths) } override fun onPreExecute() { super.onPreExecute() checkCallbackReference() } private fun checkCallbackReference(): OnPathsListedCallback? { val callback = onPathsListedCallbackWeakReference.get() if (callback == null) { cancel(false) } return callback } interface OnPathsListedCallback { fun onPathsListed(paths: Array) } class LoadingInfo(val file: File, val fileFilter: FileFilter) } private class AsyncFileLoader(foldersFragment: FoldersFragment) : WrappedAsyncTaskLoader>(foldersFragment.requireActivity()) { private val fragmentWeakReference: WeakReference = WeakReference(foldersFragment) override fun loadInBackground(): List { val foldersFragment = fragmentWeakReference.get() var directory: File? = null if (foldersFragment != null) { val crumb = foldersFragment.activeCrumb if (crumb != null) { directory = crumb.file } } return if (directory != null) { val files = FileUtil.listFiles( directory, AUDIO_FILE_FILTER ) Collections.sort(files, foldersFragment!!.fileComparator) files } else { LinkedList() } } } private open class ListSongsAsyncTask( context: Context?, private val extra: Any?, callback: OnSongsListedCallback ) : ListingFilesDialogAsyncTask>(context) { private val callbackWeakReference = WeakReference(callback) private val contextWeakReference = WeakReference(context) override fun doInBackground(vararg params: LoadingInfo): List { return try { val info = params[0] val files = FileUtil.listFilesDeep(info.files, info.fileFilter) if (isCancelled || checkContextReference() == null || checkCallbackReference() == null) { return emptyList() } Collections.sort(files, info.fileComparator) val context = checkContextReference() if (isCancelled || context == null || checkCallbackReference() == null) { emptyList() } else FileUtil.matchFilesWithMediaStore(context, files) } catch (e: Exception) { e.printStackTrace() cancel(false) emptyList() } } override fun onPostExecute(songs: List) { super.onPostExecute(songs) checkCallbackReference()?.onSongsListed(songs, extra) } override fun onPreExecute() { super.onPreExecute() checkCallbackReference() checkContextReference() } private fun checkCallbackReference(): OnSongsListedCallback? { val callback = callbackWeakReference.get() if (callback == null) { cancel(false) } return callback } private fun checkContextReference(): Context? { val context = contextWeakReference.get() if (context == null) { cancel(false) } return context } interface OnSongsListedCallback { fun onSongsListed(songs: List, extra: Any?) } class LoadingInfo( val files: List, val fileFilter: FileFilter, val fileComparator: Comparator ) } abstract class ListingFilesDialogAsyncTask : DialogAsyncTask { internal constructor(context: Context?) : super(context) override fun createDialog(context: Context): Dialog { return MaterialAlertDialogBuilder(context) .setTitle(R.string.listing_files) .setCancelable(false) .setView(R.layout.loading) .setOnCancelListener { cancel(false) } .setOnDismissListener { cancel(false) } .create() } } override fun onStorageClicked(storage: Storage) { switchToFileAdapter() setCrumb( Crumb( FileUtil.safeGetCanonicalFile(storage.file) ), true ) } private fun switchToFileAdapter() { adapter = SongFileAdapter(mainActivity, LinkedList(), R.layout.item_list, this, this) adapter!!.registerAdapterDataObserver( object : RecyclerView.AdapterDataObserver() { override fun onChanged() { super.onChanged() checkIsEmpty() checkForPadding() } }) binding.recyclerView.adapter = adapter checkIsEmpty() } private fun switchToStorageAdapter() { storageItems = FileUtil.listRoots() storageAdapter = StorageAdapter(storageItems, this) binding.recyclerView.adapter = storageAdapter binding.breadCrumbs.clearCrumbs() } companion object { val TAG: String = FoldersFragment::class.java.simpleName val AUDIO_FILE_FILTER = FileFilter { file: File -> (!file.isHidden && (file.isDirectory || FileUtil.fileIsMimeType(file, "audio/*", MimeTypeMap.getSingleton()) || FileUtil.fileIsMimeType(file, "application/opus", MimeTypeMap.getSingleton()) || FileUtil.fileIsMimeType( file, "application/ogg", MimeTypeMap.getSingleton() ))) } private const val CRUMBS = "crumbs" private const val LOADER_ID = 5 // root val defaultStartDirectory: File get() { val musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC) val startFolder = if (musicDir.exists() && musicDir.isDirectory) { musicDir } else { val externalStorage = Environment.getExternalStorageDirectory() if (externalStorage.exists() && externalStorage.isDirectory) { externalStorage } else { File("/") // root } } return startFolder } private fun tryGetCanonicalFile(file: File): File { return try { file.canonicalFile } catch (e: IOException) { e.printStackTrace() file } } } }