From e35a3cbb847db0abd87ba1aa4c67abdb8e210d5c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:20:38 +0700 Subject: [PATCH] fix(library): scan remote SAF folders when root metadata is unavailable --- .../LookupTestDocumentsProvider.java | 17 ++++- .../com/zarz/spotiflac/SafScanChildrenTest.kt | 49 ++++++++++++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 15 ++++- .../com/zarz/spotiflac/MainActivitySafScan.kt | 67 +++++++++++++++---- 4 files changed, 133 insertions(+), 15 deletions(-) diff --git a/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java b/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java index cff90dd7..ce1391e0 100644 --- a/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java +++ b/android/app/src/androidTest/java/com/zarz/spotiflac/LookupTestDocumentsProvider.java @@ -65,7 +65,7 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { sequence = 0; projectionMode = extras.getString("mode", "normal"); failFinalRename = extras.getBoolean("failFinalRename"); - add("root", null, "Root", Document.MIME_TYPE_DIR); + add("root", null, "Root", projectionMode.equals("root-empty-mime") ? "" : Document.MIME_TYPE_DIR); for (int i = 0; i < extras.getInt("count"); i++) { add("child:" + i, "root", "Track " + i + ".flac", "audio/flac"); } @@ -173,6 +173,10 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { Entry entry = entries.get(documentId); if (entry == null) throw new FileNotFoundException(documentId); MatrixCursor cursor = cursor(projection != null ? projection : defaultColumns()); + if (documentId.equals("root") && + (projectionMode.equals("root-metadata-empty") || projectionMode.equals("root-unconfirmed"))) { + return cursor; + } addEntry(cursor, entry); return cursor; } @@ -187,6 +191,17 @@ public final class LookupTestDocumentsProvider extends DocumentsProvider { String[] columns = projected && projectionMode.equals("missing") ? new String[] {Document.COLUMN_DOCUMENT_ID} : projection != null ? projection : defaultColumns(); MatrixCursor cursor = cursor(columns); + if (projectionMode.equals("always-loading") || + (projectionMode.equals("loading") && childQueries <= 2)) { + Bundle extras = new Bundle(); + extras.putBoolean(DocumentsContract.EXTRA_LOADING, true); + cursor.setExtras(extras); + return cursor; + } + if (parentId.equals("root") && + (projectionMode.equals("root-unconfirmed") || projectionMode.equals("empty"))) { + return cursor; + } for (Entry entry : entries.values()) if (parentId.equals(entry.parent)) addEntry(cursor, entry); return cursor; } diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt index e5d399c6..18805360 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafScanChildrenTest.kt @@ -47,6 +47,55 @@ class SafScanChildrenTest { } } + @Test + fun scanAcceptsReadableRemoteRootWithoutMimeMetadata() { + val root = reset(count = 3, mode = "root-empty-mime") + assertTrue(root.exists()) + assertFalse(root.canRead()) // The old scan preflight rejected this. + val (_, resolved, children) = context.resolveReadableSafTreeOrThrow(treeUri.toString()) + assertEquals(root.uri, resolved.uri) + assertEquals(5, children.size) + assertEquals(1, stats().getInt("children")) + } + + @Test + fun scanAcceptsReadableRootAfterMetadataIsInvalidatedByReconnect() { + val root = reset(count = 3, mode = "root-metadata-empty") + assertFalse(root.exists()) + val (_, resolved, children) = context.resolveReadableSafTreeOrThrow(treeUri.toString()) + assertEquals(root.uri, resolved.uri) + assertEquals(5, children.size) + assertEquals(1, stats().getInt("children")) + } + + @Test + fun loadingListingIsRetriedAndNeverReturnedAsAnEmptyScan() { + reset(count = 3, mode = "loading") + val (_, _, children) = context.resolveReadableSafTreeOrThrow(treeUri.toString()) + assertEquals(5, children.size) + assertEquals(3, stats().getInt("children")) + assertEquals(3, stats().getInt("closed")) + } + + @Test + fun loadingTimeoutAndUnconfirmedEmptyRootFailInsteadOfRemovingTracks() { + for (mode in listOf("always-loading", "root-unconfirmed")) { + reset(mode = mode) + assertThrows(IOException::class.java) { + context.resolveReadableSafTreeOrThrow(treeUri.toString()) + } + assertEquals(3, stats().getInt("children")) + } + } + + @Test + fun confirmedEmptyFolderStillScansSuccessfully() { + reset(mode = "empty") + val (_, _, children) = context.resolveReadableSafTreeOrThrow(treeUri.toString()) + assertTrue(children.isEmpty()) + assertEquals(1, stats().getInt("children")) + } + @Test fun richRowsContainFileMetadataAndTraversableChildrenWithoutNameQueries() { val root = reset(count = 2000) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 30a44daa..1292643a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1240,7 +1240,7 @@ class MainActivity: FlutterFragmentActivity() { uri, DocumentsContract.getTreeDocumentId(uri), ) - contentResolver.query( + val rootPresent = contentResolver.query( root, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, @@ -1251,6 +1251,19 @@ class MainActivity: FlutterFragmentActivity() { else -> false } } + if (rootPresent == true) { + true + } else { + // A reconnecting remote provider may not have + // queryDocument metadata yet. A completed child + // listing is stronger evidence of read access. + val directory = DocumentFile.fromTreeUri(this@MainActivity, uri) + if (directory != null && listSafChildrenOrThrow(directory).isNotEmpty()) { + true + } else { + rootPresent + } + } }, onFailure = { error -> android.util.Log.w("SpotiFLAC", "SAF library read probe failed", error) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index dc530264..a1f6f34a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -734,6 +734,24 @@ internal data class SafChildEntry( internal fun Context.listSafChildrenOrThrow( dir: DocumentFile, includeLastModified: Boolean = true, +): List { + for (attempt in 0..2) { + try { + return querySafChildrenOrThrow(dir, includeLastModified) + } catch (error: SafProviderLoadingException) { + if (attempt == 2) throw error + Thread.sleep(300L * (attempt + 1)) + } + } + throw IOException("SAF directory listing did not complete") +} + +private class SafProviderLoadingException(uri: Uri) : + IOException("SAF provider is still loading $uri; retry the scan when it is ready") + +private fun Context.querySafChildrenOrThrow( + dir: DocumentFile, + includeLastModified: Boolean, ): List { val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( dir.uri, @@ -758,6 +776,11 @@ internal fun Context.listSafChildrenOrThrow( ) } ?: throw IOException("SAF provider returned no cursor for ${dir.uri}") return cursor.use { + // Network DocumentsProviders may return cached/partial rows while + // fetching a directory. Never commit that as an empty/full scan. + if (it.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false)) { + throw SafProviderLoadingException(dir.uri) + } val documentIdIndex = it.getColumnIndexOrThrow( DocumentsContract.Document.COLUMN_DOCUMENT_ID, ) @@ -776,7 +799,7 @@ internal fun Context.listSafChildrenOrThrow( dir.uri, it.getString(documentIdIndex), ) - val child = DocumentFile.fromTreeUri(this@listSafChildrenOrThrow, childUri) + val child = DocumentFile.fromTreeUri(this@querySafChildrenOrThrow, childUri) ?: throw IOException("Invalid SAF child URI: $childUri") val name = if (displayNameIndex >= 0 && !it.isNull(displayNameIndex)) { it.getString(displayNameIndex) @@ -813,17 +836,19 @@ internal fun Context.listSafChildrenOrThrow( } } -internal fun MainActivity.resolveReadableSafTreeOrThrow( +internal fun Context.resolveReadableSafTreeOrThrow( treeUriStr: String, - ): Pair { + ): Triple> { if (treeUriStr.isBlank()) { throw IllegalArgumentException("SAF tree URI is empty") } val treeUri = Uri.parse(treeUriStr) + val root = DocumentFile.fromTreeUri(this, treeUri) + ?: throw IOException("Unable to resolve SAF tree") val hasReadPermission = contentResolver.persistedUriPermissions.any { it.uri == treeUri && it.isReadPermission } || checkUriPermission( - treeUri, + root.uri, android.os.Process.myPid(), android.os.Process.myUid(), Intent.FLAG_GRANT_READ_URI_PERMISSION, @@ -831,12 +856,20 @@ internal fun MainActivity.resolveReadableSafTreeOrThrow( if (!hasReadPermission) { throw SecurityException("Read access to the SAF tree has been revoked") } - val root = DocumentFile.fromTreeUri(this, treeUri) - ?: throw IOException("Unable to resolve SAF tree") - if (!root.exists() || !root.canRead()) { - throw IOException("SAF tree is unavailable or unreadable") + // canRead() also requires a non-empty MIME type from queryDocument. + // Remote providers can omit that metadata after reconnecting even + // though queryChildDocuments can read the folder. The real listing is + // authoritative; reuse it in the scan instead of another round trip. + for (attempt in 0..2) { + val children = listSafChildrenOrThrow(root) + if (children.isNotEmpty() || root.exists()) { + return Triple(treeUri, root, children) + } + // An empty listing without a confirmed root is inconclusive, + // never evidence that previously indexed songs were deleted. + if (attempt < 2) Thread.sleep(300L * (attempt + 1)) } - return treeUri to root + throw IOException("SAF provider could not confirm the library folder; reconnect it and retry") } internal fun MainActivity.scanSafTree( @@ -867,7 +900,7 @@ internal fun MainActivity.scanSafTree( throw java.util.concurrent.CancellationException("SAF library scan cancelled") } - val (_, root) = resolveReadableSafTreeOrThrow(treeUriStr) + val (_, root, rootChildren) = resolveReadableSafTreeOrThrow(treeUriStr) resetSafScanProgress() safScanCancel = false @@ -914,7 +947,11 @@ internal fun MainActivity.scanSafTree( continue } - val listing = lister.list(dir, queue, visitedDirUris) + val listing = if (dir.uri == root.uri) { + Result.success(rootChildren) + } else { + lister.list(dir, queue, visitedDirUris) + } val children = listing.getOrNull() if (children == null) { traversalErrors++ @@ -1297,7 +1334,7 @@ internal fun MainActivity.scanSafTreeIncremental( treeUriStr: String, existingFiles: Map, ): Any { - val (_, root) = resolveReadableSafTreeOrThrow(treeUriStr) + val (_, root, rootChildren) = resolveReadableSafTreeOrThrow(treeUriStr) resetSafScanProgress() safScanCancel = false @@ -1355,7 +1392,11 @@ internal fun MainActivity.scanSafTreeIncremental( continue } - val listing = lister.list(dir, queue, visitedDirUris) + val listing = if (dir.uri == root.uri) { + Result.success(rootChildren) + } else { + lister.list(dir, queue, visitedDirUris) + } val children = listing.getOrNull() if (children == null) { traversalErrors++