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
new file mode 100644
index 000000000..84db744c8
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/fs/ContentPathResolver.kt
@@ -0,0 +1,111 @@
+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
+import org.oxycblt.auxio.util.logW
+import javax.inject.Inject
+
+/**
+ * Resolves a content URI into a [Path] instance.
+ * TODO: Integrate this with [MediaStoreExtractor].
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+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
+ }
+}
\ No newline at end of file
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 74703e8f0..13d0a2d2a 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
@@ -37,4 +37,7 @@ class FsModule {
@Provides
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
MediaStoreExtractor.from(context, volumeManager)
+
+ @Provides
+ fun contentPathResolver(@ApplicationContext context: Context, volumeManager: VolumeManager) = ContentPathResolver.from(context, volumeManager)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt b/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt
new file mode 100644
index 000000000..4473ed9eb
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/import/ImportModule.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * ForeignModule.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.import
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface ImportModule {
+ @Binds fun playlistImporter(playlistImporter: PlaylistImporterImpl): PlaylistImporter
+
+ @Binds fun m3u(m3u: M3UImpl): M3U
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt
new file mode 100644
index 000000000..ac897c85d
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/import/M3U.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * M3U.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.import
+
+import org.oxycblt.auxio.music.fs.Components
+import java.io.BufferedReader
+import java.io.InputStream
+import java.io.InputStreamReader
+import org.oxycblt.auxio.music.fs.Path
+import org.oxycblt.auxio.util.logW
+import javax.inject.Inject
+
+interface M3U {
+ fun read(stream: InputStream, workingDirectory: Path): List?
+}
+
+class M3UImpl @Inject constructor() : M3U {
+ override fun read(stream: InputStream, workingDirectory: Path): List? {
+ val reader = BufferedReader(InputStreamReader(stream))
+ val media = mutableListOf()
+
+ consumeFile@ while (true) {
+ collectMetadata@ while (true) {
+ val line = reader.readLine() ?: break@consumeFile
+ if (!line.startsWith("#")) {
+ break@collectMetadata
+ }
+ }
+
+ val path = reader.readLine()
+ if (path == null) {
+ logW("Expected a path, instead got an EOF")
+ break@consumeFile
+ }
+
+ val relativeComponents = Components.parse(path)
+ val absoluteComponents =
+ resolveRelativePath(relativeComponents, workingDirectory.components)
+
+ media.add(Path(workingDirectory.volume, absoluteComponents))
+ }
+
+ return media.ifEmpty { null }
+ }
+
+ private fun resolveRelativePath(relative: Components, workingDirectory: Components): Components {
+ var components = workingDirectory
+ for (component in relative.components) {
+ when (component) {
+ ".." -> components = components.parent()
+ "." -> {}
+ else -> components = components.child(component)
+ }
+ }
+ return components
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt b/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt
new file mode 100644
index 000000000..a2c2bc7df
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/import/PlaylistImporter.kt
@@ -0,0 +1,33 @@
+package org.oxycblt.auxio.music.import
+
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.oxycblt.auxio.music.Playlist
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.device.DeviceLibrary
+import org.oxycblt.auxio.music.fs.ContentPathResolver
+import org.oxycblt.auxio.music.fs.Path
+import org.oxycblt.auxio.music.fs.contentResolverSafe
+import javax.inject.Inject
+
+interface PlaylistImporter {
+ suspend fun import(uri: Uri): ImportedPlaylist?
+}
+
+data class ImportedPlaylist(val name: String?, val paths: List)
+
+class PlaylistImporterImpl @Inject constructor(
+ @ApplicationContext private val contentResolver: ContentResolver,
+ private val contentPathResolver: ContentPathResolver,
+ private val m3u: M3U
+) : PlaylistImporter {
+ override suspend fun import(uri: Uri): ImportedPlaylist? {
+ val workingDirectory = contentPathResolver.resolve(uri) ?: return null
+ return contentResolver.openInputStream(uri)?.use {
+ val paths = m3u.read(it, workingDirectory) ?: return null
+ return ImportedPlaylist(null, paths)
+ }
+ }
+}
\ No newline at end of file