diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 73af74934..e50b0c448 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -24,8 +24,8 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.dirs.DocumentTreePathFactory import org.oxycblt.auxio.music.dirs.MusicDirectories +import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -57,10 +57,8 @@ interface MusicSettings : Settings { class MusicSettingsImpl @Inject -constructor( - @ApplicationContext context: Context, - val documentTreePathFactory: DocumentTreePathFactory -) : Settings.Impl(context), MusicSettings { +constructor(@ApplicationContext context: Context, val documentPathFactory: DocumentPathFactory) : + Settings.Impl(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) override var musicDirs: MusicDirectories @@ -68,7 +66,7 @@ constructor( val dirs = (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) ?: emptySet()) - .mapNotNull(documentTreePathFactory::deserializeDocumentTreePath) + .mapNotNull(documentPathFactory::fromDocumentId) return MusicDirectories( dirs, sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false)) @@ -77,7 +75,7 @@ constructor( sharedPreferences.edit { putStringSet( getString(R.string.set_key_music_dirs), - value.dirs.map(documentTreePathFactory::serializeDocumentTreePath).toSet()) + value.dirs.map(documentPathFactory::toDocumentId).toSet()) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) apply() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 3b41b445c..452e3954a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -141,6 +141,11 @@ constructor( val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + if (songs.isEmpty()) { + _importError.put(Unit) + return@launch + } + createPlaylist(importedPlaylist.name, songs) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt index f88d71449..eec4918ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/DirectoryModule.kt @@ -18,14 +18,8 @@ package org.oxycblt.auxio.music.dirs -import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -@Module -@InstallIn(SingletonComponent::class) -interface DirectoryModule { - @Binds - fun documentTreePathFactory(factory: DocumentTreePathFactoryImpl): DocumentTreePathFactory -} +@Module @InstallIn(SingletonComponent::class) interface DirectoryModule {} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt index 1db3bb6c7..64c4bdc2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD @@ -48,7 +49,7 @@ class MusicDirsDialog : ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null - @Inject lateinit var documentTreePathFactory: DocumentTreePathFactory + @Inject lateinit var documentPathFactory: DocumentPathFactory @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = @@ -101,8 +102,7 @@ class MusicDirsDialog : if (pendingDirs != null) { dirs = MusicDirectories( - pendingDirs.mapNotNull( - documentTreePathFactory::deserializeDocumentTreePath), + pendingDirs.mapNotNull(documentPathFactory::fromDocumentId), savedInstanceState.getBoolean(KEY_PENDING_MODE)) } } @@ -126,8 +126,7 @@ class MusicDirsDialog : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putStringArrayList( - KEY_PENDING_DIRS, - ArrayList(dirAdapter.dirs.map(documentTreePathFactory::serializeDocumentTreePath))) + KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId))) outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding())) } @@ -155,7 +154,7 @@ class MusicDirsDialog : return } - val dir = documentTreePathFactory.unpackDocumentTreeUri(uri) + val dir = documentPathFactory.unpackDocumentTreeUri(uri) if (dir != null) { dirAdapter.add(dir) diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 19b0aa0ec..424062f26 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -24,39 +24,81 @@ import java.io.InputStreamReader import javax.inject.Inject import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.util.logW +/** + * Minimal M3U file format implementation. + * + * @author Alexander Capehart (OxygenCobalt) + */ interface M3U { - fun read(stream: InputStream, workingDirectory: Path): List? + /** + * Reads an M3U file from the given [stream] and returns a [ImportedPlaylist] containing the + * paths to the files listed in the M3U file. + * + * @param stream The stream to read the M3U file from. + * @param workingDirectory The directory that the M3U file is contained in. This is used to + * resolve relative paths. + * @return An [ImportedPlaylist] containing the paths to the files listed in the M3U file, + */ + fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? } class M3UImpl @Inject constructor() : M3U { - override fun read(stream: InputStream, workingDirectory: Path): List? { + override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { val reader = BufferedReader(InputStreamReader(stream)) - val media = mutableListOf() + val paths = mutableListOf() + var name: String? = null consumeFile@ while (true) { + var path: String? collectMetadata@ while (true) { - val line = reader.readLine() ?: break@consumeFile - if (!line.startsWith("#")) { + // The M3U format consists of "entries" that begin with a bunch of metadata + // prefixed with "#", and then a relative/absolute path or url to the file. + // We don't really care about the metadata except for the playlist name, so + // we discard everything but that. + val currentLine = + (reader.readLine() ?: break@consumeFile).correctWhitespace() + ?: continue@collectMetadata + if (currentLine.startsWith("#")) { + // Metadata entries are roughly structured + val split = currentLine.split(":", limit = 2) + when (split[0]) { + // Playlist name + "#PLAYLIST" -> name = split.getOrNull(1)?.correctWhitespace() + // Add more metadata handling here if needed. + else -> {} + } + } else { + // Something that isn't a metadata entry, assume it's a path. It could be + // a URL, but it'll just get mangled really badly and not match with anything, + // so it's okay. + path = currentLine break@collectMetadata } } - val path = reader.readLine() if (path == null) { logW("Expected a path, instead got an EOF") break@consumeFile } + // The path may be relative to the directory that the M3U file is contained in, + // so we may need to resolve it into an absolute path before moving ahead. val relativeComponents = Components.parse(path) val absoluteComponents = resolveRelativePath(relativeComponents, workingDirectory.components) - media.add(Path(workingDirectory.volume, absoluteComponents)) + paths.add(Path(workingDirectory.volume, absoluteComponents)) } - return media.ifEmpty { null } + return if (paths.isNotEmpty()) { + ImportedPlaylist(name, paths) + } else { + // Couldn't get anything useful out of this file. + null + } } private fun resolveRelativePath( @@ -66,8 +108,12 @@ class M3UImpl @Inject constructor() : M3U { var components = workingDirectory for (component in relative.components) { when (component) { + // Parent specifier, go "back" one directory (in practice cleave off the last + // component) ".." -> components = components.parent() + // Current directory, the components are already there. "." -> {} + // New directory, add it else -> components = components.child(component) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt index a9177e8bc..d402e901e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/PlaylistImporter.kt @@ -22,28 +22,42 @@ import android.content.Context import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import org.oxycblt.auxio.music.fs.ContentPathResolver +import org.oxycblt.auxio.music.fs.DocumentPathFactory import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.contentResolverSafe +/** + * Generic playlist file importing abstraction. + * + * @see ImportedPlaylist + * @see M3U + * @author Alexander Capehart (OxygenCobalt) + */ interface PlaylistImporter { suspend fun import(uri: Uri): ImportedPlaylist? } +/** + * A playlist that has been imported. + * + * @property name The name of the playlist. May be null if not provided. + * @property paths The paths of the files in the playlist. + * @see PlaylistImporter + * @see M3U + */ data class ImportedPlaylist(val name: String?, val paths: List) class PlaylistImporterImpl @Inject constructor( @ApplicationContext private val context: Context, - private val contentPathResolver: ContentPathResolver, + private val documentPathFactory: DocumentPathFactory, private val m3u: M3U ) : PlaylistImporter { override suspend fun import(uri: Uri): ImportedPlaylist? { - val workingDirectory = contentPathResolver.resolve(uri) ?: return null + val filePath = documentPathFactory.unpackDocumentUri(uri) ?: return null return context.contentResolverSafe.openInputStream(uri)?.use { - val paths = m3u.read(it, workingDirectory) ?: return null - return ImportedPlaylist(null, paths) + return m3u.read(it, filePath.directory) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index ebeb348ea..9505ac28a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -22,18 +22,13 @@ import android.net.Uri import android.provider.DocumentsContract import java.io.File import javax.inject.Inject -import org.oxycblt.auxio.music.fs.Components -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.fs.Volume -import org.oxycblt.auxio.music.fs.VolumeManager /** - * A factory for parsing the reverse-engineered format of the URIs obtained from the document tree - * (i.e directory) folder. + * A factory for parsing the reverse-engineered format of the URIs obtained from document picker. * * @author Alexander Capehart (OxygenCobalt) */ -interface DocumentTreePathFactory { +interface DocumentPathFactory { /** * Unpacks a document URI into a [Path] instance, using [fromDocumentId]. * @@ -67,8 +62,8 @@ interface DocumentTreePathFactory { fun fromDocumentId(path: String): Path? } -class DocumentTreePathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : - DocumentTreePathFactory { +class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : + DocumentPathFactory { override fun unpackDocumentUri(uri: Uri) = fromDocumentId(DocumentsContract.getDocumentId(uri)) override fun unpackDocumentTreeUri(uri: Uri): Path? { diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index a22c7675e..072164323 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.fs import android.content.ContentResolver import android.content.Context import android.os.storage.StorageManager +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -39,11 +40,14 @@ class FsModule { fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = MediaStoreExtractor.from(context, volumeManager) - @Provides - fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = - ContentPathResolver.from(context, volumeManager) - @Provides fun contentResolver(@ApplicationContext context: Context): ContentResolver = context.contentResolverSafe } + +@Module +@InstallIn(SingletonComponent::class) +interface FsBindsModule { + @Binds + fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 61ed0e770..5ca564aea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,6 +310,7 @@ No music found Music loading failed Auxio needs permission to read your music library + Could not import a playlist from this file No app found that can handle this task No folders