diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index e49c08a45..8d75a5a1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -39,6 +39,8 @@ import com.google.android.material.appbar.AppBarLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.transition.MaterialSharedAxis +import com.leinardi.android.speeddial.SpeedDialActionItem +import com.leinardi.android.speeddial.SpeedDialView import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field import java.lang.reflect.Method @@ -77,6 +79,7 @@ import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.showToast /** * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation @@ -86,13 +89,16 @@ import org.oxycblt.auxio.util.navigateSafe */ @AndroidEntryPoint class HomeFragment : - SelectionFragment(), AppBarLayout.OnOffsetChangedListener { + SelectionFragment(), + AppBarLayout.OnOffsetChangedListener, + SpeedDialView.OnActionSelectedListener { override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var storagePermissionLauncher: ActivityResultLauncher? = null + private var filePickerLauncher: ActivityResultLauncher? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -121,6 +127,17 @@ class HomeFragment : musicModel.refresh() } + filePickerLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + logW("No URI returned from file picker") + return@registerForActivityResult + } + + logD("Received playlist URI $uri") + musicModel.importPlaylist(uri) + } + // --- UI SETUP --- binding.homeAppbar.addOnOffsetChangedListener(this) binding.homeNormalToolbar.apply { @@ -176,20 +193,7 @@ class HomeFragment : binding.homeNewPlaylistFab.apply { inflate(R.menu.new_playlist_actions) - setOnActionSelectedListener { action -> - when (action.id) { - R.id.action_new_playlist -> { - logD("Creating playlist") - musicModel.createPlaylist() - } - R.id.action_import_playlist -> { - TODO("Not implemented") - } - else -> {} - } - close() - true - } + setOnActionSelectedListener(this@HomeFragment) } hideAllFabs() @@ -206,6 +210,7 @@ class HomeFragment : collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) + collectImmediately(musicModel.importError.flow, ::handleImportError) collect(detailModel.toShow.flow, ::handleShow) } @@ -223,6 +228,7 @@ class HomeFragment : storagePermissionLauncher = null binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeNormalToolbar.setOnMenuItemClickListener(null) + binding.homeNewPlaylistFab.setOnActionSelectedListener(null) } override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { @@ -281,6 +287,24 @@ class HomeFragment : } } + override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean { + when (actionItem.id) { + R.id.action_new_playlist -> { + logD("Creating playlist") + musicModel.createPlaylist() + } + R.id.action_import_playlist -> { + logD("Importing playlist") + filePickerLauncher?.launch("audio/x-mpegurl") + } + else -> {} + } + // Returning false to close th speed dial results in no animation, manually close instead. + // Adapted from Material Files: https://github.com/zhanghai/MaterialFiles + requireBinding().homeNewPlaylistFab.close() + return true + } + private fun setupPager(binding: FragmentHomeBinding) { binding.homePager.adapter = HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) @@ -464,6 +488,13 @@ class HomeFragment : findNavController().navigateSafe(directions) } + private fun handleImportError(flag: Unit?) { + if (flag != null) { + requireContext().showToast(R.string.err_import_failed) + musicModel.importError.consume() + } + } + private fun updateFab(songs: List, isFastScrolling: Boolean) { updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt deleted file mode 100644 index e72450450..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * ContentPathResolver.kt is part of Auxio. - * - * This program 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 program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.music.fs - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.util.logE - -/** - * Resolves a content URI into a [Path] instance. - * - * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Integrate this with [MediaStoreExtractor]. - */ -interface ContentPathResolver { - /** - * Resolve a content [Uri] into it's corresponding [Path]. - * - * @param uri The content [Uri] to resolve. - * @return The corresponding [Path], or null if the [Uri] is invalid. - */ - fun resolve(uri: Uri): Path? - - companion object { - fun from(context: Context, volumeManager: VolumeManager) = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29ContentPathResolverImpl(context.contentResolverSafe, volumeManager) - else -> Api21ContentPathResolverImpl(context.contentResolverSafe, volumeManager) - } - } -} - -private class Api21ContentPathResolverImpl( - private val contentResolver: ContentResolver, - private val volumeManager: VolumeManager -) : ContentPathResolver { - override fun resolve(uri: Uri): Path? { - val rawPath = - contentResolver.useQuery(uri, arrayOf(MediaStore.MediaColumns.DATA)) { cursor -> - cursor.moveToFirst() - cursor.getStringOrNull(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)) - } - - if (rawPath == null) { - logE("No data available for uri $uri") - return null - } - - val volumes = volumeManager.getVolumes() - for (volume in volumes) { - val volumePath = (volume.components ?: continue).toString() - val strippedPath = rawPath.removePrefix(volumePath) - if (strippedPath != rawPath) { - return Path(volume, Components.parse(strippedPath)) - } - } - - logE("No volume found for uri $uri") - return null - } -} - -private class Api29ContentPathResolverImpl( - private val contentResolver: ContentResolver, - private val volumeManager: VolumeManager -) : ContentPathResolver { - private data class RawPath(val volumeName: String?, val relativePath: String?) - - override fun resolve(uri: Uri): Path? { - val rawPath = - contentResolver.useQuery( - uri, - arrayOf( - MediaStore.MediaColumns.VOLUME_NAME, MediaStore.MediaColumns.RELATIVE_PATH)) { - cursor -> - cursor.moveToFirst() - RawPath( - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.VOLUME_NAME)), - cursor.getStringOrNull( - cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))) - } - - if (rawPath.volumeName == null || rawPath.relativePath == null) { - logE("No data available for uri $uri (raw path obtained: $rawPath)") - return null - } - - // Find the StorageVolume whose MediaStore name corresponds to this song. - // This is combined with the plain relative path column to create the directory. - val volume = volumeManager.getVolumes().find { it.mediaStoreName == rawPath.volumeName } - if (volume != null) { - return Path(volume, Components.parse(rawPath.relativePath)) - } - - logE("No volume found for uri $uri") - return null - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt rename to app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 038e90b19..ebeb348ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DocumentTreePathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * DocumentTreePathFactory.kt is part of Auxio. + * DocumentPathFactory.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.dirs +package org.oxycblt.auxio.music.fs import android.net.Uri import android.provider.DocumentsContract @@ -35,7 +35,15 @@ import org.oxycblt.auxio.music.fs.VolumeManager */ interface DocumentTreePathFactory { /** - * Unpacks a document tree URI into a [Path] instance, using [deserializeDocumentTreePath]. + * Unpacks a document URI into a [Path] instance, using [fromDocumentId]. + * + * @param uri The document URI to unpack. + * @return The [Path] instance, or null if the URI could not be unpacked. + */ + fun unpackDocumentUri(uri: Uri): Path? + + /** + * Unpacks a document tree URI into a [Path] instance, using [fromDocumentId]. * * @param uri The document tree URI to unpack. * @return The [Path] instance, or null if the URI could not be unpacked. @@ -48,7 +56,7 @@ interface DocumentTreePathFactory { * @param path The [Path] instance to serialize. * @return The serialized path. */ - fun serializeDocumentTreePath(path: Path): String + fun toDocumentId(path: Path): String /** * Deserializes a document tree URI format path into a [Path] instance. @@ -56,11 +64,13 @@ interface DocumentTreePathFactory { * @param path The path to deserialize. * @return The [Path] instance, or null if the path could not be deserialized. */ - fun deserializeDocumentTreePath(path: String): Path? + fun fromDocumentId(path: String): Path? } class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : DocumentTreePathFactory { + override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) + override fun unpackDocumentTreeUri(uri: Uri): Path? { // Convert the document tree URI into it's relative path form, which can then be // parsed into a Directory instance. @@ -68,10 +78,10 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: DocumentsContract.buildDocumentUriUsingTree( uri, DocumentsContract.getTreeDocumentId(uri)) val treeUri = DocumentsContract.getTreeDocumentId(docUri) - return deserializeDocumentTreePath(treeUri) + return fromDocumentId(treeUri) } - override fun serializeDocumentTreePath(path: Path): String = + override fun toDocumentId(path: Path): String = when (val volume = path.volume) { // The primary storage has a volume prefix of "primary", regardless // of if it's internal or not. @@ -80,7 +90,7 @@ class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: is Volume.External -> "${volume.id}:${path.components}" } - override fun deserializeDocumentTreePath(path: String): Path? { + override fun fromDocumentId(path: String): Path? { // Document tree URIs consist of a prefixed volume name followed by a relative path, // delimited with a colon. val split = path.split(File.pathSeparator, limit = 2)