package link.roadio.riff.shared.config import android.content.ContentResolver import android.net.Uri import android.provider.DocumentsContract import link.roadio.riff.config.ProjectConfiguration import java.io.IOException /** Read-only SAF lookup beside the externally visible volume marker. No guessed document IDs. */ internal class ExternalConfigurationDiscovery( private val resolver: ContentResolver, ) { fun readAll(trees: List = resolver.persistedUriPermissions.filter { it.isReadPermission }.map { it.uri }): List = buildList { for (tree in trees) { if (!DocumentsContract.isTreeUri(tree)) continue // An unavailable source is absent from this startup. Once discovered, a bad file rejects // the complete overlay, rather than silently applying only the remaining inputs. // https://wiki.271.pics/Riff/1-1-0/configuration#location-and-admission val config = try { val root = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) val riff = child(tree, root, ".riff", directory = true) ?: continue child(tree, riff, ProjectConfiguration.OVERRIDE_FILE_NAME, directory = false) ?: continue } catch (_: DirectoryUnavailable) { continue } val bytes = resolver.openInputStream(config)?.use { input -> val out = java.io.ByteArrayOutputStream() val block = ByteArray(8192) while (true) { val count = input.read(block) if (count < 0) break if (out.size() > MAX_BYTES - count) throw IOException("Configuration input exceeds bound") out.write(block, 0, count) } out.toByteArray() } ?: throw IOException("Configuration document could not be opened") add( Charsets.UTF_8 .newDecoder() .decode(java.nio.ByteBuffer.wrap(bytes)) .toString(), ) } } private fun child( tree: Uri, parent: Uri, name: String, directory: Boolean, ): Uri? { val uri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, DocumentsContract.getDocumentId(parent)) val listing = try { resolver.query( uri, arrayOf( DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE, ), null, null, null, ) } catch (error: RuntimeException) { throw DirectoryUnavailable(error) } return listing.use { cursor -> if (cursor == null) throw DirectoryUnavailable() if (cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false) || cursor.extras.containsKey(DocumentsContract.EXTRA_ERROR) ) { throw DirectoryUnavailable() } var found: Uri? = null while (cursor.moveToNext()) { if (cursor.getString(1) == name && (cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR) == directory) { if (found != null) throw IOException("Ambiguous configuration document") found = DocumentsContract.buildDocumentUriUsingTree(tree, cursor.getString(0)) } } found } } private class DirectoryUnavailable( cause: Throwable? = null, ) : IOException("Configuration directory is unavailable", cause) private companion object { const val MAX_BYTES = 4 * 1024 * 1024 } }