mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(library): scan remote SAF folders when root metadata is unavailable
This commit is contained in:
1 parent
fb41a86f5a
commit
e35a3cbb84
4 files changed
+133
-15
No files matched your search
+16
-1
@@ -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++
|
||||
|
||||
Reference in new issue
Block a user