fix(library): scan remote SAF folders when root metadata is unavailable

This commit is contained in:
zarzet committed 2026-10-03 13:40:39 +07:00
1 parent fb41a86f5a
commit e35a3cbb84
4 files changed
+133 -15

No files matched your search

@@ -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;
}
@@ -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)
@@ -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)
@@ -734,6 +734,24 @@ internal data class SafChildEntry(
internal fun Context.listSafChildrenOrThrow(
dir: DocumentFile,
includeLastModified: Boolean = true,
): List<SafChildEntry> {
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<SafChildEntry> {
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<Uri, DocumentFile> {
): Triple<Uri, DocumentFile, List<SafChildEntry>> {
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<String, Long>,
): 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++