From 6f25e2eb1c33b38a6d0e3933870e068b6c7b2219 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:03:11 +0300 Subject: [PATCH 1/7] Add pure SAF source primitives: stable song ids and source-scoped reconcile Groundwork for replacing the device-wide MediaStore scan with user-added folders and files (additive, nothing wired yet): - StableIds.songId: 63-bit hash of provider authority + documentId. A persisted format (favorites/playlists key on it), pinned by golden values computed with an independent MD5 implementation. - isAudioDoc / joinRelPath / audioCandidateOf: audio filter (mime, then extension for generic types; dot-files skipped), root-relative folder paths, SAF row -> candidate mapping. - SourceReconciler: incremental diff where a stored row may only be deleted when its source was enumerated completely, so an unavailable or partially listed source can never wipe the library. Co-Authored-By: Claude Sonnet 5 --- .../player/core/media/SafCandidates.kt | 81 +++++++++++++ .../player/data/media/SourceReconciler.kt | 61 ++++++++++ .../player/data/SafCandidatesTest.kt | 103 +++++++++++++++++ .../player/data/SourceReconcilerTest.kt | 107 ++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 app/src/main/java/com/resonance/player/core/media/SafCandidates.kt create mode 100644 app/src/main/java/com/resonance/player/data/media/SourceReconciler.kt create mode 100644 app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt create mode 100644 app/src/test/java/com/resonance/player/data/SourceReconcilerTest.kt diff --git a/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt b/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt new file mode 100644 index 0000000..52db4b8 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt @@ -0,0 +1,81 @@ +package com.resonance.player.core.media + +import java.util.UUID + +/** + * One audio file found in a user-added source (a folder tree or a single + * file). Discovery-only row: tags and artwork are extracted later, and only + * for new or changed items. Times are epoch SECONDS, sizes are bytes. + */ +data class AudioCandidate( + val id: Long, + val sourceId: Long, + val contentUri: String, + val displayName: String, + val mimeType: String?, + val sizeBytes: Long, + val dateModifiedSec: Long, + val relativePath: String? +) + +/** One Storage Access Framework directory-listing row, before any Android type touches it. */ +data class DocRow( + val documentId: String, + val name: String?, + val mimeType: String?, + val lastModifiedMs: Long?, + val sizeBytes: Long? +) + +/** Same value as DocumentsContract.Document.MIME_TYPE_DIR (kept literal so this file stays JVM-testable). */ +const val DOCUMENT_DIR_MIME = "vnd.android.document/directory" + +object StableIds { + /** + * Stable song id: 63-bit positive hash of provider authority + documentId + * (NOT the tree URI), so a file reached through a folder grant and through + * a single-file grant is one song, and removing then re-adding a source + * gives the same ids back. This is a PERSISTED FORMAT (favorites, playlists + * and history key on it): changing it orphans user data. The golden-value + * test in SafCandidatesTest pins it. + */ + fun songId(authority: String, documentId: String): Long = + UUID.nameUUIDFromBytes("$authority/$documentId".toByteArray(Charsets.UTF_8)) + .mostSignificantBits and Long.MAX_VALUE +} + +/** + * Audio filter for SAF listings. MIME first; providers that report no type or + * application/octet-stream fall back to the extension allow-list. Dot-files + * are skipped: AppleDouble `._x.mp3` on exFAT cards and `.trashed-*` files are + * listed by SAF (MediaStore used to hide them) and would fail extraction on + * every scan. + */ +fun isAudioDoc(name: String?, mimeType: String?): Boolean { + if (name?.startsWith(".") == true) return false + if (SupportedMimeTypes.isAudioMime(mimeType)) return true + val generic = mimeType.isNullOrBlank() || mimeType == "application/octet-stream" + return generic && name != null && SupportedFormats.isSupported(name) +} + +/** Root-relative folder path in the same shape MediaStore used: "Music/Rock/". */ +fun joinRelPath(parent: String?, dirName: String): String = + (parent ?: "") + dirName.trim('/') + "/" + +fun audioCandidateOf( + row: DocRow, + authority: String, + sourceId: Long, + contentUri: String, + relativePath: String? +): AudioCandidate = AudioCandidate( + id = StableIds.songId(authority, row.documentId), + sourceId = sourceId, + contentUri = contentUri, + displayName = row.name?.takeIf { it.isNotBlank() } + ?: row.documentId.substringAfterLast('/').substringAfterLast(':'), + mimeType = row.mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" }, + sizeBytes = (row.sizeBytes ?: 0L).coerceAtLeast(0L), + dateModifiedSec = (row.lastModifiedMs ?: 0L).coerceAtLeast(0L) / 1000L, + relativePath = relativePath +) diff --git a/app/src/main/java/com/resonance/player/data/media/SourceReconciler.kt b/app/src/main/java/com/resonance/player/data/media/SourceReconciler.kt new file mode 100644 index 0000000..e9583fb --- /dev/null +++ b/app/src/main/java/com/resonance/player/data/media/SourceReconciler.kt @@ -0,0 +1,61 @@ +package com.resonance.player.data.media + +import com.resonance.player.core.media.AudioCandidate + +/** Minimal stored state needed to diff without loading full entities. */ +data class StoredSong( + val id: Long, + val sourceId: Long, + val dateModifiedSec: Long, + val sizeBytes: Long, + val durationMs: Long +) + +/** Pure diff result: what to (re)import vs which stored rows to delete. */ +data class SourcePlan( + val toImport: List, + val toDelete: List +) + +/** + * Pure, JVM-tested incremental reconciliation across user-added sources. + * Identity is the stable song id; a row is UNCHANGED only when modified time, + * size AND owning source all match. + * + * Safety rule: a stored row may only be deleted because its file vanished when + * its source was enumerated COMPLETELY. Rows of an unavailable (grant lost, + * unmounted) or partially listed source are never deleted, so one flaky + * provider cannot wipe the library. + */ +object SourceReconciler { + + /** "Ignore files under 30s" threshold (ringtones / voice memos filter). */ + const val MIN_DURATION_MS = 30_000L + + /** Unknown durations (<= 0) always pass: an unreadable length never drops a file. */ + fun passesDurationFilter(durationMs: Long, ignoreShort: Boolean): Boolean = + !ignoreShort || durationMs <= 0L || durationMs >= MIN_DURATION_MS + + fun plan( + stored: List, + seen: Map, + completeSourceIds: Set, + ignoreShort: Boolean + ): SourcePlan { + val storedById = stored.associateBy { it.id } + val toImport = seen.values.filter { candidate -> + val existing = storedById[candidate.id] + existing == null || + existing.dateModifiedSec != candidate.dateModifiedSec || + existing.sizeBytes != candidate.sizeBytes || + existing.sourceId != candidate.sourceId + } + val toDelete = stored + .filter { song -> + (song.sourceId in completeSourceIds && song.id !in seen) || + !passesDurationFilter(song.durationMs, ignoreShort) + } + .map { it.id } + return SourcePlan(toImport, toDelete) + } +} diff --git a/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt b/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt new file mode 100644 index 0000000..8b3acee --- /dev/null +++ b/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt @@ -0,0 +1,103 @@ +package com.resonance.player.data + +import com.resonance.player.core.media.DOCUMENT_DIR_MIME +import com.resonance.player.core.media.DocRow +import com.resonance.player.core.media.StableIds +import com.resonance.player.core.media.audioCandidateOf +import com.resonance.player.core.media.isAudioDoc +import com.resonance.player.core.media.joinRelPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SafCandidatesTest { + + private val storage = "com.android.externalstorage.documents" + + /** Golden values computed with an independent MD5 implementation: this hash is a persisted format. */ + @Test + fun stableId_goldenValues() { + assertEquals(5731844828744923300L, StableIds.songId(storage, "primary:Music/Rock/a.mp3")) + assertEquals(6270970878750308638L, StableIds.songId(storage, "primary:Music/Rock/b.mp3")) + assertEquals( + 4394339703935811002L, + StableIds.songId("com.android.providers.media.documents", "audio:1234") + ) + } + + @Test + fun stableId_isPositive_deterministic_andDependsOnAuthority() { + val a = StableIds.songId(storage, "primary:Music/x.mp3") + assertTrue(a > 0) + assertEquals(a, StableIds.songId(storage, "primary:Music/x.mp3")) + assertNotEquals(a, StableIds.songId("other.provider", "primary:Music/x.mp3")) + } + + @Test + fun isAudioDoc_usesMimeThenExtensionForGenericTypes() { + assertTrue(isAudioDoc("a.mp3", "audio/mpeg")) + assertTrue(isAudioDoc("a", "application/ogg")) + assertTrue(isAudioDoc(null, "audio/flac")) + assertTrue(isAudioDoc("Song.FLAC", "application/octet-stream")) + assertTrue(isAudioDoc("song.m4a", null)) + assertFalse(isAudioDoc("list.m3u", "audio/x-mpegurl")) + assertFalse(isAudioDoc("notes.txt", "text/plain")) + assertFalse(isAudioDoc("notes.txt", null)) + assertFalse(isAudioDoc("readme", "application/octet-stream")) + } + + @Test + fun isAudioDoc_skipsDotFiles() { + assertFalse(isAudioDoc("._a.mp3", "audio/mpeg")) + assertFalse(isAudioDoc(".trashed-1234-a.mp3", "audio/mpeg")) + } + + @Test + fun joinRelPath_buildsRootRelativeFolders() { + assertEquals("Music/", joinRelPath(null, "Music")) + assertEquals("Music/Rock/", joinRelPath("Music/", "Rock")) + assertEquals("Music/Rock/Live/", joinRelPath("Music/Rock/", "/Live/")) + } + + @Test + fun audioCandidateOf_mapsRow() { + val c = audioCandidateOf( + row = DocRow("primary:Music/Rock/a.mp3", "a.mp3", "audio/mpeg", 1_700_000_123_456L, 4096L), + authority = storage, + sourceId = 7L, + contentUri = "content://x/tree/t/document/d", + relativePath = "Music/Rock/" + ) + assertEquals(5731844828744923300L, c.id) + assertEquals(7L, c.sourceId) + assertEquals("a.mp3", c.displayName) + assertEquals("audio/mpeg", c.mimeType) + assertEquals(4096L, c.sizeBytes) + assertEquals(1_700_000_123L, c.dateModifiedSec) + assertEquals("Music/Rock/", c.relativePath) + } + + @Test + fun audioCandidateOf_toleratesMissingFields() { + val c = audioCandidateOf( + row = DocRow("primary:Music/no-name", null, "application/octet-stream", null, null), + authority = storage, + sourceId = 1L, + contentUri = "content://x/d", + relativePath = null + ) + assertEquals("no-name", c.displayName) + assertNull(c.mimeType) + assertEquals(0L, c.sizeBytes) + assertEquals(0L, c.dateModifiedSec) + assertNull(c.relativePath) + } + + @Test + fun directoryMime_isTheSafConstant() { + assertEquals("vnd.android.document/directory", DOCUMENT_DIR_MIME) + } +} diff --git a/app/src/test/java/com/resonance/player/data/SourceReconcilerTest.kt b/app/src/test/java/com/resonance/player/data/SourceReconcilerTest.kt new file mode 100644 index 0000000..742e267 --- /dev/null +++ b/app/src/test/java/com/resonance/player/data/SourceReconcilerTest.kt @@ -0,0 +1,107 @@ +package com.resonance.player.data + +import com.resonance.player.core.media.AudioCandidate +import com.resonance.player.data.media.SourceReconciler +import com.resonance.player.data.media.StoredSong +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private fun stored( + id: Long, + source: Long = 1L, + modified: Long = 100L, + size: Long = 1000L, + duration: Long = 200_000L +) = StoredSong(id, source, modified, size, duration) + +private fun cand(id: Long, source: Long = 1L, modified: Long = 100L, size: Long = 1000L) = + AudioCandidate( + id = id, sourceId = source, contentUri = "content://x/$id", displayName = "$id.mp3", + mimeType = "audio/mpeg", sizeBytes = size, dateModifiedSec = modified, relativePath = null + ) + +private fun seenOf(vararg c: AudioCandidate) = c.associateBy { it.id } + +class SourceReconcilerTest { + + private val complete = setOf(1L, 2L) + + @Test + fun emptyDb_importsEverything() { + val plan = SourceReconciler.plan(emptyList(), seenOf(cand(1), cand(2)), complete, false) + assertEquals(2, plan.toImport.size) + assertTrue(plan.toDelete.isEmpty()) + } + + @Test + fun unchangedItems_areSkipped() { + val plan = SourceReconciler.plan(listOf(stored(1)), seenOf(cand(1)), complete, false) + assertTrue(plan.toImport.isEmpty()) + assertTrue(plan.toDelete.isEmpty()) + } + + @Test + fun changedTimeSizeOrOwner_isReimported() { + val s = listOf(stored(1), stored(2), stored(3)) + val plan = SourceReconciler.plan( + s, + seenOf(cand(1, modified = 999), cand(2, size = 5), cand(3, source = 2)), + complete, + false + ) + assertEquals(setOf(1L, 2L, 3L), plan.toImport.map { it.id }.toSet()) + assertTrue(plan.toDelete.isEmpty()) + } + + @Test + fun vanishedFile_ofCompleteSource_isDeleted() { + val plan = SourceReconciler.plan( + listOf(stored(1), stored(2)), seenOf(cand(1)), complete, false + ) + assertEquals(listOf(2L), plan.toDelete) + } + + @Test + fun rowsOfIncompleteOrUnavailableSource_areNeverDeleted() { + // source 2 was not enumerated completely: its rows must survive. + val plan = SourceReconciler.plan( + listOf(stored(1, source = 1), stored(2, source = 2)), + seenOf(), + setOf(1L), + false + ) + assertEquals(listOf(1L), plan.toDelete) + } + + @Test + fun fileNowReachableThroughAnotherSource_isMovedNotDeleted() { + val plan = SourceReconciler.plan( + listOf(stored(1, source = 1)), seenOf(cand(1, source = 2)), complete, false + ) + assertTrue(plan.toDelete.isEmpty()) + assertEquals(listOf(1L), plan.toImport.map { it.id }) + } + + @Test + fun ignoreShort_prunesStoredShortFiles_butKeepsUnknownDurations() { + val s = listOf( + stored(1, duration = 10_000L), + stored(2, duration = 0L), + stored(3, duration = 30_000L) + ) + val plan = SourceReconciler.plan(s, seenOf(cand(1), cand(2), cand(3)), complete, true) + assertEquals(listOf(1L), plan.toDelete) + val off = SourceReconciler.plan(s, seenOf(cand(1), cand(2), cand(3)), complete, false) + assertTrue(off.toDelete.isEmpty()) + } + + @Test + fun durationFilterBoundaries() { + assertTrue(SourceReconciler.passesDurationFilter(5_000L, ignoreShort = false)) + assertFalse(SourceReconciler.passesDurationFilter(29_999L, ignoreShort = true)) + assertTrue(SourceReconciler.passesDurationFilter(30_000L, ignoreShort = true)) + assertTrue(SourceReconciler.passesDurationFilter(0L, ignoreShort = true)) + } +} From 6e6eddad683cfa8268af049fa2182248799df0e1 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:04:16 +0300 Subject: [PATCH 2/7] Add SafAudioDataSource: list audio of user-added folders and files One DocumentsContract child query per directory (no DocumentFile), an explicit stack with a visited set, cancellation checked per directory and row. Reports WALK status (complete / incomplete / unavailable) so a scan can tell "file is gone" from "provider or grant is gone". Not wired yet. Co-Authored-By: Claude Sonnet 5 --- .../player/data/media/SafAudioDataSource.kt | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt diff --git a/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt new file mode 100644 index 0000000..a7c5882 --- /dev/null +++ b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt @@ -0,0 +1,136 @@ +package com.resonance.player.data.media + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import com.resonance.player.core.media.AudioCandidate +import com.resonance.player.core.media.DOCUMENT_DIR_MIME +import com.resonance.player.core.media.DocRow +import com.resonance.player.core.media.audioCandidateOf +import com.resonance.player.core.media.isAudioDoc +import com.resonance.player.core.media.joinRelPath +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** + * How completely one source could be listed. Drives which stored rows a scan + * may delete (see [SourceReconciler]): only COMPLETE sources can prove a file + * is gone. + */ +enum class WalkStatus { COMPLETE, INCOMPLETE, UNAVAILABLE } + +/** + * Lists the audio files of user-added sources through the Storage Access + * Framework. The only class that talks DocumentsContract/ContentResolver for + * discovery. + * + * Trees are walked with ONE child query per directory (DocumentFile issues a + * query per property and is far slower). Cancellation is checked per + * directory and per row; the cursor closes on cancel. + */ +class SafAudioDataSource(private val resolver: ContentResolver) { + + /** True while the app still holds a persisted READ grant for [uri]. */ + fun hasReadGrant(uri: String): Boolean = + resolver.persistedUriPermissions.any { it.isReadPermission && it.uri.toString() == uri } + + suspend fun walkTree( + sourceId: Long, + treeUriString: String, + rootName: String, + onCandidate: (AudioCandidate) -> Unit + ): WalkStatus { + val treeUri = Uri.parse(treeUriString) + val authority = treeUri.authority ?: return WalkStatus.UNAVAILABLE + val rootId = try { + DocumentsContract.getTreeDocumentId(treeUri) + } catch (e: IllegalArgumentException) { + return WalkStatus.UNAVAILABLE + } + var status = WalkStatus.COMPLETE + val visited = HashSet() + val stack = ArrayDeque>() + stack.addLast(rootId to joinRelPath(null, rootName)) + while (stack.isNotEmpty()) { + currentCoroutineContext().ensureActive() + val (dirId, relPath) = stack.removeLast() + if (!visited.add(dirId)) continue + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, dirId) + val cursor = query(childrenUri) + if (cursor == null) { + if (dirId == rootId) return WalkStatus.UNAVAILABLE + status = WalkStatus.INCOMPLETE + continue + } + try { + cursor.use { c -> + while (c.moveToNext()) { + currentCoroutineContext().ensureActive() + val row = c.toDocRow() + if (row.mimeType == DOCUMENT_DIR_MIME) { + stack.addLast( + row.documentId to joinRelPath(relPath, row.name ?: row.documentId) + ) + } else if (isAudioDoc(row.name, row.mimeType)) { + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, row.documentId) + onCandidate(audioCandidateOf(row, authority, sourceId, docUri.toString(), relPath)) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + status = WalkStatus.INCOMPLETE + } + } + return status + } + + /** A single individually added file. An empty result means the file is gone (COMPLETE, no candidate). */ + suspend fun walkFile( + sourceId: Long, + documentUriString: String, + onCandidate: (AudioCandidate) -> Unit + ): WalkStatus { + val uri = Uri.parse(documentUriString) + val authority = uri.authority ?: return WalkStatus.UNAVAILABLE + val cursor = query(uri) ?: return WalkStatus.UNAVAILABLE + cursor.use { c -> + if (c.moveToFirst()) { + val row = c.toDocRow() + if (isAudioDoc(row.name, row.mimeType)) { + onCandidate(audioCandidateOf(row, authority, sourceId, documentUriString, null)) + } + } + } + return WalkStatus.COMPLETE + } + + /** Null when the provider is unreachable, the grant is gone or the URI is stale. */ + private suspend fun query(uri: Uri): Cursor? = try { + resolver.query(uri, COLUMNS, null, null, null) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + null + } + + private fun Cursor.toDocRow() = DocRow( + documentId = getString(0), + name = getString(1), + mimeType = getString(2), + lastModifiedMs = if (isNull(3)) null else getLong(3), + sizeBytes = if (isNull(4)) null else getLong(4) + ) + + private companion object { + val COLUMNS = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_SIZE + ) + } +} From cf1ac524f2da6fbf5d6902ffbfd04ac392b776b6 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:19:32 +0300 Subject: [PATCH 3/7] Build the library from user-added sources instead of a device scan DB v3: new `sources` table (folder = TREE, single song = FILE) and songs now carry a `sourceId` FK with ON DELETE CASCADE; MediaStore columns are gone and `Song.id` is the stable SAF hash. MIGRATION_2_3 is a clean start (old songs dropped, playlist items / favorites / history cleared, playlist names kept), with DDL copied from the Room-generated code. Verified on a phone against a real v2 DB: user_version 3, foreign_key_check empty, schema identical to a fresh install. SafLibraryScanner replaces the MediaStore scanner: single-flight, walks every source that still has a read grant, source-scoped reconcile (only a COMPLETE source can delete), first-seen dateAdded, ignore-short applied after extraction. RoomSourceRepository takes/releases the persistable grant and cancels a running scan first; Add/Remove use cases rescan afterwards. Nothing scans automatically any more: onAppStarted / onForegrounded are gone. Removes the MediaStore pipeline (scanner, data source, candidate mapping, old Reconciler) and the dead SongDao/SongMapper helpers. PermissionDenied now means "lost access to this file's folder". Co-Authored-By: Claude Sonnet 5 --- .../java/com/resonance/player/MainActivity.kt | 5 +- .../com/resonance/player/app/AppContainer.kt | 56 +-- .../com/resonance/player/app/ResonanceApp.kt | 3 - .../resonance/player/core/common/AppError.kt | 8 +- .../player/core/database/Migrations.kt | 34 ++ .../player/core/database/ResonanceDatabase.kt | 14 +- .../player/core/database/SongMapper.kt | 51 +-- .../player/core/database/dao/SongDao.kt | 25 +- .../player/core/database/dao/SourceDao.kt | 44 +++ .../player/core/database/entity/SongEntity.kt | 38 +- .../core/database/entity/SourceEntity.kt | 22 ++ .../player/core/media/MediaBoundaries.kt | 6 +- .../player/core/media/MediaCandidates.kt | 87 ----- .../player/core/media/SafCandidates.kt | 10 +- .../player/core/model/MusicSource.kt | 18 + .../com/resonance/player/core/model/Song.kt | 15 +- .../player/data/local/RoomSourceRepository.kt | 90 +++++ .../data/media/MediaStoreAudioDataSource.kt | 124 ------ .../data/media/MediaStoreLibraryScanner.kt | 364 ------------------ .../player/data/media/MetadataNormalizer.kt | 31 +- .../resonance/player/data/media/Reconciler.kt | 60 --- .../player/data/media/SafAudioDataSource.kt | 21 +- .../player/data/media/SafLibraryScanner.kt | 263 +++++++++++++ .../player/domain/library/Sources.kt | 47 +++ .../resonance/player/core/FoundationTest.kt | 14 +- .../resonance/player/core/GroupMappingTest.kt | 2 +- .../player/core/ScanPermissionTest.kt | 2 - .../player/data/CandidateMapperTest.kt | 105 ----- .../player/data/NormalizationTest.kt | 54 +-- .../resonance/player/data/ReconcilerTest.kt | 106 ----- .../player/data/SafCandidatesTest.kt | 9 + .../java/com/resonance/player/fakes/Fakes.kt | 4 +- 32 files changed, 683 insertions(+), 1049 deletions(-) create mode 100644 app/src/main/java/com/resonance/player/core/database/dao/SourceDao.kt create mode 100644 app/src/main/java/com/resonance/player/core/database/entity/SourceEntity.kt delete mode 100644 app/src/main/java/com/resonance/player/core/media/MediaCandidates.kt create mode 100644 app/src/main/java/com/resonance/player/core/model/MusicSource.kt create mode 100644 app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt delete mode 100644 app/src/main/java/com/resonance/player/data/media/MediaStoreAudioDataSource.kt delete mode 100644 app/src/main/java/com/resonance/player/data/media/MediaStoreLibraryScanner.kt delete mode 100644 app/src/main/java/com/resonance/player/data/media/Reconciler.kt create mode 100644 app/src/main/java/com/resonance/player/data/media/SafLibraryScanner.kt create mode 100644 app/src/main/java/com/resonance/player/domain/library/Sources.kt delete mode 100644 app/src/test/java/com/resonance/player/data/CandidateMapperTest.kt delete mode 100644 app/src/test/java/com/resonance/player/data/ReconcilerTest.kt diff --git a/app/src/main/java/com/resonance/player/MainActivity.kt b/app/src/main/java/com/resonance/player/MainActivity.kt index c64c615..fc1ec5c 100644 --- a/app/src/main/java/com/resonance/player/MainActivity.kt +++ b/app/src/main/java/com/resonance/player/MainActivity.kt @@ -22,8 +22,8 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() // Permission may have changed while away (Settings toggle): refresh - // the centralized status, then let the single-flight scanner - // reconcile incrementally (never duplicated, never a loop). + // the centralized status. No scan here: the library only changes when + // the user adds/removes a source or presses Refresh. val container = (application as ResonanceApp).container lifecycleScope.launch { container.permissionManager.refresh( @@ -32,7 +32,6 @@ class MainActivity : ComponentActivity() { MusicPermissions.audioPermissionForSdk(Build.VERSION.SDK_INT) ) ) - container.onForegrounded() } } diff --git a/app/src/main/java/com/resonance/player/app/AppContainer.kt b/app/src/main/java/com/resonance/player/app/AppContainer.kt index d274d93..c7043db 100644 --- a/app/src/main/java/com/resonance/player/app/AppContainer.kt +++ b/app/src/main/java/com/resonance/player/app/AppContainer.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import com.resonance.player.core.common.DefaultAppDispatchers import com.resonance.player.core.database.MIGRATION_1_2 +import com.resonance.player.core.database.MIGRATION_2_3 import com.resonance.player.core.database.ResonanceDatabase import com.resonance.player.core.playback.PlaybackController import com.resonance.player.data.local.AppAudioPermissionManager @@ -12,16 +13,18 @@ import com.resonance.player.data.local.LibraryPreferences import com.resonance.player.data.local.RoomFavoritesRepository import com.resonance.player.data.local.RoomMusicRepository import com.resonance.player.data.local.RoomPlaylistRepository +import com.resonance.player.data.local.RoomSourceRepository import com.resonance.player.data.local.StorageStatsProvider import com.resonance.player.data.media.AndroidMetadataExtractor import com.resonance.player.data.media.ArtworkStore -import com.resonance.player.data.media.MediaStoreAudioDataSource -import com.resonance.player.data.media.MediaStoreLibraryScanner import com.resonance.player.data.media.RetrieverArtworkExtractor +import com.resonance.player.data.media.SafAudioDataSource +import com.resonance.player.data.media.SafLibraryScanner import com.resonance.player.domain.library.GetAlbumSongsUseCase import com.resonance.player.domain.library.GetFolderSongsUseCase import com.resonance.player.domain.library.GetArtistSongsUseCase import com.resonance.player.domain.library.GetGenreSongsUseCase +import com.resonance.player.domain.library.AddSourcesUseCase import com.resonance.player.domain.library.GetLibraryStatsUseCase import com.resonance.player.domain.library.GetSongUseCase import com.resonance.player.domain.library.ObserveAlbumsUseCase @@ -38,7 +41,9 @@ import com.resonance.player.domain.library.ObserveRecentlyPlayedUseCase import com.resonance.player.domain.library.ObserveStorageOverviewUseCase import com.resonance.player.domain.library.ObserveScanStateUseCase import com.resonance.player.domain.library.ObserveSongsUseCase +import com.resonance.player.domain.library.ObserveSourcesUseCase import com.resonance.player.domain.library.RecordPlayUseCase +import com.resonance.player.domain.library.RemoveSourceUseCase import com.resonance.player.domain.library.RescanLibraryUseCase import com.resonance.player.domain.playback.AppendToQueueUseCase import com.resonance.player.domain.playback.ClearQueueUseCase @@ -69,7 +74,6 @@ import com.resonance.player.playback.PlaybackStateStore import com.resonance.player.playback.RealPlaybackController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import java.io.File /** @@ -81,7 +85,8 @@ import java.io.File * Playback: UI -> ViewModel -> UseCase -> PlaybackController -> Media3 * service. Nothing outside the playback package knows about ExoPlayer. * Scanning: UI -> ViewModel -> UseCase -> Repository -> LibraryScanner -> - * MediaStore / Room. Composables never touch ContentResolver or cursors. + * Storage Access Framework / Room. Composables never touch ContentResolver or + * cursors. */ class AppContainer(context: Context) { @@ -98,7 +103,7 @@ class AppContainer(context: Context) { val database: ResonanceDatabase by lazy { Room.databaseBuilder(appContext, ResonanceDatabase::class.java, "resonance.db") - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .fallbackToDestructiveMigrationOnDowngrade(dropAllTables = true) .build() } @@ -111,8 +116,8 @@ class AppContainer(context: Context) { AppAudioPermissionManager(appContext, libraryPreferences) } - private val mediaStoreDataSource: MediaStoreAudioDataSource by lazy { - MediaStoreAudioDataSource(appContext) + private val safDataSource: SafAudioDataSource by lazy { + SafAudioDataSource(appContext.contentResolver) } private val metadataExtractor: AndroidMetadataExtractor by lazy { @@ -127,13 +132,12 @@ class AppContainer(context: Context) { RetrieverArtworkExtractor(appContext) } - val libraryScanner: MediaStoreLibraryScanner by lazy { - MediaStoreLibraryScanner( - appContext, + val libraryScanner: SafLibraryScanner by lazy { + SafLibraryScanner( dispatchers, applicationScope, database, - mediaStoreDataSource, + safDataSource, metadataExtractor, artworkExtractor, artworkStore, @@ -141,6 +145,16 @@ class AppContainer(context: Context) { ) } + val sourceRepository: RoomSourceRepository by lazy { + RoomSourceRepository( + appContext.contentResolver, + database, + libraryScanner, + safDataSource, + dispatchers + ) + } + val favoritesRepository: RoomFavoritesRepository by lazy { RoomFavoritesRepository(database, dispatchers) } @@ -198,6 +212,9 @@ class AppContainer(context: Context) { val getFolderSongs = GetFolderSongsUseCase(musicRepository) val observeScanState = ObserveScanStateUseCase(musicRepository) val rescanLibrary = RescanLibraryUseCase(musicRepository) + val observeSources = ObserveSourcesUseCase(sourceRepository) + val addSources = AddSourcesUseCase(sourceRepository, musicRepository) + val removeSource = RemoveSourceUseCase(sourceRepository, musicRepository) val getLibraryStats = GetLibraryStatsUseCase(musicRepository) val observeLastScan = ObserveLastScanUseCase(musicRepository) val observeRecentlyPlayed = ObserveRecentlyPlayedUseCase(musicRepository) @@ -229,21 +246,4 @@ class AppContainer(context: Context) { val skipToQueueItem = SkipToQueueItemUseCase(playbackController) val appendToQueue = AppendToQueueUseCase(playbackController) val insertIntoQueue = InsertIntoQueueUseCase(playbackController) - - /** - * Cold-start sync: the cached Room library renders immediately; the - * incremental scan reconciles in the background (no-op without - * permission — the scanner reports PermissionRequired, never a loop). - */ - fun onAppStarted() { - applicationScope.launch { musicRepository.scanAndImport() } - } - - /** - * Foreground re-check (permission may have changed while away). The - * scanner is single-flight: concurrent calls observe the running scan. - */ - fun onForegrounded() { - applicationScope.launch { musicRepository.scanAndImport() } - } } diff --git a/app/src/main/java/com/resonance/player/app/ResonanceApp.kt b/app/src/main/java/com/resonance/player/app/ResonanceApp.kt index 427c1e1..cc32700 100644 --- a/app/src/main/java/com/resonance/player/app/ResonanceApp.kt +++ b/app/src/main/java/com/resonance/player/app/ResonanceApp.kt @@ -10,8 +10,5 @@ class ResonanceApp : Application() { override fun onCreate() { super.onCreate() container = AppContainer(this) - // Cached library renders first; the incremental scan reconciles - // in the background on the application scope (never blocking UI). - container.onAppStarted() } } diff --git a/app/src/main/java/com/resonance/player/core/common/AppError.kt b/app/src/main/java/com/resonance/player/core/common/AppError.kt index cba2f3c..a462c15 100644 --- a/app/src/main/java/com/resonance/player/core/common/AppError.kt +++ b/app/src/main/java/com/resonance/player/core/common/AppError.kt @@ -9,7 +9,7 @@ sealed interface AppError { /** File indexed in the database no longer exists on storage. */ data class MissingFile(val path: String) : AppError - /** Audio / storage / notification permission was denied or revoked. */ + /** Access to a file's folder (or a notification permission) was denied or lost. */ data object PermissionDenied : AppError /** Container or codec the device cannot decode. */ @@ -30,9 +30,6 @@ sealed interface AppError { /** Library query succeeded but contains zero songs. */ data object EmptyLibrary : AppError - /** MediaStore query failed entirely (provider error, security, I/O). */ - data class MediaStoreUnavailable(val reason: String?) : AppError - /** A scan run failed after starting (state is Failed, partial data kept). */ data class ScanFailed(val reason: String?) : AppError @@ -49,7 +46,7 @@ sealed interface AppError { /** Stable, localizable-later human message. Pure function, unit-tested. */ fun AppError.userMessage(): String = when (this) { is AppError.MissingFile -> "This file is no longer on your device." - AppError.PermissionDenied -> "Audio access was denied. Grant access in Settings to scan your music." + AppError.PermissionDenied -> "Lost access to this file's folder. Re-add it in Settings > Music sources." is AppError.UnsupportedFormat -> if (mimeType.isNullOrBlank()) "This audio format is not supported." else "This audio format is not supported ($mimeType)." @@ -59,7 +56,6 @@ fun AppError.userMessage(): String = when (this) { is AppError.InvalidMetadata -> "Some tags could not be read; the file can still play." AppError.CorruptedArtwork -> "The embedded artwork could not be decoded." AppError.EmptyLibrary -> "Your library is empty." - is AppError.MediaStoreUnavailable -> "Your music could not be read right now." is AppError.ScanFailed -> if (reason.isNullOrBlank()) "Scanning your music failed." else "Scanning failed: $reason" is AppError.FeatureUnavailable -> "$feature is not available yet." diff --git a/app/src/main/java/com/resonance/player/core/database/Migrations.kt b/app/src/main/java/com/resonance/player/core/database/Migrations.kt index 9e8b385..07f0fcf 100644 --- a/app/src/main/java/com/resonance/player/core/database/Migrations.kt +++ b/app/src/main/java/com/resonance/player/core/database/Migrations.kt @@ -25,3 +25,37 @@ val MIGRATION_1_2: Migration = object : Migration(1, 2) { db.execSQL("CREATE INDEX IF NOT EXISTS index_songs_artworkKey ON songs(artworkKey)") } } + +/** + * Schema v2 -> v3 (music sources, ADR-010): the library is no longer built by + * a device-wide scan. CLEAN START by decision: the old songs (MediaStore ids, + * no source) are dropped and the tables that pointed at them (playlist items, + * favorites, play history) are emptied; playlist NAMES are kept. New `sources` + * table, new `songs` table with a `sourceId` FK (ON DELETE CASCADE). + * + * The CREATE statements are copied verbatim from the Room-generated + * `ResonanceDatabase_Impl.createAllTables` so a migrated DB is identical to a + * fresh install (Room validates it on open). If an entity changes, re-copy. + */ +val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `songs`") + db.execSQL("CREATE TABLE IF NOT EXISTS `sources` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `uri` TEXT NOT NULL, `displayName` TEXT NOT NULL, `addedAtEpochSec` INTEGER NOT NULL, `lastScannedAtEpochSec` INTEGER)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_sources_uri` ON `sources` (`uri`)") + db.execSQL("CREATE TABLE IF NOT EXISTS `songs` (`id` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `title` TEXT NOT NULL, `artistName` TEXT NOT NULL, `albumName` TEXT NOT NULL, `albumArtist` TEXT, `genreName` TEXT, `trackNumber` INTEGER, `totalTracks` INTEGER, `discNumber` INTEGER, `totalDiscs` INTEGER, `year` INTEGER, `durationMs` INTEGER NOT NULL, `path` TEXT NOT NULL, `contentUri` TEXT NOT NULL, `relativePath` TEXT, `mimeType` TEXT, `bitrate` INTEGER, `sampleRate` INTEGER, `fileSizeBytes` INTEGER NOT NULL, `dateAddedEpochSec` INTEGER NOT NULL, `dateModifiedEpochSec` INTEGER NOT NULL, `lastScannedAtSec` INTEGER NOT NULL, `artworkKey` TEXT, `artworkUri` TEXT, `playCount` INTEGER NOT NULL, `lastPlayedEpochSec` INTEGER, `bpm` REAL, `musicalKey` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_title` ON `songs` (`title`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_artistName` ON `songs` (`artistName`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_albumName` ON `songs` (`albumName`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_albumArtist` ON `songs` (`albumArtist`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_genreName` ON `songs` (`genreName`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_path` ON `songs` (`path`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_sourceId` ON `songs` (`sourceId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_artworkKey` ON `songs` (`artworkKey`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_dateAddedEpochSec` ON `songs` (`dateAddedEpochSec`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_lastPlayedEpochSec` ON `songs` (`lastPlayedEpochSec`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_playCount` ON `songs` (`playCount`)") + db.execSQL("DELETE FROM playlist_items") + db.execSQL("DELETE FROM favorites") + db.execSQL("DELETE FROM playback_history") + } +} diff --git a/app/src/main/java/com/resonance/player/core/database/ResonanceDatabase.kt b/app/src/main/java/com/resonance/player/core/database/ResonanceDatabase.kt index c18cbce..6c03633 100644 --- a/app/src/main/java/com/resonance/player/core/database/ResonanceDatabase.kt +++ b/app/src/main/java/com/resonance/player/core/database/ResonanceDatabase.kt @@ -6,30 +6,34 @@ import com.resonance.player.core.database.dao.FavoriteDao import com.resonance.player.core.database.dao.HistoryDao import com.resonance.player.core.database.dao.PlaylistDao import com.resonance.player.core.database.dao.SongDao +import com.resonance.player.core.database.dao.SourceDao import com.resonance.player.core.database.entity.FavoriteEntity import com.resonance.player.core.database.entity.HistoryEntryEntity import com.resonance.player.core.database.entity.PlaylistEntity import com.resonance.player.core.database.entity.PlaylistItemEntity import com.resonance.player.core.database.entity.SongEntity +import com.resonance.player.core.database.entity.SourceEntity /** - * Single Room database for the app (ADR-005). Version 2 — Phase 3 library - * pipeline columns (volume/size/album-artist/path/totals/scan/artwork). - * Upgrade from v1 is a purely additive [MIGRATION_1_2]; user data - * (playlists, favorites, history, play counts) is preserved. + * Single Room database for the app (ADR-005). Version 3 — the library is + * built from user-added sources (folders / single songs) instead of a device + * scan; songs carry a stable id and belong to a source (ADR-010). Upgrade + * from v2 is the hand-written [MIGRATION_2_3] (clean start for songs). */ @Database( entities = [ + SourceEntity::class, SongEntity::class, PlaylistEntity::class, PlaylistItemEntity::class, FavoriteEntity::class, HistoryEntryEntity::class ], - version = 2, + version = 3, exportSchema = false ) abstract class ResonanceDatabase : RoomDatabase() { + abstract fun sourceDao(): SourceDao abstract fun songDao(): SongDao abstract fun playlistDao(): PlaylistDao abstract fun favoriteDao(): FavoriteDao diff --git a/app/src/main/java/com/resonance/player/core/database/SongMapper.kt b/app/src/main/java/com/resonance/player/core/database/SongMapper.kt index 43f9bd1..a143d04 100644 --- a/app/src/main/java/com/resonance/player/core/database/SongMapper.kt +++ b/app/src/main/java/com/resonance/player/core/database/SongMapper.kt @@ -18,14 +18,10 @@ import com.resonance.player.core.database.dao.GenreRow */ fun SongEntity.toDomain(isFavorite: Boolean = false): Song = Song( id = id, - mediaStoreId = mediaStoreId, - volumeName = volumeName, title = title, artistName = artistName, albumName = albumName, albumArtist = albumArtist, - albumId = albumId, - artistId = artistId, genreName = genreName, trackNumber = trackNumber, totalTracks = totalTracks, @@ -52,51 +48,6 @@ fun SongEntity.toDomain(isFavorite: Boolean = false): Song = Song( musicalKey = musicalKey ) -fun Song.toEntity(): SongEntity = SongEntity( - id = id, - mediaStoreId = mediaStoreId, - volumeName = volumeName, - title = title, - artistName = artistName, - albumName = albumName, - albumArtist = albumArtist, - albumId = albumId, - artistId = artistId, - genreName = genreName, - trackNumber = trackNumber, - totalTracks = totalTracks, - discNumber = discNumber, - totalDiscs = totalDiscs, - year = year, - durationMs = durationMs, - path = path, - contentUri = contentUri, - relativePath = relativePath, - mimeType = mimeType, - bitrate = bitrate, - sampleRate = sampleRate, - fileSizeBytes = fileSizeBytes, - dateAddedEpochSec = dateAddedEpochSec, - dateModifiedEpochSec = dateModifiedEpochSec, - lastScannedAtSec = lastScannedAtSec, - artworkKey = artworkKey, - artworkUri = artworkUri, - playCount = playCount, - lastPlayedEpochSec = lastPlayedEpochSec, - bpm = bpm, - musicalKey = musicalKey -) - -/** - * Merges a freshly scanned entity over the stored one, PRESERVING playback - * statistics. The scanner owns library fields; playCount/lastPlayed belong - * to the playback subsystem and must survive rescans. - */ -fun SongEntity.withPreservedStats(existing: SongEntity?): SongEntity { - if (existing == null) return this - return copy(playCount = existing.playCount, lastPlayedEpochSec = existing.lastPlayedEpochSec) -} - /** Stable display id for an album group (non-persistent, UI keys only). */ fun albumGroupId(albumName: String, albumArtist: String?): Long { val key = albumName.lowercase() + "|" + (albumArtist?.lowercase() ?: "") @@ -131,6 +82,6 @@ fun GenreRow.toDomain(): Genre = Genre(name = genreName, songCount = songCount) fun FolderRow.toDomain(): MusicFolder { val path = relativePath ?: "" val name = path.trimEnd('/').substringAfterLast('/') - .takeIf { it.isNotEmpty() } ?: "Device storage" + .takeIf { it.isNotEmpty() } ?: "Added songs" return MusicFolder(path = path, name = name, songCount = songCount) } diff --git a/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt b/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt index 4be18a3..4960adb 100644 --- a/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt +++ b/app/src/main/java/com/resonance/player/core/database/dao/SongDao.kt @@ -1,8 +1,6 @@ package com.resonance.player.core.database.dao import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Upsert import com.resonance.player.core.database.entity.SongEntity @@ -11,10 +9,11 @@ import kotlinx.coroutines.flow.Flow /** Lightweight row for incremental diffing (never full entities). */ data class ScanFingerprint( val id: Long, - val mediaStoreId: Long, - val volumeName: String, + val sourceId: Long, val dateModifiedEpochSec: Long, val fileSizeBytes: Long, + val durationMs: Long, + val dateAddedEpochSec: Long, val playCount: Long, val lastPlayedEpochSec: Long?, val artworkKey: String? @@ -108,10 +107,10 @@ interface SongDao { @Query("SELECT * FROM songs WHERE id IN (:ids)") suspend fun getByIds(ids: List): List - @Query("SELECT * FROM songs WHERE mediaStoreId = :mediaStoreId AND volumeName = :volumeName") - suspend fun getByMediaKey(mediaStoreId: Long, volumeName: String): SongEntity? - - @Query("SELECT id, mediaStoreId, volumeName, dateModifiedEpochSec, fileSizeBytes, playCount, lastPlayedEpochSec, artworkKey FROM songs") + @Query( + "SELECT id, sourceId, dateModifiedEpochSec, fileSizeBytes, durationMs, dateAddedEpochSec, " + + "playCount, lastPlayedEpochSec, artworkKey FROM songs" + ) suspend fun getFingerprints(): List @Query("SELECT COUNT(*) FROM songs") @@ -206,22 +205,12 @@ interface SongDao { @Query("SELECT DISTINCT artworkKey FROM songs WHERE artworkKey IS NOT NULL") suspend fun getReferencedArtworkKeys(): List - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(songs: List) - @Upsert suspend fun upsertBatch(songs: List) - @Query("UPDATE songs SET playCount = playCount + 1, lastPlayedEpochSec = :nowSec WHERE id = :id") suspend fun incrementPlayCount(id: Long, nowSec: Long) @Query("DELETE FROM songs WHERE id IN (:ids)") suspend fun deleteByIds(ids: List) - - @Query("DELETE FROM songs WHERE id NOT IN (:keepIds)") - suspend fun pruneMissing(keepIds: List) - - @Query("DELETE FROM songs") - suspend fun clearAll() } diff --git a/app/src/main/java/com/resonance/player/core/database/dao/SourceDao.kt b/app/src/main/java/com/resonance/player/core/database/dao/SourceDao.kt new file mode 100644 index 0000000..de978e8 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/database/dao/SourceDao.kt @@ -0,0 +1,44 @@ +package com.resonance.player.core.database.dao + +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.resonance.player.core.database.entity.SourceEntity +import kotlinx.coroutines.flow.Flow + +/** A source plus how many songs currently belong to it (Settings row). */ +data class SourceWithCount( + @Embedded val source: SourceEntity, + val songCount: Int +) + +@Dao +interface SourceDao { + @Query( + "SELECT s.*, (SELECT COUNT(*) FROM songs WHERE sourceId = s.id) AS songCount " + + "FROM sources s ORDER BY s.id" + ) + fun observeAll(): Flow> + + @Query("SELECT * FROM sources ORDER BY id") + suspend fun getAll(): List + + @Query("SELECT * FROM sources WHERE id = :id") + suspend fun getById(id: Long): SourceEntity? + + /** + * IGNORE, never REPLACE: REPLACE deletes the parent row first and the FK + * cascade would wipe every song of the source. Returns -1 when the uri + * already exists. + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(source: SourceEntity): Long + + @Query("DELETE FROM sources WHERE id = :id") + suspend fun delete(id: Long) + + @Query("UPDATE sources SET lastScannedAtEpochSec = :sec WHERE id IN (:ids)") + suspend fun markScanned(ids: List, sec: Long) +} diff --git a/app/src/main/java/com/resonance/player/core/database/entity/SongEntity.kt b/app/src/main/java/com/resonance/player/core/database/entity/SongEntity.kt index a91b835..8c3f4d3 100644 --- a/app/src/main/java/com/resonance/player/core/database/entity/SongEntity.kt +++ b/app/src/main/java/com/resonance/player/core/database/entity/SongEntity.kt @@ -1,25 +1,32 @@ package com.resonance.player.core.database.entity -import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey /** - * Cached song row. Primary key mirrors the MediaStore audio id captured at - * import time (ADR-005: stable within a MediaStore generation; a deleted and - * re-added file is treated as a new song — documented, not silent). + * Cached song row. The primary key is a stable hash of provider authority + + * document id (see StableIds), so it survives rescans and re-adds; a moved or + * renamed file is a new song. Every song belongs to one [SourceEntity]; the + * FK cascade removes the songs when the source is removed. * - * Phase 3 (schema v2): storage volume, file size, album artist, relative - * path, track/disc totals, artwork references and last-scan stamp. Playback - * stats (playCount/lastPlayed) live in the same row but are PRESERVED across - * rescans by merging — the scanner never overwrites them. + * Playback stats (playCount/lastPlayed) live in the same row but are + * PRESERVED across rescans by merging — the scanner never overwrites them. * - * Query indexes: title, artist, album, albumArtist, genre, path, - * mediaStoreId, artworkKey, date added, last played, play count. + * Query indexes: title, artist, album, albumArtist, genre, path, source, + * artworkKey, date added, last played, play count. */ @Entity( tableName = "songs", + foreignKeys = [ + ForeignKey( + entity = SourceEntity::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE + ) + ], indices = [ Index(value = ["title"]), Index(value = ["artistName"]), @@ -27,7 +34,7 @@ import androidx.room.PrimaryKey Index(value = ["albumArtist"]), Index(value = ["genreName"]), Index(value = ["path"]), - Index(value = ["mediaStoreId"]), + Index(value = ["sourceId"]), Index(value = ["artworkKey"]), Index(value = ["dateAddedEpochSec"]), Index(value = ["lastPlayedEpochSec"]), @@ -36,14 +43,11 @@ import androidx.room.PrimaryKey ) data class SongEntity( @PrimaryKey val id: Long, - val mediaStoreId: Long, - @ColumnInfo(defaultValue = "'external'") val volumeName: String, + val sourceId: Long, val title: String, val artistName: String, val albumName: String, val albumArtist: String?, - val albumId: Long?, - val artistId: Long?, val genreName: String?, val trackNumber: Int?, val totalTracks: Int?, @@ -57,10 +61,10 @@ data class SongEntity( val mimeType: String?, val bitrate: Int?, val sampleRate: Int?, - @ColumnInfo(defaultValue = "0") val fileSizeBytes: Long, + val fileSizeBytes: Long, val dateAddedEpochSec: Long, val dateModifiedEpochSec: Long, - @ColumnInfo(defaultValue = "0") val lastScannedAtSec: Long, + val lastScannedAtSec: Long, val artworkKey: String?, val artworkUri: String?, val playCount: Long = 0L, diff --git a/app/src/main/java/com/resonance/player/core/database/entity/SourceEntity.kt b/app/src/main/java/com/resonance/player/core/database/entity/SourceEntity.kt new file mode 100644 index 0000000..4708201 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/database/entity/SourceEntity.kt @@ -0,0 +1,22 @@ +package com.resonance.player.core.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One thing the user added to the library: a folder ("TREE", listed + * recursively) or a single song ("FILE"). [uri] is the persisted Storage + * Access Framework URI, unique so re-adding is idempotent. Songs point back + * through `songs.sourceId` with ON DELETE CASCADE: removing a source drops + * its songs. + */ +@Entity(tableName = "sources", indices = [Index(value = ["uri"], unique = true)]) +data class SourceEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val kind: String, + val uri: String, + val displayName: String, + val addedAtEpochSec: Long, + val lastScannedAtEpochSec: Long? = null +) diff --git a/app/src/main/java/com/resonance/player/core/media/MediaBoundaries.kt b/app/src/main/java/com/resonance/player/core/media/MediaBoundaries.kt index e77e029..141cb08 100644 --- a/app/src/main/java/com/resonance/player/core/media/MediaBoundaries.kt +++ b/app/src/main/java/com/resonance/player/core/media/MediaBoundaries.kt @@ -6,7 +6,7 @@ import com.resonance.player.core.model.Song /** * Report for one incremental library scan. [failed] counts items that could * not be imported (corrupt/unreadable) without aborting the scan; [total] is - * the number of MediaStore candidates seen. + * the number of audio files found in the added sources. */ data class ScanReport( val added: Int, @@ -33,9 +33,9 @@ sealed interface ScanState { } /** - * Boundary for the MediaStore scanner implementation. Must: + * Boundary for the library scanner: lists the audio of the user-added sources + * (folders / single songs). Must: * - run off the main thread (Dispatchers.IO), - * - stream the MediaStore query (never hold full result sets in memory), * - extract metadata only for new/changed items (incremental), * - upsert into Room in batches and prune rows whose files vanished, * - be single-flight (one authoritative scan at a time) and cancellable. diff --git a/app/src/main/java/com/resonance/player/core/media/MediaCandidates.kt b/app/src/main/java/com/resonance/player/core/media/MediaCandidates.kt deleted file mode 100644 index 15d9319..0000000 --- a/app/src/main/java/com/resonance/player/core/media/MediaCandidates.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.resonance.player.core.media - -/** - * What MediaStore knows about one audio item BEFORE deeper metadata - * extraction. Lightweight discovery row: the reconciler diffs these against - * stored fingerprints to decide NEW / MODIFIED / UNCHANGED / DELETED without - * touching tags or artwork for unchanged files. - * - * Times are epoch SECONDS (MediaStore DATE_MODIFIED unit); sizes are bytes. - */ -data class MediaItemCandidate( - val mediaStoreId: Long, - val volumeName: String, - val contentUri: String, - val displayName: String, - val mimeType: String?, - val sizeBytes: Long, - val dateModifiedSec: Long, - val dateAddedSec: Long, - val durationMs: Long, - val relativePath: String?, - val title: String?, - val artist: String?, - val album: String?, - val genre: String?, - val year: Int?, - val track: Int?, - val albumId: Long?, - val artistId: Long? -) - -/** - * One MediaStore cursor row as untyped column values. The Android glue reads - * the Cursor once per row into this map; the pure [mapCandidate] function - * below turns it into a [MediaItemCandidate] and is fully JVM-testable - * without Robolectric (no android.database classes leak into logic). - */ -data class RawRow(val values: Map) { - fun string(key: String): String? = (values[key] as? String)?.takeIf { it.isNotEmpty() } - fun long(key: String): Long? = when (val v = values[key]) { - is Long -> v - is Int -> v.toLong() - is String -> v.toLongOrNull() - else -> null - } - fun int(key: String): Int? = when (val v = values[key]) { - is Int -> v - is Long -> v.toInt() - is String -> v.toIntOrNull() - else -> null - } -} - -/** - * Builds the item content URI for a volume without assuming a raw path. - * [volumeName] is the MediaStore volume ("external" pre-Q, "external_primary" - * or a storage UUID on Q+); callers pass whatever the row reported. - */ -fun contentUriFor(volumeName: String, mediaStoreId: Long): String = - "content://media/" + volumeName + "/audio/media/" + mediaStoreId - -/** Pure row mapping: RawRow -> candidate. Never throws for bad values. */ -fun mapCandidate(row: RawRow, volumeFallback: String = "external"): MediaItemCandidate { - val mediaStoreId = row.long("_id") ?: -1L - val volume = row.string("volume_name")?.takeIf { it.isNotBlank() } ?: volumeFallback - val explicitUri = row.string("_uri") - return MediaItemCandidate( - mediaStoreId = mediaStoreId, - volumeName = volume, - contentUri = explicitUri ?: contentUriFor(volume, mediaStoreId), - displayName = row.string("_display_name") ?: "unknown", - mimeType = row.string("mime_type"), - sizeBytes = row.long("_size") ?: 0L, - dateModifiedSec = row.long("date_modified") ?: 0L, - dateAddedSec = row.long("date_added") ?: 0L, - durationMs = row.long("duration") ?: 0L, - relativePath = row.string("relative_path"), - title = row.string("title"), - artist = row.string("artist"), - album = row.string("album"), - genre = row.string("genre"), - year = row.int("year"), - track = row.int("track"), - albumId = row.long("album_id"), - artistId = row.long("artist_id") - ) -} diff --git a/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt b/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt index 52db4b8..67e0b2a 100644 --- a/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt +++ b/app/src/main/java/com/resonance/player/core/media/SafCandidates.kt @@ -58,10 +58,18 @@ fun isAudioDoc(name: String?, mimeType: String?): Boolean { return generic && name != null && SupportedFormats.isSupported(name) } -/** Root-relative folder path in the same shape MediaStore used: "Music/Rock/". */ +/** Root-relative folder path: "Music/Rock/". */ fun joinRelPath(parent: String?, dirName: String): String = (parent ?: "") + dirName.trim('/') + "/" +/** "Music" -> "Music (2)" -> "Music (3)": two folders with one name stay distinguishable (and get distinct Folders-tab roots). */ +fun uniqueName(base: String, taken: Set): String { + if (base !in taken) return base + var n = 2 + while ("$base ($n)" in taken) n++ + return "$base ($n)" +} + fun audioCandidateOf( row: DocRow, authority: String, diff --git a/app/src/main/java/com/resonance/player/core/model/MusicSource.kt b/app/src/main/java/com/resonance/player/core/model/MusicSource.kt new file mode 100644 index 0000000..8a40708 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/model/MusicSource.kt @@ -0,0 +1,18 @@ +package com.resonance.player.core.model + +/** A folder is listed recursively; a file is one individually added song. */ +enum class SourceKind { TREE, FILE } + +/** + * A folder or single song the user added to the library. [accessOk] is false + * when the persisted read grant is gone (the songs are kept; re-adding the + * same folder repairs it). + */ +data class MusicSource( + val id: Long, + val kind: SourceKind, + val uri: String, + val displayName: String, + val songCount: Int, + val accessOk: Boolean +) diff --git a/app/src/main/java/com/resonance/player/core/model/Song.kt b/app/src/main/java/com/resonance/player/core/model/Song.kt index 0577140..a80066d 100644 --- a/app/src/main/java/com/resonance/player/core/model/Song.kt +++ b/app/src/main/java/com/resonance/player/core/model/Song.kt @@ -1,26 +1,21 @@ package com.resonance.player.core.model /** - * Stable domain Song. Identity is [id] (MediaStore audio id at import time, - * Room primary key). [path]/[contentUri] are indexed for lookup but are NEVER - * the sole identity of a song (ADR-005). + * Stable domain Song. Identity is [id] (a hash of provider authority + + * document id, Room primary key; ADR-010). [path]/[contentUri] are indexed for + * lookup but are NEVER the sole identity of a song. * - * Phase 3 additions: storage volume, file size, album artist, relative path, - * track/disc totals, artwork references and last-scan stamp. BPM / musicalKey - * stay reserved for the Smart Mix engine (later phase, still no fake data). + * BPM / musicalKey stay reserved for the Smart Mix engine (later phase, still + * no fake data). * * UI layers may only observe this type — never Room entities (ADR-004). */ data class Song( val id: Long, - val mediaStoreId: Long, - val volumeName: String, val title: String, val artistName: String, val albumName: String, val albumArtist: String?, - val albumId: Long?, - val artistId: Long?, val genreName: String?, val trackNumber: Int?, val totalTracks: Int?, diff --git a/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt b/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt new file mode 100644 index 0000000..7261b13 --- /dev/null +++ b/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt @@ -0,0 +1,90 @@ +package com.resonance.player.data.local + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import com.resonance.player.core.common.AppDispatchers +import com.resonance.player.core.database.ResonanceDatabase +import com.resonance.player.core.database.entity.SourceEntity +import com.resonance.player.core.media.AudioScanner +import com.resonance.player.core.media.uniqueName +import com.resonance.player.core.model.MusicSource +import com.resonance.player.core.model.SourceKind +import com.resonance.player.data.media.SafAudioDataSource +import com.resonance.player.domain.library.SourceRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +/** + * Room + Storage Access Framework implementation. The only class that takes + * or releases persistable grants. Both writes stop a running scan first: the + * scanner is single-flight, so a scan in progress would swallow the + * follow-up scan request, and importing songs of a source that was just + * removed would violate the songs -> sources foreign key. + */ +class RoomSourceRepository( + private val resolver: ContentResolver, + private val database: ResonanceDatabase, + private val scanner: AudioScanner, + private val dataSource: SafAudioDataSource, + private val dispatchers: AppDispatchers, + private val clockSec: () -> Long = { System.currentTimeMillis() / 1000L } +) : SourceRepository { + + override fun observeSources(): Flow> = + database.sourceDao().observeAll().map { rows -> + val grants = dataSource.readGrants() + rows.map { + MusicSource( + id = it.source.id, + kind = SourceKind.valueOf(it.source.kind), + uri = it.source.uri, + displayName = it.source.displayName, + songCount = it.songCount, + accessOk = it.source.uri in grants + ) + } + }.flowOn(dispatchers.io) + + override suspend fun addSources(kind: SourceKind, uris: List) { + withContext(dispatchers.io) { + scanner.cancel() + val dao = database.sourceDao() + val taken = dao.getAll().mapTo(HashSet()) { it.displayName } + for (uri in uris) { + try { + resolver.takePersistableUriPermission(Uri.parse(uri), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + // The provider offers no lasting grant: it would be unreadable after a restart. + continue + } + val name = uniqueName( + dataSource.displayNameOf(uri, tree = kind == SourceKind.TREE) ?: DEFAULT_NAME, + taken + ) + val source = SourceEntity(kind = kind.name, uri = uri, displayName = name, addedAtEpochSec = clockSec()) + if (dao.insert(source) != -1L) taken += name + } + } + } + + override suspend fun removeSource(id: Long) { + withContext(dispatchers.io) { + scanner.cancel() + val dao = database.sourceDao() + val source = dao.getById(id) ?: return@withContext + dao.delete(id) + try { + resolver.releasePersistableUriPermission(Uri.parse(source.uri), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + // No grant left to release (it was already lost). + } + } + } + + private companion object { + const val DEFAULT_NAME = "Music" + } +} diff --git a/app/src/main/java/com/resonance/player/data/media/MediaStoreAudioDataSource.kt b/app/src/main/java/com/resonance/player/data/media/MediaStoreAudioDataSource.kt deleted file mode 100644 index 3a94cf0..0000000 --- a/app/src/main/java/com/resonance/player/data/media/MediaStoreAudioDataSource.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.resonance.player.data.media - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import com.resonance.player.core.media.MediaItemCandidate -import com.resonance.player.core.media.RawRow -import com.resonance.player.core.media.mapCandidate -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -/** One streamed MediaStore row: a usable candidate or a skipped bad row. */ -sealed interface ScanRow { - data class Row(val candidate: MediaItemCandidate) : ScanRow - data class Skipped(val reason: String) : ScanRow -} - -/** - * MediaStore is the ONLY discovery mechanism (no raw filesystem walks, no - * hardcoded /Music paths). Queries run on the collector dispatcher (IO); - * the cursor streams (CursorWindow pages internally) and is always closed. - * - * Query-level failures (SecurityException / IllegalArgumentException / - * provider errors) propagate to the scanner, which maps them explicitly — - * per-row failures become [ScanRow.Skipped] and never abort the scan. - */ -class MediaStoreAudioDataSource(private val appContext: Context) { - - fun collectionUri(): Uri = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) - } else { - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - - fun collectionUriForVolume(volumeName: String): Uri = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Audio.Media.getContentUri(volumeName) - } else { - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - - fun projection(): Array { - val base = mutableListOf( - MediaStore.Audio.Media._ID, - MediaStore.Audio.Media.DISPLAY_NAME, - MediaStore.Audio.Media.MIME_TYPE, - MediaStore.Audio.Media.SIZE, - MediaStore.Audio.Media.DATE_MODIFIED, - MediaStore.Audio.Media.DATE_ADDED, - MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.TITLE, - MediaStore.Audio.Media.ARTIST, - MediaStore.Audio.Media.ALBUM, - MediaStore.Audio.Media.ALBUM_ID, - MediaStore.Audio.Media.ARTIST_ID, - MediaStore.Audio.Media.YEAR, - MediaStore.Audio.Media.TRACK, - MediaStore.Audio.Media.IS_MUSIC - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - base.add(MediaStore.Audio.AudioColumns.GENRE) - base.add(MediaStore.MediaColumns.VOLUME_NAME) - base.add(MediaStore.MediaColumns.RELATIVE_PATH) - } - return base.toTypedArray() - } - - fun selection(): String = MediaStore.Audio.Media.IS_MUSIC + " != 0" - - suspend fun count(): Int { - val cursor = appContext.contentResolver.query( - collectionUri(), - arrayOf(MediaStore.Audio.Media._ID), - selection(), - null, - null - ) ?: return 0 - cursor.use { return it.count } - } - - fun streamCandidates(): Flow = flow { - val projection = projection() - val cursor = appContext.contentResolver.query( - collectionUri(), - projection, - selection(), - null, - MediaStore.Audio.Media._ID + " ASC" - ) ?: throw IllegalStateException("MediaStore query returned null cursor") - cursor.use { - while (it.moveToNext()) { - currentCoroutineContext().ensureActive() - emit(readRow(it)) - } - } - } - - private fun readRow(cursor: android.database.Cursor): ScanRow { - return try { - val values = HashMap(32) - for (i in 0 until cursor.columnCount) { - values[cursor.getColumnName(i)] = when (cursor.getType(i)) { - android.database.Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i) - android.database.Cursor.FIELD_TYPE_STRING -> cursor.getString(i) - android.database.Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i) - else -> null - } - } - val mediaId = (values["_id"] as? Long) ?: return ScanRow.Skipped("missing _id") - val volume = (values["volume_name"] as? String) - ?.takeIf { s -> s.isNotBlank() } ?: "external" - values["_uri"] = android.content.ContentUris.withAppendedId( - collectionUriForVolume(volume), mediaId - ).toString() - ScanRow.Row(mapCandidate(RawRow(values))) - } catch (e: Exception) { - ScanRow.Skipped(e.message ?: "bad row") - } - } -} diff --git a/app/src/main/java/com/resonance/player/data/media/MediaStoreLibraryScanner.kt b/app/src/main/java/com/resonance/player/data/media/MediaStoreLibraryScanner.kt deleted file mode 100644 index ecb91d9..0000000 --- a/app/src/main/java/com/resonance/player/data/media/MediaStoreLibraryScanner.kt +++ /dev/null @@ -1,364 +0,0 @@ -package com.resonance.player.data.media - -import android.content.Context -import android.content.pm.PackageManager -import android.util.Log -import androidx.core.content.ContextCompat -import com.resonance.player.core.common.AppDispatchers -import com.resonance.player.core.common.AppError -import com.resonance.player.core.common.Result -import com.resonance.player.core.database.ResonanceDatabase -import com.resonance.player.core.database.dao.ScanFingerprint -import com.resonance.player.core.database.entity.SongEntity -import com.resonance.player.core.media.AudioScanner -import com.resonance.player.core.media.MediaItemCandidate -import com.resonance.player.core.media.ScanReport -import com.resonance.player.core.media.ScanState -import com.resonance.player.core.media.SongMetadata -import com.resonance.player.core.permissions.MusicPermissions -import com.resonance.player.data.local.LibraryPreferences -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.sync.withPermit - -/** - * Real incremental MediaStore scanner (single-flight, cancellable). - * - * Flow: permission -> count -> stream candidates -> diff against stored - * fingerprints -> bounded metadata/artwork extraction for new/changed only - * -> batched Room upserts (stats preserved) -> chunked deletes -> artwork - * prune -> report. Corrupt/inaccessible single files increment [failed] and - * never abort the run. Startup behavior: fast cached UI first, this runs in - * the background on app scope (see ResonanceApp). - */ -class MediaStoreLibraryScanner( - private val appContext: Context, - private val dispatchers: AppDispatchers, - private val scope: CoroutineScope, - private val database: ResonanceDatabase, - private val dataSource: MediaStoreAudioDataSource, - private val extractor: AndroidMetadataExtractor, - private val artworkExtractor: ArtworkExtractor, - private val artworkStore: ArtworkStore, - private val prefs: LibraryPreferences, - private val clockSec: () -> Long = { System.currentTimeMillis() / 1000L } -) : AudioScanner { - - private val mutable = MutableStateFlow(ScanState.Idle) - override val state: StateFlow = mutable.asStateFlow() - - private val mutex = Mutex() - private var current: Job? = null - - override suspend fun scanLibrary(): Result { - val job = mutex.withLock { - val running = current?.takeIf { it.isActive } - if (running != null) { - return@withLock running - } - scope.launchScan().also { current = it } - } - return try { - job.join() - lastResult ?: Result.Failure(AppError.ScanFailed("scan produced no result")) - } catch (e: CancellationException) { - throw e - } - } - - override fun cancel() { - current?.cancel() - } - - @Volatile - private var lastResult: Result? = null - - private fun CoroutineScope.launchScan(): Job = launch(dispatchers.io) { - val startedMs = android.os.SystemClock.elapsedRealtime() - mutable.value = ScanState.CheckingPermission - try { - if (!hasAudioPermission()) { - mutable.value = ScanState.PermissionRequired - lastResult = Result.Failure(AppError.PermissionDenied) - return@launch - } - val total = try { - dataSource.count() - } catch (e: SecurityException) { - mutable.value = ScanState.PermissionRequired - lastResult = Result.Failure(AppError.PermissionDenied) - return@launch - } catch (e: Exception) { - if (e is CancellationException) throw e - mutable.value = ScanState.Failed(AppError.MediaStoreUnavailable(e.message)) - lastResult = Result.Failure(AppError.MediaStoreUnavailable(e.message)) - return@launch - } - mutable.value = ScanState.Scanning(0, total, 0, 0) - - val stored = try { - database.songDao().getFingerprints() - } catch (e: Exception) { - if (e is CancellationException) throw e - mutable.value = ScanState.Failed(AppError.DatabaseError(e.message)) - lastResult = Result.Failure(AppError.DatabaseError(e.message)) - return@launch - } - val storedByKey = stored.associateBy { it.mediaStoreId to it.volumeName } - - val seen = LinkedHashMap, MediaItemCandidate>(total.coerceAtLeast(16)) - var skipped = 0 - val ignoreShort = try { - prefs.ignoreShortFiles.first() - } catch (t: Exception) { - false - } - try { - dataSource.streamCandidates().collect { row -> - when (row) { - is ScanRow.Row -> { - val c = row.candidate - // Stitch "ignore files under 30s": excluded here so - // later enabling the filter also prunes them via - // the normal vanished-file reconciliation. - if (Reconciler.passesDurationFilter(c.durationMs, ignoreShort)) { - seen[c.mediaStoreId to c.volumeName] = c - } - } - is ScanRow.Skipped -> skipped++ - } - if ((seen.size + skipped) % PROGRESS_EVERY == 0) { - val s = mutable.value - if (s is ScanState.Scanning) { - mutable.value = s.copy(processed = seen.size + skipped) - } - } - } - } catch (e: SecurityException) { - mutable.value = ScanState.PermissionRequired - lastResult = Result.Failure(AppError.PermissionDenied) - return@launch - } catch (e: Exception) { - if (e is CancellationException) throw e - mutable.value = ScanState.Failed(AppError.MediaStoreUnavailable(e.message)) - lastResult = Result.Failure(AppError.MediaStoreUnavailable(e.message)) - return@launch - } - - val basePlan = Reconciler.plan( - stored.map { - StoredFingerprint( - it.id, it.mediaStoreId, it.volumeName, - it.dateModifiedEpochSec, it.fileSizeBytes, - it.playCount, it.lastPlayedEpochSec - ) - }, - seen - ) - // Unchanged rows whose cached artwork file vanished are - // re-imported so the image can be re-extracted (cheap exists - // checks; extraction itself stays bounded with the rest). - val artMissing = stored.mapNotNull { fingerprint -> - val key = fingerprint.artworkKey ?: return@mapNotNull null - if (artworkStore.uriFor(key) != null) return@mapNotNull null - seen[fingerprint.mediaStoreId to fingerprint.volumeName] - } - val plan = if (artMissing.isEmpty()) { - basePlan - } else { - val merged = (basePlan.toImport + artMissing) - .distinctBy { it.mediaStoreId to it.volumeName } - basePlan.copy(toImport = merged) - } - - var removed = 0 - if (plan.toDelete.isNotEmpty()) { - try { - plan.toDelete.chunked(DB_CHUNK).forEach { chunk -> - database.songDao().deleteByIds(chunk) - removed += chunk.size - } - } catch (e: Exception) { - if (e is CancellationException) throw e - mutable.value = ScanState.Failed(AppError.DatabaseError(e.message)) - lastResult = Result.Failure(AppError.DatabaseError(e.message)) - return@launch - } - } - - var added = 0 - var updated = 0 - var failed = skipped - var imported = 0 - val storedKeys = storedByKey.keys - val nowSec = clockSec() - // Total covers discovery (seen.size) plus the import backlog so - // progress keeps advancing through metadata extraction. - val workTotal = seen.size + plan.toImport.size - val semaphore = Semaphore(EXTRACTION_PARALLELISM) - try { - plan.toImport.chunked(DB_BATCH).forEach { batch -> - val entities = batch.map { candidate -> - async { - semaphore.withPermit { importCandidate(candidate, storedByKey, nowSec) } - } - }.awaitAll() - val ready = entities.filterNotNull() - failed += entities.size - ready.size - if (ready.isNotEmpty()) { - database.songDao().upsertBatch(ready) - var batchAdded = 0 - for (entity in ready) { - if (storedKeys.contains(entity.mediaStoreId to entity.volumeName)) { - updated++ - } else { - batchAdded++ - } - } - added += batchAdded - } - imported += batch.size - mutable.value = ScanState.Scanning(seen.size + imported, workTotal, added, updated) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - mutable.value = ScanState.Failed(AppError.DatabaseError(e.message)) - lastResult = Result.Failure(AppError.DatabaseError(e.message)) - return@launch - } - - try { - val referenced = database.songDao().getReferencedArtworkKeys().toSet() - val pruned = artworkStore.prune(referenced) - if (pruned > 0) Log.i(TAG, "Pruned $pruned orphaned artwork files") - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w(TAG, "Artwork prune failed", e) - } - - try { - prefs.setLastScan(nowSec) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w(TAG, "Could not persist last scan time", e) - } - - val report = ScanReport(added, updated, removed, failed, seen.size) - val elapsed = android.os.SystemClock.elapsedRealtime() - startedMs - Log.i( - TAG, - "Scan done in ${elapsed}ms: total=${seen.size} added=$added " + - "updated=$updated removed=$removed failed=$failed" - ) - mutable.value = ScanState.Completed(report) - lastResult = Result.Success(report) - } catch (e: CancellationException) { - Log.i(TAG, "Scan cancelled") - mutable.value = ScanState.Cancelled - throw e - } catch (e: Exception) { - Log.w(TAG, "Scan failed", e) - mutable.value = ScanState.Failed(AppError.ScanFailed(e.message)) - lastResult = Result.Failure(AppError.ScanFailed(e.message)) - } finally { - mutex.withLock { if (current?.isCompleted != false) current = null } - } - } - - private suspend fun importCandidate( - candidate: MediaItemCandidate, - storedByKey: Map, ScanFingerprint>, - nowSec: Long - ): SongEntity? { - return try { - val extracted: SongMetadata? = when (val result = extractor.extract(candidate.contentUri)) { - is Result.Success -> result.value - is Result.Failure -> { - if (result.error is AppError.MissingFile || - result.error is AppError.PermissionDenied - ) { - return null - } - null - } - Result.Loading -> null - } - val normalized = MetadataNormalizer.normalize(candidate, extracted) - val artRef = try { - val bytes = artworkExtractor.extractArtwork(candidate.contentUri) - if (bytes != null) artworkStore.store(bytes) else null - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.w(TAG, "Artwork failed for ${candidate.contentUri}", e) - null - } - val existing = storedByKey[candidate.mediaStoreId to candidate.volumeName] - SongEntity( - id = candidate.mediaStoreId, - mediaStoreId = candidate.mediaStoreId, - volumeName = candidate.volumeName, - title = normalized.title, - artistName = normalized.artistName, - albumName = normalized.albumName, - albumArtist = normalized.albumArtist, - albumId = null, - artistId = null, - genreName = normalized.genreName, - trackNumber = normalized.trackNumber, - totalTracks = normalized.totalTracks, - discNumber = normalized.discNumber, - totalDiscs = normalized.totalDiscs, - year = normalized.year, - durationMs = normalized.durationMs, - path = normalized.displayPath, - contentUri = candidate.contentUri, - relativePath = candidate.relativePath, - mimeType = normalized.mimeType, - bitrate = normalized.bitrate, - sampleRate = normalized.sampleRate, - fileSizeBytes = candidate.sizeBytes, - dateAddedEpochSec = candidate.dateAddedSec, - dateModifiedEpochSec = candidate.dateModifiedSec, - lastScannedAtSec = nowSec, - artworkKey = artRef?.key, - artworkUri = artRef?.uri, - playCount = existing?.playCount ?: 0L, - lastPlayedEpochSec = existing?.lastPlayedEpochSec, - bpm = null, - musicalKey = null - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Import failed for ${candidate.contentUri}", e) - null - } - } - - private fun hasAudioPermission(): Boolean { - val permission = MusicPermissions.audioPermissionForSdk( - android.os.Build.VERSION.SDK_INT - ) - return ContextCompat.checkSelfPermission(appContext, permission) == - PackageManager.PERMISSION_GRANTED - } - - companion object { - private const val TAG = "LibraryScanner" - private const val DB_BATCH = 100 - private const val DB_CHUNK = 500 - private const val PROGRESS_EVERY = 200 - private const val EXTRACTION_PARALLELISM = 4 - } -} diff --git a/app/src/main/java/com/resonance/player/data/media/MetadataNormalizer.kt b/app/src/main/java/com/resonance/player/data/media/MetadataNormalizer.kt index 73615e3..4beae71 100644 --- a/app/src/main/java/com/resonance/player/data/media/MetadataNormalizer.kt +++ b/app/src/main/java/com/resonance/player/data/media/MetadataNormalizer.kt @@ -1,6 +1,6 @@ package com.resonance.player.data.media -import com.resonance.player.core.media.MediaItemCandidate +import com.resonance.player.core.media.AudioCandidate import com.resonance.player.core.media.SongMetadata import com.resonance.player.core.media.UnknownMetadata @@ -31,35 +31,26 @@ data class NormalizedTrack( object MetadataNormalizer { /** - * Priority: embedded tags (MediaMetadataRetriever) > MediaStore columns > - * filename/display fallbacks. A missing tag is normal; only the final - * display value must never be blank, "null" or "undefined". + * Priority: embedded tags (MediaMetadataRetriever) > filename/display + * fallbacks. A missing tag is normal; only the final display value must + * never be blank, "null" or "undefined". */ - fun normalize(candidate: MediaItemCandidate, extracted: SongMetadata?): NormalizedTrack { - val (track, totalTracks) = parseTrackNumber( - extracted?.trackRaw ?: candidate.track?.toString() - ) + fun normalize(candidate: AudioCandidate, extracted: SongMetadata?): NormalizedTrack { + val (track, totalTracks) = parseTrackNumber(extracted?.trackRaw) val (disc, totalDiscs) = parseTrackNumber(extracted?.discRaw) return NormalizedTrack( title = clean(extracted?.title) - ?: clean(candidate.title) ?: FilenameFallback.titleFromFileName(candidate.displayName), - artistName = clean(extracted?.artistName) - ?: clean(candidate.artist) - ?: UnknownMetadata.ARTIST, - albumName = clean(extracted?.albumName) - ?: clean(candidate.album) - ?: UnknownMetadata.ALBUM, + artistName = clean(extracted?.artistName) ?: UnknownMetadata.ARTIST, + albumName = clean(extracted?.albumName) ?: UnknownMetadata.ALBUM, albumArtist = clean(extracted?.albumArtist), - genreName = clean(extracted?.genreName) ?: clean(candidate.genre), + genreName = clean(extracted?.genreName), trackNumber = track, totalTracks = totalTracks, discNumber = disc, totalDiscs = totalDiscs, - year = parseYear(extracted?.yearRaw) ?: candidate.year?.takeIf { it in 1000..2999 }, - durationMs = candidate.durationMs.takeIf { it > 0L } - ?: extracted?.durationMs?.takeIf { it > 0L } - ?: 0L, + year = parseYear(extracted?.yearRaw), + durationMs = extracted?.durationMs?.takeIf { it > 0L } ?: 0L, mimeType = extracted?.mimeType?.takeIf { it.isNotBlank() } ?: candidate.mimeType, bitrate = extracted?.bitrate, diff --git a/app/src/main/java/com/resonance/player/data/media/Reconciler.kt b/app/src/main/java/com/resonance/player/data/media/Reconciler.kt deleted file mode 100644 index f92f6c5..0000000 --- a/app/src/main/java/com/resonance/player/data/media/Reconciler.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.resonance.player.data.media - -import com.resonance.player.core.media.MediaItemCandidate - -/** Minimal stored state needed to diff without loading full entities. */ -data class StoredFingerprint( - val id: Long, - val mediaStoreId: Long, - val volumeName: String, - val dateModifiedSec: Long, - val sizeBytes: Long, - val playCount: Long, - val lastPlayedSec: Long? -) - -/** Pure diff result: what to (re)import vs which rows vanished. */ -data class ReconcilePlan( - val toImport: List, - val toDelete: List -) - -/** - * Pure incremental reconciliation (JVM-tested). Identity = (mediaStoreId, - * volumeName); a row is UNCHANGED only when dateModified AND size both match. - * Anything else is re-imported (metadata/artwork refresh); stored ids absent - * from the scan are deleted. Duplicate candidates collapse last-wins. - */ -object Reconciler { - - /** Stitch "ignore files under 30s" threshold (ringtones/memos filter). */ - const val MIN_DURATION_MS = 30_000L - - /** - * Duration gate for discovery. Unknown durations (<= 0) always pass — - * an unreadable length must never silently drop a file. - */ - fun passesDurationFilter(durationMs: Long, ignoreShort: Boolean): Boolean = - !ignoreShort || durationMs <= 0L || durationMs >= MIN_DURATION_MS - - fun plan( - stored: List, - seen: Map, MediaItemCandidate> - ): ReconcilePlan { - val storedByKey = stored.associateBy { it.mediaStoreId to it.volumeName } - val toImport = ArrayList(seen.size) - for ((key, candidate) in seen) { - val existing = storedByKey[key] - if (existing == null || - existing.dateModifiedSec != candidate.dateModifiedSec || - existing.sizeBytes != candidate.sizeBytes - ) { - toImport.add(candidate) - } - } - val toDelete = storedByKey.values - .filter { (it.mediaStoreId to it.volumeName) !in seen } - .map { it.id } - return ReconcilePlan(toImport, toDelete) - } -} diff --git a/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt index a7c5882..db7f186 100644 --- a/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt +++ b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt @@ -32,9 +32,24 @@ enum class WalkStatus { COMPLETE, INCOMPLETE, UNAVAILABLE } */ class SafAudioDataSource(private val resolver: ContentResolver) { - /** True while the app still holds a persisted READ grant for [uri]. */ - fun hasReadGrant(uri: String): Boolean = - resolver.persistedUriPermissions.any { it.isReadPermission && it.uri.toString() == uri } + /** URIs the app still holds a persisted READ grant for. */ + fun readGrants(): Set = + resolver.persistedUriPermissions.filter { it.isReadPermission }.mapTo(HashSet()) { it.uri.toString() } + + /** Display name of a picked folder ([tree]) or file, or null when it cannot be read. */ + suspend fun displayNameOf(uriString: String, tree: Boolean): String? { + val uri = Uri.parse(uriString) + val docUri = try { + if (tree) { + DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) + } else { + uri + } + } catch (e: IllegalArgumentException) { + return null + } + return query(docUri)?.use { c -> if (c.moveToFirst()) c.getString(1) else null } + } suspend fun walkTree( sourceId: Long, diff --git a/app/src/main/java/com/resonance/player/data/media/SafLibraryScanner.kt b/app/src/main/java/com/resonance/player/data/media/SafLibraryScanner.kt new file mode 100644 index 0000000..9d5a4af --- /dev/null +++ b/app/src/main/java/com/resonance/player/data/media/SafLibraryScanner.kt @@ -0,0 +1,263 @@ +package com.resonance.player.data.media + +import android.os.SystemClock +import android.util.Log +import com.resonance.player.core.common.AppDispatchers +import com.resonance.player.core.common.AppError +import com.resonance.player.core.common.Result +import com.resonance.player.core.database.ResonanceDatabase +import com.resonance.player.core.database.dao.ScanFingerprint +import com.resonance.player.core.database.entity.SongEntity +import com.resonance.player.core.media.AudioCandidate +import com.resonance.player.core.media.AudioScanner +import com.resonance.player.core.media.MediaMetadataExtractor +import com.resonance.player.core.media.ScanReport +import com.resonance.player.core.media.ScanState +import com.resonance.player.core.model.SourceKind +import com.resonance.player.data.local.LibraryPreferences +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit + +/** + * Incremental scanner over the user-added sources (single-flight, + * cancellable). Runs only when asked: right after a source is added or + * removed, and from the "Refresh library" actions. + * + * Flow: list every source with a live grant -> diff against stored + * fingerprints ([SourceReconciler]) -> chunked deletes -> bounded metadata and + * artwork extraction for new/changed files only -> batched Room upserts (play + * stats and first-seen date preserved) -> artwork prune -> report. A corrupt + * or unreadable file counts as [ScanReport.failed] and never aborts the run; + * a source that is unavailable or only partly listed never deletes its songs. + */ +class SafLibraryScanner( + private val dispatchers: AppDispatchers, + private val scope: CoroutineScope, + private val database: ResonanceDatabase, + private val dataSource: SafAudioDataSource, + private val extractor: MediaMetadataExtractor, + private val artworkExtractor: ArtworkExtractor, + private val artworkStore: ArtworkStore, + private val prefs: LibraryPreferences, + private val clockSec: () -> Long = { System.currentTimeMillis() / 1000L } +) : AudioScanner { + + private val mutable = MutableStateFlow(ScanState.Idle) + override val state: StateFlow = mutable.asStateFlow() + + private val mutex = Mutex() + private var current: Job? = null + + @Volatile + private var lastResult: Result? = null + + override suspend fun scanLibrary(): Result { + val job = mutex.withLock { + current?.takeIf { it.isActive } ?: scope.launchScan().also { current = it } + } + job.join() + return lastResult ?: Result.Failure(AppError.ScanFailed("scan produced no result")) + } + + override fun cancel() { + current?.cancel() + } + + private sealed interface Import { + data class Ok(val entity: SongEntity) : Import + data object Filtered : Import + data object Failed : Import + } + + private fun CoroutineScope.launchScan(): Job = launch(dispatchers.io) { + val startedMs = SystemClock.elapsedRealtime() + mutable.value = ScanState.Scanning(0, 0, 0, 0) + try { + val sources = database.sourceDao().getAll() + val grants = dataSource.readGrants() + + // Sources go in id order and putIfAbsent keeps the first, so a file + // reachable through two sources belongs to the older one. + val seen = LinkedHashMap() + val complete = HashSet() + val onCandidate: (AudioCandidate) -> Unit = { c -> + seen.putIfAbsent(c.id, c) + if (seen.size % PROGRESS_EVERY == 0) mutable.value = ScanState.Scanning(seen.size, 0, 0, 0) + } + for (source in sources) { + if (source.uri !in grants) continue + val status = when (SourceKind.valueOf(source.kind)) { + SourceKind.TREE -> dataSource.walkTree(source.id, source.uri, source.displayName, onCandidate) + SourceKind.FILE -> dataSource.walkFile(source.id, source.uri, onCandidate) + } + if (status == WalkStatus.COMPLETE) complete += source.id + } + + val stored = database.songDao().getFingerprints() + val storedById = stored.associateBy { it.id } + val ignoreShort = prefs.ignoreShortFiles.first() + val plan = SourceReconciler.plan( + stored.map { + StoredSong(it.id, it.sourceId, it.dateModifiedEpochSec, it.fileSizeBytes, it.durationMs) + }, + seen, + complete, + ignoreShort + ) + // Unchanged rows whose cached artwork file vanished are re-imported + // so the image can be re-extracted (cheap exists checks). + val artMissing = stored.mapNotNull { f -> + f.artworkKey?.takeIf { artworkStore.uriFor(it) == null }?.let { seen[f.id] } + } + val toImport = (plan.toImport + artMissing).distinctBy { it.id } + + var removed = 0 + plan.toDelete.chunked(DB_CHUNK).forEach { chunk -> + database.songDao().deleteByIds(chunk) + removed += chunk.size + } + + var added = 0 + var updated = 0 + var failed = 0 + var done = 0 + val nowSec = clockSec() + val semaphore = Semaphore(EXTRACTION_PARALLELISM) + if (toImport.isNotEmpty()) mutable.value = ScanState.Scanning(0, toImport.size, 0, 0) + toImport.chunked(DB_BATCH).forEach { batch -> + val results = batch.map { candidate -> + async { semaphore.withPermit { importCandidate(candidate, storedById[candidate.id], nowSec, ignoreShort) } } + }.awaitAll() + val ready = results.filterIsInstance().map { it.entity } + failed += results.count { it is Import.Failed } + if (ready.isNotEmpty()) { + database.songDao().upsertBatch(ready) + val fresh = ready.count { it.id !in storedById } + added += fresh + updated += ready.size - fresh + } + done += batch.size + mutable.value = ScanState.Scanning(done, toImport.size, added, updated) + } + + try { + val pruned = artworkStore.prune(database.songDao().getReferencedArtworkKeys().toSet()) + if (pruned > 0) Log.i(TAG, "Pruned $pruned orphaned artwork files") + if (complete.isNotEmpty()) database.sourceDao().markScanned(complete.toList(), nowSec) + prefs.setLastScan(nowSec) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Post-scan housekeeping failed", e) + } + + val report = ScanReport(added, updated, removed, failed, seen.size) + Log.i( + TAG, + "Scan done in ${SystemClock.elapsedRealtime() - startedMs}ms: files=${seen.size} " + + "added=$added updated=$updated removed=$removed failed=$failed" + ) + mutable.value = ScanState.Completed(report) + lastResult = Result.Success(report) + } catch (e: CancellationException) { + Log.i(TAG, "Scan cancelled") + mutable.value = ScanState.Cancelled + throw e + } catch (e: Exception) { + Log.w(TAG, "Scan failed", e) + mutable.value = ScanState.Failed(AppError.ScanFailed(e.message)) + lastResult = Result.Failure(AppError.ScanFailed(e.message)) + } finally { + mutex.withLock { if (current?.isCompleted != false) current = null } + } + } + + private suspend fun importCandidate( + candidate: AudioCandidate, + existing: ScanFingerprint?, + nowSec: Long, + ignoreShort: Boolean + ): Import = try { + val extracted = when (val result = extractor.extract(candidate.contentUri)) { + is Result.Success -> result.value + is Result.Failure -> { + if (result.error is AppError.MissingFile || result.error is AppError.PermissionDenied) { + return Import.Failed + } + null + } + Result.Loading -> null + } + val normalized = MetadataNormalizer.normalize(candidate, extracted) + // ponytail: a filtered short file is never stored, so it is re-extracted on every + // refresh; persist a "filtered" marker if that ever gets slow. + if (!SourceReconciler.passesDurationFilter(normalized.durationMs, ignoreShort)) { + Import.Filtered + } else { + val artRef = try { + artworkExtractor.extractArtwork(candidate.contentUri)?.let { artworkStore.store(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Artwork failed for ${candidate.contentUri}", e) + null + } + Import.Ok( + SongEntity( + id = candidate.id, + sourceId = candidate.sourceId, + title = normalized.title, + artistName = normalized.artistName, + albumName = normalized.albumName, + albumArtist = normalized.albumArtist, + genreName = normalized.genreName, + trackNumber = normalized.trackNumber, + totalTracks = normalized.totalTracks, + discNumber = normalized.discNumber, + totalDiscs = normalized.totalDiscs, + year = normalized.year, + durationMs = normalized.durationMs, + path = normalized.displayPath, + contentUri = candidate.contentUri, + relativePath = candidate.relativePath, + mimeType = normalized.mimeType, + bitrate = normalized.bitrate, + sampleRate = normalized.sampleRate, + fileSizeBytes = candidate.sizeBytes, + // First seen: min(now, mtime), kept across re-imports. + dateAddedEpochSec = existing?.dateAddedEpochSec + ?: minOf(nowSec, candidate.dateModifiedSec.takeIf { it > 0L } ?: nowSec), + dateModifiedEpochSec = candidate.dateModifiedSec, + lastScannedAtSec = nowSec, + artworkKey = artRef?.key, + artworkUri = artRef?.uri, + playCount = existing?.playCount ?: 0L, + lastPlayedEpochSec = existing?.lastPlayedEpochSec + ) + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Import failed for ${candidate.contentUri}", e) + Import.Failed + } + + private companion object { + const val TAG = "LibraryScanner" + const val DB_BATCH = 100 + const val DB_CHUNK = 500 + const val PROGRESS_EVERY = 200 + const val EXTRACTION_PARALLELISM = 4 + } +} diff --git a/app/src/main/java/com/resonance/player/domain/library/Sources.kt b/app/src/main/java/com/resonance/player/domain/library/Sources.kt new file mode 100644 index 0000000..6e49616 --- /dev/null +++ b/app/src/main/java/com/resonance/player/domain/library/Sources.kt @@ -0,0 +1,47 @@ +package com.resonance.player.domain.library + +import com.resonance.player.core.model.MusicSource +import com.resonance.player.core.model.SourceKind +import kotlinx.coroutines.flow.Flow + +/** + * The folders and single songs the user added. Android-free boundary: URIs + * travel as strings; taking and releasing the persistable read grant is the + * implementation's job. + */ +interface SourceRepository { + fun observeSources(): Flow> + + /** Idempotent: an already added uri keeps its row and just gets its grant re-taken (the repair path). */ + suspend fun addSources(kind: SourceKind, uris: List) + + /** Drops the source, its songs and its read grant. The file itself is never touched. */ + suspend fun removeSource(id: Long) +} + +class ObserveSourcesUseCase(private val repository: SourceRepository) { + operator fun invoke(): Flow> = repository.observeSources() +} + +/** Adds folders/songs, then imports them right away (nothing else scans automatically). */ +class AddSourcesUseCase( + private val sources: SourceRepository, + private val library: MusicRepository +) { + suspend operator fun invoke(kind: SourceKind, uris: List) { + if (uris.isEmpty()) return + sources.addSources(kind, uris) + library.scanAndImport() + } +} + +/** Removes a source, then rescans so files it shared with another source come back under that one. */ +class RemoveSourceUseCase( + private val sources: SourceRepository, + private val library: MusicRepository +) { + suspend operator fun invoke(id: Long) { + sources.removeSource(id) + library.scanAndImport() + } +} diff --git a/app/src/test/java/com/resonance/player/core/FoundationTest.kt b/app/src/test/java/com/resonance/player/core/FoundationTest.kt index 7367833..fa7e0c7 100644 --- a/app/src/test/java/com/resonance/player/core/FoundationTest.kt +++ b/app/src/test/java/com/resonance/player/core/FoundationTest.kt @@ -2,7 +2,6 @@ package com.resonance.player.core import com.resonance.player.core.database.entity.SongEntity import com.resonance.player.core.database.toDomain -import com.resonance.player.core.database.toEntity import com.resonance.player.core.permissions.MusicPermissions import com.resonance.player.core.ui.adaptive.WindowWidthSize import com.resonance.player.core.ui.adaptive.windowWidthSizeFor @@ -15,9 +14,9 @@ import org.junit.Test class SongMapperTest { private fun entity() = SongEntity( - id = 7L, mediaStoreId = 7L, volumeName = "external", title = "T", + id = 7L, sourceId = 1L, title = "T", artistName = "A", albumName = "Al", albumArtist = "AA", - albumId = null, artistId = null, genreName = null, + genreName = null, trackNumber = 1, totalTracks = 10, discNumber = null, totalDiscs = null, year = 2020, durationMs = 180_000L, path = "Music/t.mp3", contentUri = "content://m/7", relativePath = "Music/", @@ -28,10 +27,15 @@ class SongMapperTest { ) @Test - fun roundTrip_preservesFields() { + fun toDomain_mapsFields() { val domain = entity().toDomain(isFavorite = true) assertTrue(domain.isFavorite) - assertEquals(entity(), domain.toEntity()) + assertEquals(7L, domain.id) + assertEquals("T", domain.title) + assertEquals("AA", domain.albumArtist) + assertEquals("content://m/7", domain.contentUri) + assertEquals("Music/", domain.relativePath) + assertEquals("abc", domain.artworkKey) } @Test diff --git a/app/src/test/java/com/resonance/player/core/GroupMappingTest.kt b/app/src/test/java/com/resonance/player/core/GroupMappingTest.kt index 9c3d48b..e75e590 100644 --- a/app/src/test/java/com/resonance/player/core/GroupMappingTest.kt +++ b/app/src/test/java/com/resonance/player/core/GroupMappingTest.kt @@ -61,6 +61,6 @@ class GroupMappingTest { assertEquals("Download", FolderRow("Download/", 29).toDomain().name) val unknown = FolderRow(null, 1).toDomain() - assertEquals("Device storage", unknown.name) + assertEquals("Added songs", unknown.name) } } diff --git a/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt b/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt index 67595c0..49a1de3 100644 --- a/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt +++ b/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt @@ -36,8 +36,6 @@ class ScanErrorsTest { @Test fun newErrors_haveMessages() { - assertTrue(AppError.MediaStoreUnavailable("x").userMessage().isNotBlank()) - assertTrue(AppError.MediaStoreUnavailable(null).userMessage().isNotBlank()) assertTrue(AppError.ScanFailed("boom").userMessage().contains("boom")) assertTrue(AppError.ScanFailed(null).userMessage().isNotBlank()) } diff --git a/app/src/test/java/com/resonance/player/data/CandidateMapperTest.kt b/app/src/test/java/com/resonance/player/data/CandidateMapperTest.kt deleted file mode 100644 index ce519d3..0000000 --- a/app/src/test/java/com/resonance/player/data/CandidateMapperTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.resonance.player.data - -import com.resonance.player.core.media.RawRow -import com.resonance.player.core.media.SupportedMimeTypes -import com.resonance.player.core.media.contentUriFor -import com.resonance.player.core.media.mapCandidate -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class CandidateMapperTest { - - private fun row(vararg pairs: Pair) = RawRow(mapOf(*pairs)) - - @Test - fun mapsFullRow() { - val candidate = mapCandidate( - row( - "_id" to 42L, - "volume_name" to "external_primary", - "_display_name" to "song.mp3", - "mime_type" to "audio/mpeg", - "_size" to 5_000_000L, - "date_modified" to 1_700_000_000L, - "date_added" to 1_699_000_000L, - "duration" to 200_000L, - "relative_path" to "Music/Rock/", - "title" to "Song", - "artist" to "Artist", - "album" to "Album", - "genre" to "Rock", - "year" to 2021, - "track" to 3, - "album_id" to 7L, - "artist_id" to 8L - ) - ) - assertEquals(42L, candidate.mediaStoreId) - assertEquals("external_primary", candidate.volumeName) - assertEquals("content://media/external_primary/audio/media/42", candidate.contentUri) - assertEquals("song.mp3", candidate.displayName) - assertEquals("audio/mpeg", candidate.mimeType) - assertEquals(5_000_000L, candidate.sizeBytes) - assertEquals(1_700_000_000L, candidate.dateModifiedSec) - assertEquals(200_000L, candidate.durationMs) - assertEquals("Music/Rock/", candidate.relativePath) - assertEquals("Song", candidate.title) - assertEquals(2021, candidate.year) - assertEquals(3, candidate.track) - assertEquals(7L, candidate.albumId) - } - - @Test - fun toleratesMissingAndGarbage() { - val candidate = mapCandidate( - row("_id" to 1L, "duration" to "not-a-number", "year" to "abc"), - volumeFallback = "external" - ) - assertEquals("external", candidate.volumeName) - assertEquals("content://media/external/audio/media/1", candidate.contentUri) - assertEquals("unknown", candidate.displayName) - assertEquals(0L, candidate.durationMs) - assertEquals(0L, candidate.sizeBytes) - assertEquals(null, candidate.year) - assertEquals(null, candidate.title) - } - - @Test - fun contentUriFor_buildsVolumeUris() { - assertEquals( - "content://media/ABCD-1234/audio/media/9", - contentUriFor("ABCD-1234", 9L) - ) - } -} - -class MimeClassificationTest { - - @Test - fun acceptsAudioMimes() { - assertTrue(SupportedMimeTypes.isAudioMime("audio/mpeg")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/flac")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/x-flac")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/mp4")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/ogg")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/opus")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/wav")) - assertTrue(SupportedMimeTypes.isAudioMime("application/ogg")) - assertTrue(SupportedMimeTypes.isAudioMime("audio/mpeg; charset=binary")) - } - - @Test - fun rejectsPlaylistsAndNonAudio() { - assertFalse(SupportedMimeTypes.isAudioMime("audio/x-mpegurl")) - assertFalse(SupportedMimeTypes.isAudioMime("audio/mpegurl")) - assertFalse(SupportedMimeTypes.isAudioMime("application/vnd.apple.mpegurl")) - assertFalse(SupportedMimeTypes.isAudioMime("audio/x-scpls")) - assertFalse(SupportedMimeTypes.isAudioMime("video/mp4")) - assertFalse(SupportedMimeTypes.isAudioMime("image/jpeg")) - assertFalse(SupportedMimeTypes.isAudioMime(null)) - assertFalse(SupportedMimeTypes.isAudioMime("")) - assertFalse(SupportedMimeTypes.isAudioMime(" ")) - } -} diff --git a/app/src/test/java/com/resonance/player/data/NormalizationTest.kt b/app/src/test/java/com/resonance/player/data/NormalizationTest.kt index 0d5f63b..b77302e 100644 --- a/app/src/test/java/com/resonance/player/data/NormalizationTest.kt +++ b/app/src/test/java/com/resonance/player/data/NormalizationTest.kt @@ -1,6 +1,6 @@ package com.resonance.player.data -import com.resonance.player.core.media.MediaItemCandidate +import com.resonance.player.core.media.AudioCandidate import com.resonance.player.core.media.SongMetadata import com.resonance.player.core.media.UnknownMetadata import com.resonance.player.data.media.FilenameFallback @@ -11,21 +11,12 @@ import org.junit.Test private fun candidate( displayName: String = "track.mp3", - title: String? = null, - artist: String? = null, - album: String? = null, - genre: String? = null, - year: Int? = null, - track: Int? = null, - durationMs: Long = 200_000L, relativePath: String? = "Music/" -) = MediaItemCandidate( - mediaStoreId = 1L, volumeName = "external", - contentUri = "content://media/external/audio/media/1", +) = AudioCandidate( + id = 1L, sourceId = 1L, + contentUri = "content://com.android.externalstorage.documents/tree/x/document/y", displayName = displayName, mimeType = "audio/mpeg", sizeBytes = 5_000_000L, - dateModifiedSec = 100L, dateAddedSec = 90L, durationMs = durationMs, - relativePath = relativePath, title = title, artist = artist, album = album, - genre = genre, year = year, track = track, albumId = 10L, artistId = 20L + dateModifiedSec = 100L, relativePath = relativePath ) private fun tags( @@ -47,13 +38,19 @@ private fun tags( class MetadataNormalizerTest { @Test - fun embeddedTags_winOverColumns() { + fun embeddedTags_areUsed() { val track = MetadataNormalizer.normalize( - candidate(title = "Column Title", artist = "Column Artist"), - tags(title = "Tag Title", artist = "Tag Artist") + candidate(), + tags(title = "Tag Title", artist = "Tag Artist", album = "Tag Album", genre = "Rock", yearRaw = "2020-05-01", trackRaw = "3/12") ) assertEquals("Tag Title", track.title) assertEquals("Tag Artist", track.artistName) + assertEquals("Tag Album", track.albumName) + assertEquals("Rock", track.genreName) + assertEquals(2020, track.year) + assertEquals(3, track.trackNumber) + assertEquals(12, track.totalTracks) + assertEquals("Music/track.mp3", track.displayPath) } @Test @@ -77,19 +74,24 @@ class MetadataNormalizerTest { } @Test - fun duration_prefersMediaStore_thenTags_thenZero() { + fun duration_comesFromTags_elseZero() { assertEquals( - 200_000L, - MetadataNormalizer.normalize(candidate(durationMs = 200_000L), null).durationMs - ) - val fromTags = MetadataNormalizer.normalize( - candidate(durationMs = 0L), - tags().copy(durationMs = 180_000L) + 180_000L, + MetadataNormalizer.normalize(candidate(), tags().copy(durationMs = 180_000L)).durationMs ) - assertEquals(180_000L, fromTags.durationMs) + assertEquals(0L, MetadataNormalizer.normalize(candidate(), null).durationMs) assertEquals( 0L, - MetadataNormalizer.normalize(candidate(durationMs = -5L), null).durationMs + MetadataNormalizer.normalize(candidate(), tags().copy(durationMs = -5L)).durationMs + ) + } + + @Test + fun mimeType_fallsBackToTheCandidate() { + assertEquals("audio/mpeg", MetadataNormalizer.normalize(candidate(), null).mimeType) + assertEquals( + "audio/flac", + MetadataNormalizer.normalize(candidate(), tags().copy(mimeType = "audio/flac")).mimeType ) } } diff --git a/app/src/test/java/com/resonance/player/data/ReconcilerTest.kt b/app/src/test/java/com/resonance/player/data/ReconcilerTest.kt deleted file mode 100644 index 35994aa..0000000 --- a/app/src/test/java/com/resonance/player/data/ReconcilerTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.resonance.player.data - -import com.resonance.player.core.media.MediaItemCandidate -import com.resonance.player.data.media.Reconciler -import com.resonance.player.data.media.StoredFingerprint -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -private fun stored( - id: Long, - modified: Long = 100L, - size: Long = 1000L, - volume: String = "external" -) = StoredFingerprint(id, id, volume, modified, size, playCount = 5L, lastPlayedSec = 50L) - -private fun seen( - id: Long, - modified: Long = 100L, - size: Long = 1000L, - volume: String = "external" -) = MediaItemCandidate( - mediaStoreId = id, volumeName = volume, - contentUri = "content://media/$volume/audio/media/$id", - displayName = "$id.mp3", mimeType = "audio/mpeg", sizeBytes = size, - dateModifiedSec = modified, dateAddedSec = 90L, durationMs = 200_000L, - relativePath = null, title = null, artist = null, album = null, - genre = null, year = null, track = null, albumId = null, artistId = null -) - -private fun keyOf(id: Long, volume: String = "external") = id to volume - -class ReconcilerTest { - - @Test - fun emptyDb_importsEverything() { - val seenMap = mapOf(keyOf(1L) to seen(1L), keyOf(2L) to seen(2L)) - val plan = Reconciler.plan(emptyList(), seenMap) - assertEquals(2, plan.toImport.size) - assertTrue(plan.toDelete.isEmpty()) - } - - @Test - fun unchangedItems_areSkipped() { - val plan = Reconciler.plan( - listOf(stored(1L), stored(2L)), - mapOf(keyOf(1L) to seen(1L), keyOf(2L) to seen(2L)) - ) - assertTrue(plan.toImport.isEmpty()) - assertTrue(plan.toDelete.isEmpty()) - } - - @Test - fun modifiedSizeOrMtime_triggersReimport() { - val plan = Reconciler.plan( - listOf(stored(1L), stored(2L)), - mapOf(keyOf(1L) to seen(1L, modified = 200L), keyOf(2L) to seen(2L, size = 2000L)) - ) - assertEquals(setOf(1L, 2L), plan.toImport.map { it.mediaStoreId }.toSet()) - assertTrue(plan.toDelete.isEmpty()) - } - - @Test - fun vanishedRows_areDeleted() { - val plan = Reconciler.plan( - listOf(stored(1L), stored(2L), stored(3L)), - mapOf(keyOf(1L) to seen(1L)) - ) - assertTrue(plan.toImport.isEmpty()) - assertEquals(listOf(2L, 3L), plan.toDelete.sorted()) - } - - @Test - fun mixedScenario() { - val plan = Reconciler.plan( - listOf(stored(1L), stored(2L), stored(4L)), - mapOf( - keyOf(1L) to seen(1L), - keyOf(2L) to seen(2L, modified = 999L), - keyOf(3L) to seen(3L) - ) - ) - assertEquals(listOf(2L, 3L), plan.toImport.map { it.mediaStoreId }.sorted()) - assertEquals(listOf(4L), plan.toDelete) - } - - @Test - fun sameIdOnDifferentVolumes_areDistinct() { - val plan = Reconciler.plan( - listOf(stored(1L, volume = "external")), - mapOf(keyOf(1L, "ABCD-1234") to seen(1L, volume = "ABCD-1234")) - ) - assertEquals(1, plan.toImport.size) - assertEquals(listOf(1L), plan.toDelete) - } - - @Test - fun durationFilter_skipsShortKeepsUnknown() { - assertTrue(Reconciler.passesDurationFilter(0L, ignoreShort = true)) - assertTrue(Reconciler.passesDurationFilter(-5L, ignoreShort = true)) - assertFalse(Reconciler.passesDurationFilter(29_999L, ignoreShort = true)) - assertTrue(Reconciler.passesDurationFilter(30_000L, ignoreShort = true)) - assertTrue(Reconciler.passesDurationFilter(1_000L, ignoreShort = false)) - } -} diff --git a/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt b/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt index 8b3acee..f1c55bf 100644 --- a/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt +++ b/app/src/test/java/com/resonance/player/data/SafCandidatesTest.kt @@ -6,6 +6,7 @@ import com.resonance.player.core.media.StableIds import com.resonance.player.core.media.audioCandidateOf import com.resonance.player.core.media.isAudioDoc import com.resonance.player.core.media.joinRelPath +import com.resonance.player.core.media.uniqueName import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -62,6 +63,14 @@ class SafCandidatesTest { assertEquals("Music/Rock/Live/", joinRelPath("Music/Rock/", "/Live/")) } + @Test + fun uniqueName_numbersDuplicates() { + assertEquals("Music", uniqueName("Music", emptySet())) + assertEquals("Music (2)", uniqueName("Music", setOf("Music"))) + assertEquals("Music (3)", uniqueName("Music", setOf("Music", "Music (2)"))) + assertEquals("Rock", uniqueName("Rock", setOf("Music", "Music (2)"))) + } + @Test fun audioCandidateOf_mapsRow() { val c = audioCandidateOf( diff --git a/app/src/test/java/com/resonance/player/fakes/Fakes.kt b/app/src/test/java/com/resonance/player/fakes/Fakes.kt index 765c6d7..2abc541 100644 --- a/app/src/test/java/com/resonance/player/fakes/Fakes.kt +++ b/app/src/test/java/com/resonance/player/fakes/Fakes.kt @@ -19,9 +19,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf fun testSong(id: Long, title: String = "Song $id") = Song( - id = id, mediaStoreId = id, volumeName = "external", title = title, + id = id, title = title, artistName = "Artist", albumName = "Album", albumArtist = null, - albumId = null, artistId = null, genreName = null, + genreName = null, trackNumber = null, totalTracks = null, discNumber = null, totalDiscs = null, year = null, durationMs = 180_000L, path = "Music/$id.mp3", contentUri = "content://media/external/audio/media/$id", From cff2f2ac27bf2fa174a6b590faca60e0fa3b7fab Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:25:26 +0300 Subject: [PATCH 4/7] Add the music-source UI: pickers, Settings sources, empty-state CTAs The two system pickers (folder tree, multiple audio files) are created once in the app shell and exposed to screens through LocalMusicActions; a pick takes the grant and scans right away. Settings gets a "Music sources" group (a row per folder with song count and remove, one aggregate "Added songs" row, Add folder / Add songs, and a hint about the folders Android refuses). A folder whose access was lost stays listed and reopens the picker there. The empty state on Home and every Library tab now offers both actions. Also: "Rescan library" becomes "Refresh library"; Home no longer flashes the empty state before Room's first emission; playlist counts only count items whose song is still in the library; Settings stats refresh when a scan completes; sources are removed in one batch with a single rescan. Co-Authored-By: Claude Sonnet 5 --- .../com/resonance/player/app/AppContainer.kt | 4 +- .../core/database/dao/CollectionDaos.kt | 6 +- .../core/ui/components/LibraryStates.kt | 81 ++++++-- .../player/core/ui/components/MusicActions.kt | 16 ++ .../player/data/local/RoomSourceRepository.kt | 16 +- .../player/domain/library/Sources.kt | 13 +- .../player/feature/home/HomeScreen.kt | 3 +- .../player/feature/settings/SettingsScreen.kt | 175 ++++++++++++------ .../feature/settings/SettingsViewModel.kt | 26 ++- .../player/navigation/AppNavGraph.kt | 29 ++- app/src/main/res/values/strings.xml | 25 ++- 11 files changed, 292 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/resonance/player/core/ui/components/MusicActions.kt diff --git a/app/src/main/java/com/resonance/player/app/AppContainer.kt b/app/src/main/java/com/resonance/player/app/AppContainer.kt index c7043db..c052135 100644 --- a/app/src/main/java/com/resonance/player/app/AppContainer.kt +++ b/app/src/main/java/com/resonance/player/app/AppContainer.kt @@ -43,7 +43,7 @@ import com.resonance.player.domain.library.ObserveScanStateUseCase import com.resonance.player.domain.library.ObserveSongsUseCase import com.resonance.player.domain.library.ObserveSourcesUseCase import com.resonance.player.domain.library.RecordPlayUseCase -import com.resonance.player.domain.library.RemoveSourceUseCase +import com.resonance.player.domain.library.RemoveSourcesUseCase import com.resonance.player.domain.library.RescanLibraryUseCase import com.resonance.player.domain.playback.AppendToQueueUseCase import com.resonance.player.domain.playback.ClearQueueUseCase @@ -214,7 +214,7 @@ class AppContainer(context: Context) { val rescanLibrary = RescanLibraryUseCase(musicRepository) val observeSources = ObserveSourcesUseCase(sourceRepository) val addSources = AddSourcesUseCase(sourceRepository, musicRepository) - val removeSource = RemoveSourceUseCase(sourceRepository, musicRepository) + val removeSources = RemoveSourcesUseCase(sourceRepository, musicRepository) val getLibraryStats = GetLibraryStatsUseCase(musicRepository) val observeLastScan = ObserveLastScanUseCase(musicRepository) val observeRecentlyPlayed = ObserveRecentlyPlayedUseCase(musicRepository) diff --git a/app/src/main/java/com/resonance/player/core/database/dao/CollectionDaos.kt b/app/src/main/java/com/resonance/player/core/database/dao/CollectionDaos.kt index 717a0c6..d1fc7c1 100644 --- a/app/src/main/java/com/resonance/player/core/database/dao/CollectionDaos.kt +++ b/app/src/main/java/com/resonance/player/core/database/dao/CollectionDaos.kt @@ -18,7 +18,11 @@ interface PlaylistDao { @Query("SELECT * FROM playlists WHERE name LIKE :q ESCAPE '\\' ORDER BY name COLLATE NOCASE ASC LIMIT :limit") fun searchPlaylists(q: String, limit: Int): Flow> - @Query("SELECT playlistId, COUNT(*) AS itemCount FROM playlist_items GROUP BY playlistId") + // Inner join: an item whose song left the library (source removed) is not shown, so it must not be counted. + @Query( + "SELECT pi.playlistId AS playlistId, COUNT(*) AS itemCount FROM playlist_items pi " + + "INNER JOIN songs s ON s.id = pi.songId GROUP BY pi.playlistId" + ) fun observeItemCounts(): Flow> @Query("SELECT * FROM playlists WHERE id = :id") diff --git a/app/src/main/java/com/resonance/player/core/ui/components/LibraryStates.kt b/app/src/main/java/com/resonance/player/core/ui/components/LibraryStates.kt index 5f171f1..99f6d46 100644 --- a/app/src/main/java/com/resonance/player/core/ui/components/LibraryStates.kt +++ b/app/src/main/java/com/resonance/player/core/ui/components/LibraryStates.kt @@ -1,5 +1,8 @@ package com.resonance.player.core.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,13 +13,21 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.resonance.player.R +import com.resonance.player.core.ui.theme.ResonanceTheme +import kotlinx.coroutines.delay /** * Shared deterministic states. Layouts use constraints + dp spacing only — @@ -33,29 +44,63 @@ fun LoadingView(modifier: Modifier = Modifier) { } } +/** + * "No music yet" with the two ways to add some. Used by Home and every Library + * tab, so the CTA is one place. + */ @Composable fun EmptyLibraryView(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.empty_library_title), - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.empty_library_body), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) + val actions = LocalMusicActions.current + val colors = ResonanceTheme.colors + val typography = ResonanceTheme.typography + val spacing = ResonanceTheme.spacing + // ponytail: the lists start empty until Room's first emission; waiting a beat keeps the CTA + // from flashing on a library that does have songs. A real loading state per list replaces this. + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(EMPTY_STATE_DELAY_MS) + visible = true + } + AnimatedVisibility(visible = visible, enter = fadeIn(tween(200)), modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(spacing.xxl), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.empty_library_title), + style = typography.titleMd, + color = colors.textPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.empty_library_body), + style = typography.bodyMd, + color = colors.textSecondary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(spacing.lg)) + ResonancePrimaryButton( + label = stringResource(R.string.music_add_folder), + onClick = { actions.addFolder(null) } + ) + Spacer(Modifier.height(spacing.sm)) + TextButton(onClick = actions.addSongs) { + Text( + stringResource(R.string.music_add_songs), + style = typography.labelLg, + color = colors.accent + ) + } + } } } +private const val EMPTY_STATE_DELAY_MS = 150L + @Composable fun ErrorView( message: String, diff --git a/app/src/main/java/com/resonance/player/core/ui/components/MusicActions.kt b/app/src/main/java/com/resonance/player/core/ui/components/MusicActions.kt new file mode 100644 index 0000000..a782a03 --- /dev/null +++ b/app/src/main/java/com/resonance/player/core/ui/components/MusicActions.kt @@ -0,0 +1,16 @@ +package com.resonance.player.core.ui.components + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * How screens ask for music to be added. The system pickers live once in the + * app shell (one launcher, one result handler); screens only trigger them. + * [addFolder] takes an optional folder URI to open the picker at, used to + * re-add a folder whose access was lost. + */ +class MusicActions( + val addFolder: (initialUri: String?) -> Unit, + val addSongs: () -> Unit +) + +val LocalMusicActions = staticCompositionLocalOf { MusicActions(addFolder = {}, addSongs = {}) } diff --git a/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt b/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt index 7261b13..f031321 100644 --- a/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt +++ b/app/src/main/java/com/resonance/player/data/local/RoomSourceRepository.kt @@ -70,16 +70,18 @@ class RoomSourceRepository( } } - override suspend fun removeSource(id: Long) { + override suspend fun removeSources(ids: List) { withContext(dispatchers.io) { scanner.cancel() val dao = database.sourceDao() - val source = dao.getById(id) ?: return@withContext - dao.delete(id) - try { - resolver.releasePersistableUriPermission(Uri.parse(source.uri), Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: SecurityException) { - // No grant left to release (it was already lost). + for (id in ids) { + val source = dao.getById(id) ?: continue + dao.delete(id) + try { + resolver.releasePersistableUriPermission(Uri.parse(source.uri), Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + // No grant left to release (it was already lost). + } } } } diff --git a/app/src/main/java/com/resonance/player/domain/library/Sources.kt b/app/src/main/java/com/resonance/player/domain/library/Sources.kt index 6e49616..dbacdeb 100644 --- a/app/src/main/java/com/resonance/player/domain/library/Sources.kt +++ b/app/src/main/java/com/resonance/player/domain/library/Sources.kt @@ -15,8 +15,8 @@ interface SourceRepository { /** Idempotent: an already added uri keeps its row and just gets its grant re-taken (the repair path). */ suspend fun addSources(kind: SourceKind, uris: List) - /** Drops the source, its songs and its read grant. The file itself is never touched. */ - suspend fun removeSource(id: Long) + /** Drops the sources, their songs and their read grants. The files themselves are never touched. */ + suspend fun removeSources(ids: List) } class ObserveSourcesUseCase(private val repository: SourceRepository) { @@ -35,13 +35,14 @@ class AddSourcesUseCase( } } -/** Removes a source, then rescans so files it shared with another source come back under that one. */ -class RemoveSourceUseCase( +/** Removes sources, then rescans so files they shared with another source come back under that one. */ +class RemoveSourcesUseCase( private val sources: SourceRepository, private val library: MusicRepository ) { - suspend operator fun invoke(id: Long) { - sources.removeSource(id) + suspend operator fun invoke(ids: List) { + if (ids.isEmpty()) return + sources.removeSources(ids) library.scanAndImport() } } diff --git a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt index 710bdad..51f11be 100644 --- a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt @@ -103,7 +103,8 @@ private fun HomeContent( val scanState by viewModel.scanState.collectAsStateWithLifecycle() val storage by viewModel.storage.collectAsStateWithLifecycle() val trackCount = storage?.trackCount ?: 0 - val isEmpty = trackCount == 0 && scanState !is ScanState.Scanning + // storage is null until Room's first emission: not "empty" yet, so no CTA flash on cold start. + val isEmpty = storage != null && trackCount == 0 && scanState !is ScanState.Scanning Column(Modifier.fillMaxSize()) { ResonanceTopBar( diff --git a/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt b/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt index e1ac740..576d70c 100644 --- a/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/settings/SettingsScreen.kt @@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.TimerOff import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults @@ -30,18 +35,23 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import android.app.Activity import com.resonance.player.BuildConfig import com.resonance.player.R import com.resonance.player.core.common.userMessage import com.resonance.player.core.media.ScanState -import com.resonance.player.core.permissions.AudioPermissionStatus +import com.resonance.player.core.model.MusicSource +import com.resonance.player.core.model.SourceKind +import com.resonance.player.core.ui.components.LocalMusicActions +import com.resonance.player.core.ui.components.ResonanceDialog import com.resonance.player.core.ui.components.ResonancePrimaryButton import com.resonance.player.core.ui.components.ResonanceSectionHeader import com.resonance.player.core.ui.components.ResonanceSegmentedControl @@ -49,8 +59,6 @@ import com.resonance.player.core.ui.components.ResonanceSettingsIcon import com.resonance.player.core.ui.components.ResonanceSettingsRow import com.resonance.player.core.ui.components.ResonanceSwitch import com.resonance.player.core.ui.components.ResonanceTopBar -import com.resonance.player.core.ui.components.openAppSettings -import com.resonance.player.core.ui.components.rememberPermissionGrant import com.resonance.player.core.ui.theme.ACCENT_PRESETS import com.resonance.player.core.ui.theme.ResonanceTheme import com.resonance.player.core.ui.theme.accentPreviewColor @@ -67,13 +75,9 @@ fun SettingsScreen(viewModel: SettingsViewModel) { val scanState by viewModel.scanState.collectAsStateWithLifecycle() val lastScan by viewModel.lastScan.collectAsStateWithLifecycle() val stats by viewModel.stats.collectAsStateWithLifecycle() - val permission by viewModel.permissionStatus.collectAsStateWithLifecycle() + val sources by viewModel.sources.collectAsStateWithLifecycle() val ignoreShort by viewModel.ignoreShortFiles.collectAsStateWithLifecycle() - val context = LocalContext.current - val activity = context as? Activity - val requestGrant = rememberPermissionGrant(viewModel.permissionManager) { - viewModel.rescan() - } + var removeTarget by remember { mutableStateOf?>(null) } val colors = ResonanceTheme.colors val typography = ResonanceTheme.typography val spacing = ResonanceTheme.spacing @@ -84,6 +88,15 @@ fun SettingsScreen(viewModel: SettingsViewModel) { .fillMaxSize() .verticalScroll(rememberScrollState()) ) { + SettingsGroupLabel(stringResource(R.string.settings_group_sources)) + MusicSourcesCard(sources, onRemove = { removeTarget = it }) + Text( + text = stringResource(R.string.music_sources_hint), + style = typography.bodySm, + color = colors.textMuted, + modifier = Modifier.padding(horizontal = spacing.lg, vertical = spacing.sm) + ) + Spacer(Modifier.height(spacing.sectionSpacing)) SettingsGroupLabel(stringResource(R.string.settings_group_library)) Surface( shape = ResonanceTheme.radii.card, @@ -174,39 +187,6 @@ fun SettingsScreen(viewModel: SettingsViewModel) { ) } ) - ResonanceSettingsRow( - title = stringResource(R.string.settings_permission), - subtitle = permissionLabel(permission), - leading = { - ResonanceSettingsIcon( - icon = Icons.Filled.Shield, - contentDescription = null - ) - }, - trailing = { - if (permission != AudioPermissionStatus.Granted) { - androidx.compose.material3.TextButton( - onClick = { - if (permission == AudioPermissionStatus.PermanentlyDenied) { - openAppSettings(activity) - } else { - requestGrant() - } - } - ) { - Text( - if (permission == AudioPermissionStatus.PermanentlyDenied) { - stringResource(R.string.perm_open_settings) - } else { - stringResource(R.string.perm_grant) - }, - style = typography.labelLg, - color = colors.accent - ) - } - } - } - ) } } Spacer(Modifier.height(spacing.sectionSpacing)) @@ -258,8 +238,107 @@ fun SettingsScreen(viewModel: SettingsViewModel) { Spacer(Modifier.height(spacing.xxl)) } } + removeTarget?.let { targets -> + val folder = targets.singleOrNull()?.takeIf { it.kind == SourceKind.TREE } + ResonanceDialog( + title = stringResource( + if (folder != null) R.string.music_remove_folder_title else R.string.music_remove_songs_title + ), + text = if (folder != null) { + stringResource(R.string.music_remove_folder_body, folder.displayName) + } else { + stringResource(R.string.music_remove_songs_body) + }, + confirmLabel = stringResource(R.string.music_remove), + onConfirm = { + viewModel.removeSources(targets.map { it.id }) + removeTarget = null + }, + onDismiss = { removeTarget = null }, + dismissLabel = stringResource(R.string.action_dismiss) + ) + } } +/** + * The folders and songs the library is built from. Folders get a row each; + * individually added songs share ONE row (a row per song would bury the + * folders). A folder whose access was lost stays listed (its songs are kept): + * tapping it reopens the picker there, which repairs it. + */ +@Composable +private fun MusicSourcesCard(sources: List, onRemove: (List) -> Unit) { + val actions = LocalMusicActions.current + val colors = ResonanceTheme.colors + Surface( + shape = ResonanceTheme.radii.card, + color = colors.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ResonanceTheme.spacing.lg) + ) { + Column { + sources.filter { it.kind == SourceKind.TREE }.forEach { source -> + ResonanceSettingsRow( + title = source.displayName, + subtitle = if (source.accessOk) { + songCountLabel(source.songCount) + } else { + stringResource(R.string.music_access_lost) + }, + leading = { ResonanceSettingsIcon(icon = Icons.Filled.Folder, contentDescription = null) }, + trailing = { RemoveButton(source.displayName) { onRemove(listOf(source)) } }, + onClick = if (source.accessOk) null else ({ actions.addFolder(source.uri) }) + ) + } + val files = sources.filter { it.kind == SourceKind.FILE } + if (files.isNotEmpty()) { + val count = songCountLabel(files.sumOf { it.songCount }) + val title = stringResource(R.string.music_added_songs) + ResonanceSettingsRow( + title = title, + subtitle = if (files.all { it.accessOk }) { + count + } else { + count + " - " + stringResource(R.string.music_files_lost) + }, + leading = { ResonanceSettingsIcon(icon = Icons.Filled.MusicNote, contentDescription = null) }, + trailing = { RemoveButton(title) { onRemove(files) } } + ) + } + ResonanceSettingsRow( + title = stringResource(R.string.music_add_folder), + subtitle = null, + leading = { ResonanceSettingsIcon(icon = Icons.Filled.CreateNewFolder, contentDescription = null) }, + trailing = { }, + onClick = { actions.addFolder(null) } + ) + ResonanceSettingsRow( + title = stringResource(R.string.music_add_songs), + subtitle = null, + leading = { ResonanceSettingsIcon(icon = Icons.Filled.MusicNote, contentDescription = null) }, + trailing = { }, + onClick = actions.addSongs + ) + } + } +} + +@Composable +private fun RemoveButton(name: String, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.music_remove_named, name), + tint = ResonanceTheme.colors.textSecondary + ) + } +} + +@Composable +private fun songCountLabel(count: Int): String = + pluralStringResource(R.plurals.music_song_count, count, count) + /** Accent hue picker: curated swatches (tap) + a full hue slider (drag). */ @Composable private fun AccentPicker(hue: Float, onSelect: (Float) -> Unit) { @@ -325,14 +404,6 @@ private fun themeName(mode: ThemeMode): String = when (mode) { ThemeMode.DARK -> stringResource(R.string.settings_theme_dark) } -@Composable -private fun permissionLabel(status: AudioPermissionStatus): String = when (status) { - AudioPermissionStatus.Granted -> stringResource(R.string.perm_granted) - AudioPermissionStatus.NotAsked -> stringResource(R.string.perm_rationale) - AudioPermissionStatus.Denied -> stringResource(R.string.perm_rationale) - AudioPermissionStatus.PermanentlyDenied -> stringResource(R.string.perm_permanently_denied) -} - private val scanTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) diff --git a/app/src/main/java/com/resonance/player/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/resonance/player/feature/settings/SettingsViewModel.kt index 5162ff1..eeb2e0c 100644 --- a/app/src/main/java/com/resonance/player/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/resonance/player/feature/settings/SettingsViewModel.kt @@ -4,12 +4,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.resonance.player.core.media.ScanState import com.resonance.player.core.model.LibraryStats -import com.resonance.player.core.permissions.AudioPermissionManager -import com.resonance.player.core.permissions.AudioPermissionStatus +import com.resonance.player.core.model.MusicSource import com.resonance.player.data.local.LibraryPreferences import com.resonance.player.domain.library.GetLibraryStatsUseCase import com.resonance.player.domain.library.ObserveLastScanUseCase import com.resonance.player.domain.library.ObserveScanStateUseCase +import com.resonance.player.domain.library.ObserveSourcesUseCase +import com.resonance.player.domain.library.RemoveSourcesUseCase import com.resonance.player.domain.library.RescanLibraryUseCase import com.resonance.player.core.ui.theme.DEFAULT_ACCENT_HUE import com.resonance.player.domain.settings.ThemeMode @@ -21,12 +22,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** Settings: theme (DataStore) + library section (stats, scan, rescan). */ +/** Settings: theme (DataStore) + music sources + library section (stats, refresh). */ class SettingsViewModel( private val repository: UserPreferencesRepository, - val permissionManager: AudioPermissionManager, observeScanState: ObserveScanStateUseCase, private val rescanLibrary: RescanLibraryUseCase, + observeSources: ObserveSourcesUseCase, + private val removeSourcesUseCase: RemoveSourcesUseCase, private val getLibraryStats: GetLibraryStatsUseCase, observeLastScan: ObserveLastScanUseCase, private val libraryPreferences: LibraryPreferences @@ -43,7 +45,8 @@ class SettingsViewModel( val lastScan: StateFlow = observeLastScan() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - val permissionStatus: StateFlow = permissionManager.status + val sources: StateFlow> = observeSources() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val ignoreShortFiles: StateFlow = libraryPreferences.ignoreShortFiles .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) @@ -53,6 +56,10 @@ class SettingsViewModel( init { refreshStats() + // Scans are also started by the app shell right after a folder/song is added. + viewModelScope.launch { + observeScanState().collect { if (it is ScanState.Completed) refreshStats() } + } } fun setThemeMode(mode: ThemeMode) { @@ -74,10 +81,11 @@ class SettingsViewModel( } fun rescan() { - viewModelScope.launch { - rescanLibrary() - refreshStats() - } + viewModelScope.launch { rescanLibrary() } + } + + fun removeSources(ids: List) { + viewModelScope.launch { removeSourcesUseCase(ids) } } fun refreshStats() { diff --git a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt index 16a7163..eee5ca5 100644 --- a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt @@ -1,5 +1,8 @@ package com.resonance.player.navigation +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -21,6 +24,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.remember @@ -49,8 +53,11 @@ import com.resonance.player.R import com.resonance.player.core.ui.theme.ResonanceTheme import com.resonance.player.app.AppContainer import com.resonance.player.core.ui.adaptive.WindowWidthSize +import com.resonance.player.core.model.SourceKind import com.resonance.player.core.ui.adaptive.rememberWindowWidthSize import com.resonance.player.core.ui.components.ArtworkImage +import com.resonance.player.core.ui.components.LocalMusicActions +import com.resonance.player.core.ui.components.MusicActions import com.resonance.player.core.ui.components.NavDockDestination import com.resonance.player.core.ui.components.ResonanceMiniPlayer import com.resonance.player.core.ui.components.ResonanceNavDock @@ -127,6 +134,21 @@ fun ResonanceAppShell(container: AppContainer) { scope.launch { snackbarHostState.showSnackbar(ResonanceSnackbarVisuals(message)) } } + // The system pickers are created once here; screens trigger them through LocalMusicActions. + // The grant is taken and the scan started right after a pick (nothing else scans by itself). + val addFolderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) scope.launch { container.addSources(SourceKind.TREE, listOf(uri.toString())) } + } + val addSongsPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) scope.launch { container.addSources(SourceKind.FILE, uris.map { it.toString() }) } + } + val musicActions = remember { + MusicActions( + addFolder = { initialUri -> addFolderPicker.launch(initialUri?.let(Uri::parse)) }, + addSongs = { addSongsPicker.launch(arrayOf("audio/*")) } + ) + } + /** Bottom-nav/rail tab switches only: single-top with saved/restored tab state. */ fun navigateToTab(route: String) { navController.navigate(route) { @@ -300,9 +322,10 @@ fun ResonanceAppShell(container: AppContainer) { factory = factory { SettingsViewModel( container.settingsRepository, - container.permissionManager, container.observeScanState, container.rescanLibrary, + container.observeSources, + container.removeSources, container.getLibraryStats, container.observeLastScan, container.libraryPreferences @@ -449,7 +472,9 @@ fun ResonanceAppShell(container: AppContainer) { ) } Column(modifier = Modifier.weight(1f)) { - AppGraph(modifier = Modifier.weight(1f)) + CompositionLocalProvider(LocalMusicActions provides musicActions) { + AppGraph(modifier = Modifier.weight(1f)) + } AnimatedVisibility( visible = showMiniPlayer, enter = fadeIn() + slideInVertically { it }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 073332c..f974d43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,23 @@ Queue Playlists No music yet - Grant audio access, then scan your device. Your files never leave this device. + Add a folder with your music, or pick single songs. Your files stay where they are and never leave this device. + Add folder + Add songs + Added songs + Access lost. Tap to add this folder again + some can\'t be opened, add them again + Remove + Remove %1$s + Remove folder? + The songs from \"%1$s\" leave your library. Your files are not deleted. + Remove added songs? + All songs you added one by one leave your library. Your files are not deleted. + Android does not let apps use a whole storage or the Download folder. Add a subfolder instead, or pick files with Add songs. + + %1$d song + %1$d songs + Something went wrong Retry Dismiss @@ -43,8 +59,8 @@ Scanning your music… Some files could not be imported. Library - Rescan library - Last scan + Refresh library + Last refreshed Never Scanning… songs @@ -52,12 +68,13 @@ Audio access artists genres + Music sources Local library Appearance Accent color About Ignore short files - Skip audio under 30 seconds on the next scan + Skip audio under 30 seconds on the next refresh System Light Dark From a7c92c26470225599976998aa0bd17f4ff0dd2b5 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:27:23 +0300 Subject: [PATCH 5/7] Remove the audio-permission stack The library no longer needs READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE, so the permission gates, manager, status model, the Settings "Audio access" row, the manifest permissions, the perm_* strings, the permissionAsked pref and the CheckingPermission / PermissionRequired scan states go. Home and Library render their content directly. FOREGROUND_SERVICE* and POST_NOTIFICATIONS stay for playback. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 16 +-- .../java/com/resonance/player/MainActivity.kt | 21 --- .../com/resonance/player/app/AppContainer.kt | 5 - .../player/core/media/MediaBoundaries.kt | 2 - .../core/permissions/AudioPermission.kt | 35 ----- .../core/permissions/MusicPermissions.kt | 22 --- .../core/ui/components/PermissionGate.kt | 131 ------------------ .../data/local/AudioPermissionManager.kt | 49 ------- .../player/data/local/LibraryPreferences.kt | 10 +- .../player/feature/home/HomeScreen.kt | 23 +-- .../player/feature/library/LibraryScreen.kt | 22 +-- .../player/navigation/AppNavGraph.kt | 2 - app/src/main/res/values/strings.xml | 6 - .../resonance/player/core/FoundationTest.kt | 28 ---- .../resonance/player/core/ScanErrorsTest.kt | 24 ++++ .../player/core/ScanPermissionTest.kt | 49 ------- 16 files changed, 32 insertions(+), 413 deletions(-) delete mode 100644 app/src/main/java/com/resonance/player/core/permissions/AudioPermission.kt delete mode 100644 app/src/main/java/com/resonance/player/core/permissions/MusicPermissions.kt delete mode 100644 app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt delete mode 100644 app/src/main/java/com/resonance/player/data/local/AudioPermissionManager.kt create mode 100644 app/src/test/java/com/resonance/player/core/ScanErrorsTest.kt delete mode 100644 app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5af32b..bff5f0d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,22 +1,16 @@ - - - - - + - + AudioPermissionStatus.Granted - !askedBefore -> AudioPermissionStatus.NotAsked - shouldShowRationale -> AudioPermissionStatus.Denied - else -> AudioPermissionStatus.PermanentlyDenied -} - -/** - * Abstraction over the Activity permission request. Implemented once in the - * UI layer; ViewModels observe status through [AudioPermissionManager]. - */ -interface AudioPermissionManager { - val status: kotlinx.coroutines.flow.StateFlow - suspend fun refresh(shouldShowRationale: Boolean) - suspend fun markAsked() -} diff --git a/app/src/main/java/com/resonance/player/core/permissions/MusicPermissions.kt b/app/src/main/java/com/resonance/player/core/permissions/MusicPermissions.kt deleted file mode 100644 index 7c209f2..0000000 --- a/app/src/main/java/com/resonance/player/core/permissions/MusicPermissions.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.resonance.player.core.permissions - -/** - * Platform permission branches, isolated here so no other layer checks - * Build.VERSION directly (ADR-002). Uses raw SDK ints + permission-name - * literals so the pure mapping stays JVM-testable without Robolectric. - */ -object MusicPermissions { - const val READ_MEDIA_AUDIO = "android.permission.READ_MEDIA_AUDIO" - const val READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE" - const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS" - - /** Granular media permission (API 33+) vs legacy storage (<= 32). */ - fun audioPermissionForSdk(sdkInt: Int): String = - if (sdkInt >= 33) READ_MEDIA_AUDIO else READ_EXTERNAL_STORAGE - - /** Runtime notification permission only exists on API 33+. */ - fun needsNotificationPermission(sdkInt: Int): Boolean = sdkInt >= 33 - - /** Foreground-service mediaPlayback type only exists on API 29+. */ - fun supportsMediaPlaybackServiceType(sdkInt: Int): Boolean = sdkInt >= 29 -} diff --git a/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt b/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt deleted file mode 100644 index a645c6d..0000000 --- a/app/src/main/java/com/resonance/player/core/ui/components/PermissionGate.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.resonance.player.core.ui.components - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.app.ActivityCompat -import com.resonance.player.R -import com.resonance.player.core.permissions.AudioPermissionManager -import com.resonance.player.core.permissions.AudioPermissionStatus -import com.resonance.player.core.permissions.MusicPermissions -import kotlinx.coroutines.launch - -fun audioPermissionName(): String = - MusicPermissions.audioPermissionForSdk(Build.VERSION.SDK_INT) - -fun shouldShowAudioRationale(activity: Activity?): Boolean { - if (activity == null) return false - return ActivityCompat.shouldShowRequestPermissionRationale(activity, audioPermissionName()) -} - -fun openAppSettings(activity: Activity?) { - if (activity == null) return - val intent = Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", activity.packageName, null) - ) - activity.startActivity(intent) -} - -/** - * Centralized audio-permission UX. Cached library content stays visible - * underneath; the prompt card explains why access is needed (no dark - * patterns, no re-request loops — the OS owns denial backoff). - */ -@Composable -fun AudioPermissionGate( - manager: AudioPermissionManager, - onPermissionGranted: () -> Unit, - content: @Composable () -> Unit -) { - val status by manager.status.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - val context = LocalContext.current - val activity = context as? Activity - - LaunchedEffect(Unit) { - manager.refresh(shouldShowAudioRationale(activity)) - } - - val requestGrant = rememberPermissionGrant(manager, onPermissionGranted) - - Column { - when (status) { - AudioPermissionStatus.Granted -> Unit - AudioPermissionStatus.NotAsked, - AudioPermissionStatus.Denied -> { - PermissionPromptCard( - message = stringResource(R.string.perm_rationale), - actionLabel = stringResource(R.string.perm_grant), - onAction = requestGrant - ) - } - AudioPermissionStatus.PermanentlyDenied -> { - PermissionPromptCard( - message = stringResource(R.string.perm_permanently_denied), - actionLabel = stringResource(R.string.perm_open_settings), - onAction = { openAppSettings(activity) } - ) - } - } - content() - } -} - -@Composable -private fun PermissionPromptCard(message: String, actionLabel: String, onAction: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Column(Modifier.padding(16.dp)) { - Text(message, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onAction) { Text(actionLabel) } - } - } -} - -/** - * Shared permission-request launcher: marks "asked", refreshes status with - * the current rationale signal and reports grants. Used by Library gate and - * Settings alike so request behavior never diverges. - */ -@Composable -fun rememberPermissionGrant( - manager: AudioPermissionManager, - onPermissionGranted: () -> Unit -): () -> Unit { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val activity = context as? Activity - val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - scope.launch { - manager.markAsked() - manager.refresh(shouldShowAudioRationale(activity)) - if (granted) onPermissionGranted() - } - } - return { launcher.launch(audioPermissionName()) } -} \ No newline at end of file diff --git a/app/src/main/java/com/resonance/player/data/local/AudioPermissionManager.kt b/app/src/main/java/com/resonance/player/data/local/AudioPermissionManager.kt deleted file mode 100644 index fa2119e..0000000 --- a/app/src/main/java/com/resonance/player/data/local/AudioPermissionManager.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.resonance.player.data.local - -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import com.resonance.player.core.media.ScanState -import com.resonance.player.core.permissions.AudioPermissionManager -import com.resonance.player.core.permissions.AudioPermissionStatus -import com.resonance.player.core.permissions.MusicPermissions -import com.resonance.player.core.permissions.permissionStatusFor -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first - -/** Single centralized permission holder (no scattered SDK checks in UI). */ -class AppAudioPermissionManager( - private val appContext: Context, - private val prefs: LibraryPreferences -) : AudioPermissionManager { - - private val mutable = MutableStateFlow(AudioPermissionStatus.NotAsked) - override val status: StateFlow = mutable.asStateFlow() - - override suspend fun refresh(shouldShowRationale: Boolean) { - val granted = ContextCompat.checkSelfPermission( - appContext, MusicPermissions.audioPermissionForSdk(currentSdk()) - ) == PackageManager.PERMISSION_GRANTED - val asked = try { - prefs.permissionAsked.first() - } catch (e: Exception) { - false - } - mutable.value = permissionStatusFor(granted, asked, shouldShowRationale) - } - - override suspend fun markAsked() { - try { - prefs.setPermissionAsked() - } catch (e: Exception) { - // Best-effort flag; permission flow works without it. - } - refresh(shouldShowRationale = false) - } - - private fun currentSdk(): Int = android.os.Build.VERSION.SDK_INT -} - diff --git a/app/src/main/java/com/resonance/player/data/local/LibraryPreferences.kt b/app/src/main/java/com/resonance/player/data/local/LibraryPreferences.kt index 4b8d485..47f8334 100644 --- a/app/src/main/java/com/resonance/player/data/local/LibraryPreferences.kt +++ b/app/src/main/java/com/resonance/player/data/local/LibraryPreferences.kt @@ -14,14 +14,11 @@ private val Context.libraryPrefsStore: DataStore by preferencesData name = "library_prefs" ) -/** Tiny library prefs: last scan stamp + whether audio permission was asked. */ +/** Tiny library prefs: last scan stamp + the ignore-short-files switch. */ class LibraryPreferences(private val context: Context) { val lastScanEpochSec: Flow = context.libraryPrefsStore.data.map { it[Keys.LAST_SCAN] } - val permissionAsked: Flow = context.libraryPrefsStore.data - .map { it[Keys.PERMISSION_ASKED] ?: false } - val ignoreShortFiles: Flow = context.libraryPrefsStore.data .map { it[Keys.IGNORE_SHORT] ?: false } @@ -29,17 +26,12 @@ class LibraryPreferences(private val context: Context) { context.libraryPrefsStore.edit { it[Keys.LAST_SCAN] = epochSec } } - suspend fun setPermissionAsked() { - context.libraryPrefsStore.edit { it[Keys.PERMISSION_ASKED] = true } - } - suspend fun setIgnoreShortFiles(ignore: Boolean) { context.libraryPrefsStore.edit { it[Keys.IGNORE_SHORT] = ignore } } private object Keys { val LAST_SCAN = longPreferencesKey("last_scan_epoch_sec") - val PERMISSION_ASKED = booleanPreferencesKey("permission_asked") val IGNORE_SHORT = booleanPreferencesKey("ignore_short_files") } } diff --git a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt index 51f11be..70931bf 100644 --- a/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/home/HomeScreen.kt @@ -46,9 +46,7 @@ import com.resonance.player.core.common.formatBytes import com.resonance.player.core.common.formatDurationMs import com.resonance.player.core.media.ScanState import com.resonance.player.core.model.Song -import com.resonance.player.core.permissions.AudioPermissionManager import com.resonance.player.core.ui.components.ArtworkImage -import com.resonance.player.core.ui.components.AudioPermissionGate import com.resonance.player.core.ui.components.EmptyLibraryView import com.resonance.player.core.ui.components.PlaylistPickerSheet import com.resonance.player.core.ui.components.ResonanceAlbumCard @@ -66,29 +64,10 @@ import com.resonance.player.core.ui.theme.ResonanceTheme /** * Stitch Home: storage health card, Recently Played album carousel, * Jump-Back-In tiles, Recently Added rows. Everything is real repository - * data; empty library shows the permission/scan flow, never demo content. + * data; an empty library offers to add music, never demo content. */ @Composable fun HomeScreen( - viewModel: HomeViewModel, - permissionManager: AudioPermissionManager, - currentSongId: Long?, - onOpenLibrary: (Int) -> Unit, - onOpenSearch: () -> Unit, - onOpenFavorites: () -> Unit, - onSongClick: (Long) -> Unit, - onOpenQueue: () -> Unit -) { - AudioPermissionGate( - manager = permissionManager, - onPermissionGranted = viewModel::rescan - ) { - HomeContent(viewModel, currentSongId, onOpenLibrary, onOpenSearch, onOpenFavorites, onSongClick, onOpenQueue) - } -} - -@Composable -private fun HomeContent( viewModel: HomeViewModel, currentSongId: Long?, onOpenLibrary: (Int) -> Unit, diff --git a/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt b/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt index ead7aad..ae068c6 100644 --- a/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt +++ b/app/src/main/java/com/resonance/player/feature/library/LibraryScreen.kt @@ -47,9 +47,7 @@ import com.resonance.player.R import com.resonance.player.core.common.formatDurationMs import com.resonance.player.core.model.Song import com.resonance.player.domain.library.SongSort -import com.resonance.player.core.permissions.AudioPermissionManager import com.resonance.player.core.ui.components.ArtworkImage -import com.resonance.player.core.ui.components.AudioPermissionGate import com.resonance.player.core.ui.components.EmptyLibraryView import com.resonance.player.core.ui.components.ErrorView import com.resonance.player.core.ui.components.LoadingView @@ -68,35 +66,17 @@ import kotlinx.coroutines.launch * Stitch Library: top bar with global search, category chips with counts, * sort toolbar + shuffle-all, Stitch song rows with playing state/badges/ * overflow, alphabet scrubber with HUD. Missing-file rows are intentionally - * absent (the scanner auto-prunes vanished files; playback-time absence + * absent (a library refresh prunes vanished files; playback-time absence * surfaces via the player error state). */ @Composable fun LibraryScreen( viewModel: LibraryViewModel, - permissionManager: AudioPermissionManager, initialTab: Int = 0, currentSongId: Long? = null, onSongClick: (Long) -> Unit, onOpenQueue: () -> Unit, onOpenSearch: () -> Unit -) { - AudioPermissionGate( - manager = permissionManager, - onPermissionGranted = viewModel::rescan - ) { - LibraryTabs(viewModel, initialTab, currentSongId, onSongClick, onOpenQueue, onOpenSearch) - } -} - -@Composable -private fun LibraryTabs( - viewModel: LibraryViewModel, - initialTab: Int, - currentSongId: Long?, - onSongClick: (Long) -> Unit, - onOpenQueue: () -> Unit, - onOpenSearch: () -> Unit ) { var tab by remember(initialTab) { mutableIntStateOf(initialTab.coerceIn(0, 4)) } val scanState by viewModel.scanState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt index eee5ca5..8fc1572 100644 --- a/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/resonance/player/navigation/AppNavGraph.kt @@ -239,7 +239,6 @@ fun ResonanceAppShell(container: AppContainer) { ) HomeScreen( vm, - container.permissionManager, snapshot.song?.id, onOpenLibrary = { navigate(AppDestination.Library.routeFor(it)) }, onOpenSearch = { navigate(AppDestination.Search.route) }, @@ -282,7 +281,6 @@ fun ResonanceAppShell(container: AppContainer) { ) LibraryScreen( vm, - container.permissionManager, initialTab, snapshot.song?.id, onSongClick = { navigate(AppDestination.Player.routeFor(it)) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f974d43..42ecefb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,10 +47,6 @@ Reorder The queue is empty. Play something from your library. Playing from queue - Crate needs access to audio files on this device to build your local music library. Your files never leave this device. - Grant access - Audio access was denied. You can enable it anytime in the app settings. - Open settings Songs Albums Artists @@ -65,7 +61,6 @@ Scanning… songs albums - Audio access artists genres Music sources @@ -81,7 +76,6 @@ Private by design No account. No ads. No tracking. No network. Local music player - Granted Search songs, albums, artists… Search your library Find songs, albums, artists, playlists and genres on this device. diff --git a/app/src/test/java/com/resonance/player/core/FoundationTest.kt b/app/src/test/java/com/resonance/player/core/FoundationTest.kt index fa7e0c7..a2f90ae 100644 --- a/app/src/test/java/com/resonance/player/core/FoundationTest.kt +++ b/app/src/test/java/com/resonance/player/core/FoundationTest.kt @@ -2,7 +2,6 @@ package com.resonance.player.core import com.resonance.player.core.database.entity.SongEntity import com.resonance.player.core.database.toDomain -import com.resonance.player.core.permissions.MusicPermissions import com.resonance.player.core.ui.adaptive.WindowWidthSize import com.resonance.player.core.ui.adaptive.windowWidthSizeFor import androidx.compose.ui.unit.dp @@ -44,33 +43,6 @@ class SongMapperTest { } } -class MusicPermissionsTest { - - @Test - fun selectsPermissionBySdk() { - assertEquals( - MusicPermissions.READ_MEDIA_AUDIO, - MusicPermissions.audioPermissionForSdk(33) - ) - assertEquals( - MusicPermissions.READ_EXTERNAL_STORAGE, - MusicPermissions.audioPermissionForSdk(32) - ) - assertEquals( - MusicPermissions.READ_EXTERNAL_STORAGE, - MusicPermissions.audioPermissionForSdk(26) - ) - } - - @Test - fun branches_matchMinSdkContract() { - assertTrue(MusicPermissions.needsNotificationPermission(33)) - assertFalse(MusicPermissions.needsNotificationPermission(32)) - assertTrue(MusicPermissions.supportsMediaPlaybackServiceType(29)) - assertFalse(MusicPermissions.supportsMediaPlaybackServiceType(26)) - } -} - class AdaptiveTest { @Test diff --git a/app/src/test/java/com/resonance/player/core/ScanErrorsTest.kt b/app/src/test/java/com/resonance/player/core/ScanErrorsTest.kt new file mode 100644 index 0000000..55c40c3 --- /dev/null +++ b/app/src/test/java/com/resonance/player/core/ScanErrorsTest.kt @@ -0,0 +1,24 @@ +package com.resonance.player.core + +import com.resonance.player.core.common.AppError +import com.resonance.player.core.common.userMessage +import com.resonance.player.core.media.ScanReport +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScanErrorsTest { + + @Test + fun newErrors_haveMessages() { + assertTrue(AppError.ScanFailed("boom").userMessage().contains("boom")) + assertTrue(AppError.ScanFailed(null).userMessage().isNotBlank()) + } + + @Test + fun report_defaultsKeepOldCallSitesWorking() { + val report = ScanReport(added = 1, updated = 2, removed = 3) + assertEquals(0, report.failed) + assertEquals(0, report.total) + } +} diff --git a/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt b/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt deleted file mode 100644 index 49a1de3..0000000 --- a/app/src/test/java/com/resonance/player/core/ScanPermissionTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.resonance.player.core - -import com.resonance.player.core.common.AppError -import com.resonance.player.core.common.userMessage -import com.resonance.player.core.media.ScanReport -import com.resonance.player.core.permissions.AudioPermissionStatus -import com.resonance.player.core.permissions.permissionStatusFor -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PermissionStatusTest { - - @Test - fun matrix() { - assertEquals( - AudioPermissionStatus.Granted, - permissionStatusFor(granted = true, askedBefore = false, shouldShowRationale = false) - ) - assertEquals( - AudioPermissionStatus.NotAsked, - permissionStatusFor(granted = false, askedBefore = false, shouldShowRationale = false) - ) - assertEquals( - AudioPermissionStatus.Denied, - permissionStatusFor(granted = false, askedBefore = true, shouldShowRationale = true) - ) - assertEquals( - AudioPermissionStatus.PermanentlyDenied, - permissionStatusFor(granted = false, askedBefore = true, shouldShowRationale = false) - ) - } -} - -class ScanErrorsTest { - - @Test - fun newErrors_haveMessages() { - assertTrue(AppError.ScanFailed("boom").userMessage().contains("boom")) - assertTrue(AppError.ScanFailed(null).userMessage().isNotBlank()) - } - - @Test - fun report_defaultsKeepOldCallSitesWorking() { - val report = ScanReport(added = 1, updated = 2, removed = 3) - assertEquals(0, report.failed) - assertEquals(0, report.total) - } -} From 3188ce7ddb646827c551a56c63aa520e96cda649 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:43:05 +0300 Subject: [PATCH 6/7] Trust explicitly picked files: no name/type filter for FILE sources The dot-file filter (AppleDouble `._x.mp3`, `.trashed-*`) and the audio name/type check exist for folder listings. Applied to a file the user picked with the system picker they silently dropped it: found on a phone, where a picked `.trashed-...mp3` never showed up under "Added songs". Co-Authored-By: Claude Sonnet 5 --- .../player/data/media/SafAudioDataSource.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt index db7f186..6c29bdb 100644 --- a/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt +++ b/app/src/main/java/com/resonance/player/data/media/SafAudioDataSource.kt @@ -103,7 +103,12 @@ class SafAudioDataSource(private val resolver: ContentResolver) { return status } - /** A single individually added file. An empty result means the file is gone (COMPLETE, no candidate). */ + /** + * A single individually added file. An empty result means the file is gone + * (COMPLETE, no candidate). The user picked it explicitly, so it is trusted + * as audio: the name/type filter only guards folder listings (a picked + * `.trashed-*` file must not vanish silently). + */ suspend fun walkFile( sourceId: Long, documentUriString: String, @@ -114,10 +119,7 @@ class SafAudioDataSource(private val resolver: ContentResolver) { val cursor = query(uri) ?: return WalkStatus.UNAVAILABLE cursor.use { c -> if (c.moveToFirst()) { - val row = c.toDocRow() - if (isAudioDoc(row.name, row.mimeType)) { - onCandidate(audioCandidateOf(row, authority, sourceId, documentUriString, null)) - } + onCandidate(audioCandidateOf(c.toDocRow(), authority, sourceId, documentUriString, null)) } } return WalkStatus.COMPLETE From e9eade3d926dab76a7fe1c8d789d7b5b2fa5c876 Mon Sep 17 00:00:00 2001 From: Taras Pylypiv Date: Sat, 19 Sep 2026 23:45:02 +0300 Subject: [PATCH 7/7] Document music sources: ADR-010, pipeline, triggers, access Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 100 +++++++++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d8d1ce..e40958f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,13 +1,13 @@ -# Crate — Architecture (Phase 3: library ingestion pipeline) +# Crate — Architecture (Phase 3: library ingestion pipeline, music sources) Local-first, offline-first music player. No INTERNET permission by design -(verified in the merged manifest). +(verified in the merged manifest), and no storage/audio permission either. Single `:app` Gradle module with strict package layers (solo-dev friendly; split into Gradle modules only when build times or reuse demand it). ## Dependency direction -UI (Compose) -> ViewModel -> UseCase -> Repository -> Data Source (Room / MediaStore) +UI (Compose) -> ViewModel -> UseCase -> Repository -> Data Source (Room / Storage Access Framework) - UI never touches ExoPlayer, MediaController, Room entities, DAOs, ContentResolver or cursors. @@ -21,39 +21,51 @@ UI (Compose) -> ViewModel -> UseCase -> Repository -> Data Source (Room / MediaS screen, Bluetooth and UI all render MediaSession/UI state derived from it. - Player/session: `playback.PlaybackService` (MediaSessionService) is the ONLY owner of ExoPlayer + MediaSession. One instance per process. -- Library ingestion: `MediaStoreLibraryScanner` (app scope, single-flight, +- Library ingestion: `SafLibraryScanner` (app scope, single-flight, cancellable) owns discovery -> extract -> normalize -> artwork -> - reconcile -> persist. Composables never query MediaStore. + reconcile -> persist. Composables never touch ContentResolver. +- Music sources: `RoomSourceRepository` is the ONLY place that takes or + releases a persisted read grant. The system pickers live once in the app + shell (`LocalMusicActions`); screens only trigger them. - Queue: runtime-only `QueueBookkeeper`. Never touches saved data. -- Database: single `ResonanceDatabase` (Room v2). Entities map via - `SongMapper`. Playlists/favorites/history hold NO foreign keys to songs, - so deleting vanished files never cascades user data. +- Database: single `ResonanceDatabase` (Room v3). Entities map via + `SongMapper`. Songs belong to a source (FK, ON DELETE CASCADE: removing a + source drops its songs). Playlists/favorites/history hold NO foreign keys + to songs, so removing songs never cascades user data. - Settings + restore + library prefs: DataStore. -- Permissions: SDK branches in `MusicPermissions`; status in centralized - `AudioPermissionManager`; UI uses one shared gate/launcher. +- Access: no storage/audio permission. Music is what the user picked + (ADR-010); playback keeps its foreground-service/notification permissions. -## Ingestion pipeline (Phase 3) +## Ingestion pipeline -MediaStore (IS_MUSIC != 0, _ID ASC, API-gated projection) - -> MediaStoreAudioDataSource (streaming cursor, per-row isolation) - -> MediaItemCandidate (pure row mapping, content URIs per volume) +User-added sources (folder = TREE via OpenDocumentTree, song = FILE via OpenMultipleDocuments) + -> SafAudioDataSource (DocumentsContract, ONE child query per directory; + per-source COMPLETE / INCOMPLETE / UNAVAILABLE) + -> AudioCandidate (pure row mapping, stable song id) -> AndroidMetadataExtractor (MediaMetadataRetriever, best-effort nulls) - -> MetadataNormalizer (tags > columns > filename; pure, tested) + -> MetadataNormalizer (tags > filename; pure, tested) -> RetrieverArtworkExtractor + ArtworkStore (SHA-256 file cache, dedup) - -> Reconciler (pure NEW/MODIFIED/UNCHANGED/DELETED diff) + -> SourceReconciler (pure, source-scoped NEW/MODIFIED/UNCHANGED/DELETED diff) -> Room batches (100 upserts, 500-id delete chunks, stats preserved) -> Repository Flows -> ViewModels -> Compose (Coil lazy artwork) -- Identity: Room PK = MediaStore audio id (ADR-005); change detection by - (mediaStoreId, volumeName, dateModifiedSec, sizeBytes). A deleted + - re-added file is a new song (documented tradeoff, keeps FK-less history - stable). Cross-volume id reuse is handled by composite keys everywhere. -- Metadata priority: embedded tags > MediaStore columns > filename - fallback > Unknown-* display constants (Unicode preserved, no case - folding, no transliteration, source files never modified). +- Identity: `Song.id` = 63-bit hash of provider authority + documentId + (ADR-010); change detection by (dateModifiedSec, sizeBytes, sourceId). A + moved or renamed file is a new song (documented tradeoff; favorites and + playlist entries are FK-less and keyed by id). +- Deletion safety: a stored song is deleted only when its source was listed + COMPLETE and no longer contains it. A source that is unavailable (grant + lost, card unmounted) or only partly listed never deletes anything. +- Metadata priority: embedded tags > filename fallback > Unknown-* display + constants (Unicode preserved, no case folding, no transliteration, source + files never modified). Folder listings skip dot-files (AppleDouble, + `.trashed-*`); a file the user picked explicitly is trusted as audio. - Track/disc parse "1", "01", "1/12"; year accepts 1000..2999 from - YYYY[-MM-DD]; duration prefers MediaStore, then tags, else 0 (unknown, - never faked); missing genre stays null (excluded from genre groups). + YYYY[-MM-DD]; duration comes from tags, else 0 (unknown, never faked); + missing genre stays null (excluded from genre groups). "Ignore short + files" is applied after extraction (short files are re-read on every + refresh; a persisted marker is the upgrade if that ever hurts). +- First-seen date: `dateAdded` = min(now, file mtime), kept across re-imports. - Compilations: albums group by (title, album-artist); display prefers album artist, else single artist, else "Various Artists". - Album/artist identity is name-normalized (case-insensitive); "Greatest @@ -63,12 +75,17 @@ MediaStore (IS_MUSIC != 0, _ID ASC, API-gated projection) only. Missing files re-extract on next scan; orphans pruned post-scan. Coil loads lazily with fallback; MediaItem carries the file URI to the session (never blocks playback startup). -- Scan triggers: app start + Activity resume + permission grant + Settings - rescan. Cached Room data renders instantly; scans reconcile in - background. Permission loss stops scanning, keeps cache, never wipes. +- Scan triggers: right after a source is added or removed, and the + "Refresh library" actions (Settings row, Home card). Nothing scans on app + start or resume. Cached Room data renders instantly. Adding or removing a + source cancels a running scan first (the scanner is single-flight, so a + scan in progress would swallow the follow-up request). +- Lost access: a source without a live grant is skipped and listed as + "Access lost"; its songs stay. Re-adding the same folder (idempotent) + re-takes the grant and repairs it. - Concurrency: 4-permit semaphore for extraction, IO dispatcher everywhere, - cooperative cancellation, no GlobalScope, empty-queue/empty-library and - permission states in UI. + cooperative cancellation, no GlobalScope, empty-queue/empty-library + states in UI. ## Decisions (ADR) @@ -76,18 +93,21 @@ MediaStore (IS_MUSIC != 0, _ID ASC, API-gated projection) - ADR-002 minSdk 26: API 29/33 branches guarded in one place. - ADR-003 playback boundary: `core.playback.PlaybackController`. - ADR-004 entities never reach UI (`SongMapper` is the single bridge). -- ADR-005 song identity = MediaStore audio id at import; path/URI indexed, never sole identity. +- ADR-005 (superseded by ADR-010) song identity = MediaStore audio id at import; path/URI indexed, never sole identity. - ADR-006 manual `AppContainer` DI; app-lifetime scope for playback + scanner. - ADR-007 Smart Mix stays interfaces-only; song metadata (genre, counts, history) is scoring-ready, no fake BPM/key. - ADR-008 stop() = halt + keep queue + reset to head; errors never auto-advance; restore never autoplays. -- ADR-009 schema upgrades are explicit additive Migrations (v1->v2 keeps all user data); destructive fallback applies to downgrades only. +- ADR-009 schema upgrades are explicit Migrations (v1->v2 additive, keeps all user data; v2->v3 is a deliberate clean start for songs, see ADR-010); destructive fallback applies to downgrades only. +- ADR-010 the library is the music the user adds, not a device scan. Folders (OpenDocumentTree) and single songs (OpenMultipleDocuments) with persisted READ grants; scans run only after add/remove and on "Refresh library"; no READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE. `Song.id` stays a Long (nav arg, Media3 media id, DataStore queue, favorites/playlists untouched) but becomes a stable hash of authority + documentId, pinned by a golden-value test because it is a persisted format. Songs FK to `sources` with CASCADE; source inserts use IGNORE (REPLACE would delete the parent row and cascade every song). v2->v3 drops the old songs and empties playlist items, favorites and history (playlist names are kept); the DDL is copied from the Room-generated code. "Remove" in the UI always means remove from the library: files are never deleted. + Known limits: Android caps persisted grants (512, 128 before API 30; a picked file costs one) and grants do not survive backup/restore (`allowBackup=true`), so access is derived from `persistedUriPermissions` and re-adding repairs it; Android 11+ refuses the storage root and the Download folder in the folder picker (add a subfolder, or its files via Add songs); `.nomedia` / `IS_MUSIC` are no longer honoured; the same file reached through two providers (picker's Audio tab vs a folder) can appear twice. -## What is real in Phase 3 +## What is real -Real MediaStore discovery, tag extraction, normalization, artwork cache, -incremental reconciliation, Room v2, Library tabs (Songs/Albums/Artists/ -Genres/Folders), extended Search, tap-to-play + play-album into the Phase 2 -engine, Now Playing artwork, Settings rescan/stats/permission, 83 JVM tests. +Real SAF discovery of user-added folders and songs, tag extraction, +normalization, artwork cache, source-scoped incremental reconciliation, +Room v3, Library tabs (Songs/Albums/Artists/Genres/Folders), extended +Search, tap-to-play + play-album into the Phase 2 engine, Now Playing +artwork, Settings music sources / refresh / stats, 108 JVM tests. ## Host note (Windows, Cyrillic username) @@ -128,13 +148,15 @@ screens). Translated to native Compose — never copied HTML/CSS. status/system bars handled at shell level. `tabs` is lazy by design (eager outer static-init reading nested objects is a JLS 12.4.2 null hazard — caught by unit test). -- Deliberately NOT built (product conflicts): SAF folder management, tag +- Deliberately NOT built (product conflicts): tag edit/file delete, DSP/EQ engine/ReplayGain/crossfade/sleep timer/bit- perfect claims, account avatar, GPL badge, selectable radii/accents, missing-file Locate rows (auto-prune stays), Smart Mix UI. ## Next -Screen-by-screen Stitch reskin on these components, playlist management +Gestures (swipe down to close Now Playing, swipe up / left / right on the +mini player, long-press a song for its menu incl. "Remove from library"), +screen-by-screen Stitch reskin on these components, playlist management UI, Smart Mix engine feeding generated queues, release minification (strips unused icons), backup/export.