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