fix(recordings): open the recordings folder from the system file manager
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s

The recordings live in app-private storage (<data>/app_flutter/grabaciones),
which the Android sandbox forbids any other app from reading, so no
ACTION_VIEW on a file:// or FileProvider URI could ever open it. On top of
that, viewDirectory built an EMPTY candidate list for that path:
directoryDocumentUri returned null (path outside external storage) and
FileProvider.getUriForFile threw because pluriwave_file_paths.xml never
covered app_flutter. The loop never ran, so both entry points -- the radio
recorder and Settings -- always showed "could not open the folder".

Publish the folder as a browsable storage root via
RecordingsDocumentsProvider instead. The files never leave private storage;
the document framework asks us for them one document at a time, and the user
can browse, copy out, rename and delete straight from the file manager. The
root follows a user-configured path and falls back to the default recordings
directory. Its title reuses the already-translated recordingsFolderTitle, so
no new literal is introduced in any of the 13 locales.

Also fixes "open last recording", broken by the same missing FileProvider
root, and replaces Intent.createChooser with a bare startActivity in the
candidate loop: a chooser never throws when nothing can handle the intent, so
the first candidate always "succeeded" and the fallback chain never ran.

Device QA pending -- the provider is driven entirely by the platform's
document framework, so no unit test covers it. Each candidate logs its own
name under file_actions.viewDirectory for logcat triage.
This commit is contained in:
2026-07-25 15:07:10 +02:00
parent 321362b1bf
commit 1e33a79724
7 changed files with 445 additions and 41 deletions
+17
View File
@@ -104,6 +104,23 @@
android:resource="@xml/pluriwave_file_paths" />
</provider>
<!--
Publishes the app-private recordings folder as a browsable storage
root for the system file manager. MANAGE_DOCUMENTS restricts direct
access to the document framework (DocumentsUI); grantUriPermissions
lets it hand single-file access to whatever app the user picks.
-->
<provider
android:name=".RecordingsDocumentsProvider"
android:authorities="${applicationId}.recordings"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -28,6 +28,7 @@ object AlarmNotificationStrings {
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
const val KEY_RECORDINGS_ROOT_TITLE = "recordingsRootTitle"
const val KEY_MISSED_TITLE = "missedTitle"
const val KEY_MISSED_TEMPLATE = "missedTemplate"
@@ -55,6 +56,10 @@ object AlarmNotificationStrings {
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
/** Title of the storage root published by [RecordingsDocumentsProvider]. */
fun recordingsRootTitle(context: Context) =
get(context, KEY_RECORDINGS_ROOT_TITLE, "PluriWave recordings")
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
fun missedText(context: Context, name: String): String =
format(
@@ -41,6 +41,11 @@ class MainActivity : AudioServiceActivity() {
private val bluetoothConnectPermissionRequestCode = 4823
private val pickMusicFolderRequestCode = 4824
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
// MIME types DocumentsUI's file browser declares an ACTION_VIEW filter for.
// DocumentsContract only exposes the directory one as a constant.
private val directoryDocumentMimeType = DocumentsContract.Document.MIME_TYPE_DIR
private val rootDocumentMimeType = "vnd.android.document/root"
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
@@ -784,57 +789,89 @@ class MainActivity : AudioServiceActivity() {
return opened
}
/**
* Opens [path] in whatever app the system uses to browse folders.
*
* The default recordings folder lives in app-private storage, which NO file
* manager can reach through a `file://` or `FileProvider` URI -- the Android
* sandbox forbids other apps from reading `/data/user/0/<package>/`. That is
* why the old `resource/folder` candidate could never work.
* [RecordingsDocumentsProvider] publishes the folder as a document root
* instead, so the candidates below hand the system a URI it can actually
* resolve without a single byte leaving private storage. A user-configured
* folder on shared storage keeps using the platform's own external-storage
* provider, which the file manager already indexes.
*
* `startActivity` is used directly instead of `Intent.createChooser`,
* because a chooser never throws when nothing can handle the intent (it just
* shows an empty dialog). That swallowed the failure and stopped the
* fallback chain from ever running.
*/
private fun viewDirectory(path: String): Boolean {
val directory = File(path)
if (!directory.exists()) {
directory.mkdirs()
}
// Point the published root at the folder Flutter is really using, so a
// path changed in Settings is what the file manager shows.
RecordingsDocumentsProvider.rememberRoot(this, path)
val candidates = mutableListOf<Intent>()
val candidates = mutableListOf<Pair<String, Intent>>()
// Shared storage: the platform provider already exposes this folder, so
// prefer it when the user picked a public path.
directoryDocumentUri(path)?.let { uri ->
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "vnd.android.document/directory")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setData(uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
}
try {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
} catch (error: Throwable) {
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
}
for (intent in candidates) {
try {
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
)
Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
} catch (error: Throwable) {
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
candidates += "externalstorage-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, directoryDocumentMimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
// Our own root: handled by DocumentsUI's file browser on every device
// that ships it, and the only option that works for private storage.
candidates += "recordings-root" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootUri(this@MainActivity),
rootDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
candidates += "recordings-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootDocumentUri(this@MainActivity),
directoryDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Last resort: DocumentsUI always handles OPEN_DOCUMENT_TREE, and
// EXTRA_INITIAL_URI lands it straight on the recordings folder.
candidates += "recordings-tree" to Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
RecordingsDocumentsProvider.rootTreeUri(this@MainActivity)
)
}
}
for ((origin, intent) in candidates) {
if (openIntentSafely(intent, "file_actions.viewDirectory $origin", path, requireData = false)) {
return true
}
}
Log.w(tag, "file_actions.viewDirectory no candidate could be launched path=$path")
return false
}
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
if (intent == null || intent.data == null) return false
private fun openIntentSafely(
intent: Intent?,
origin: String,
path: String,
requireData: Boolean = true,
): Boolean {
if (intent == null) return false
if (requireData && intent.data == null) return false
return try {
startActivity(intent)
Log.d(tag, "$origin launched path=$path")
@@ -872,10 +909,10 @@ class MainActivity : AudioServiceActivity() {
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
openDirectory(file.parentFile?.absolutePath ?: path)
viewDirectory(file.parentFile?.absolutePath ?: path)
} catch (error: Throwable) {
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
openDirectory(file.parentFile?.absolutePath ?: path)
viewDirectory(file.parentFile?.absolutePath ?: path)
}
}
@@ -896,6 +933,10 @@ class MainActivity : AudioServiceActivity() {
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
// Android 11+ hides Android/data and Android/obb from the document
// framework, so a URI into them resolves to nothing useful.
if (relative.startsWith("Android/data") || relative.startsWith("Android/obb")) return null
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
@@ -0,0 +1,325 @@
package es.freetimelab.pluriwave
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import android.util.Log
import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileNotFoundException
/**
* Publishes the radio-recordings folder as a storage root the system file
* manager can browse, WITHOUT moving a single file out of app-private storage.
*
* Why this exists: the recordings live under
* `getApplicationDocumentsDirectory()/grabaciones`
* (`/data/user/0/es.freetimelab.pluriwave/app_flutter/grabaciones`). The Android
* sandbox forbids any other app -- including the system Files app -- from
* reading that path, so no `ACTION_VIEW` on a `file://` or `FileProvider` URI
* can ever open it. A `DocumentsProvider` is the only supported way to expose
* private files to the document framework: we stay the owner of the bytes and
* the system asks US for them, one document at a time.
*
* The root is browsable, readable, writable, renameable and deletable so the
* user can do whatever they want with their recordings (copy out, share, delete,
* open in another player) straight from the file manager.
*
* Static-review-only component: it runs in the app process but is driven
* entirely by the platform's document framework, so it has no Dart unit tests.
* See MainActivity.viewDirectory for the intents that open it.
*/
class RecordingsDocumentsProvider : DocumentsProvider() {
companion object {
private const val TAG = "PluriWave"
/** Root id and document id of the exposed folder itself. */
const val ROOT_ID = "recordings"
/**
* Remembers the folder Flutter is actually recording into. Written on
* every open-folder request so a user-configured path is honoured, and
* read back by [rootDirectory] when the platform enumerates roots (which
* can happen with no Activity alive).
*/
private const val PREFS = "pluriwave_recordings_root"
private const val KEY_PATH = "path"
/**
* Mirrors path_provider's `getApplicationDocumentsDirectory()` on
* Android (`context.getDir("flutter", MODE_PRIVATE)`) plus the
* `grabaciones` subfolder appended by
* `ServicioGrabacionRadio.directorioEfectivo()`. Used until Flutter has
* reported the effective path at least once.
*/
private fun defaultDirectory(context: Context): File =
File(context.getDir("flutter", Context.MODE_PRIVATE), "grabaciones")
fun authority(context: Context): String = "${context.packageName}.recordings"
/** `ACTION_VIEW` target that opens the file manager at this root. */
fun rootUri(context: Context): Uri =
DocumentsContract.buildRootUri(authority(context), ROOT_ID)
/** `ACTION_VIEW` target for the root folder as a document. */
fun rootDocumentUri(context: Context): Uri =
DocumentsContract.buildDocumentUri(authority(context), ROOT_ID)
/** `EXTRA_INITIAL_URI` target for the `ACTION_OPEN_DOCUMENT_TREE` fallback. */
fun rootTreeUri(context: Context): Uri =
DocumentsContract.buildTreeDocumentUri(authority(context), ROOT_ID)
/**
* Points the published root at [path] and tells the framework to
* refresh, so a folder change in Settings is reflected in the file
* manager. No-op when the path is unchanged.
*/
fun rememberRoot(context: Context, path: String) {
val app = context.applicationContext
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString(KEY_PATH, null) == path) return
prefs.edit().putString(KEY_PATH, path).apply()
try {
app.contentResolver.notifyChange(
DocumentsContract.buildRootsUri(authority(app)),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed", error)
}
}
/** The directory currently published as [ROOT_ID], created if missing. */
fun rootDirectory(context: Context): File {
val app = context.applicationContext
val stored = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_PATH, null)
?.takeIf { it.isNotBlank() }
val directory = if (stored != null) File(stored) else defaultDirectory(app)
if (!directory.exists()) directory.mkdirs()
return directory
}
private val ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
)
private val DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_FLAGS,
)
}
/**
* [DocumentsProvider.getContext] is nullable only before `onCreate`.
* Not named requireContext: ContentProvider.requireContext() is API 30 and
* minSdk is 24.
*/
private fun resolveContext(): Context =
requireNotNull(context) { "provider context unavailable" }
override fun onCreate(): Boolean = true
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: ROOT_COLUMNS)
val context = resolveContext()
// Ensure the folder exists before the file manager lists an empty root.
rootDirectory(context)
cursor.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
// The file manager renders title as the primary label and summary
// below it, so the brand identifies the source and the localized
// folder name says what it holds.
add(Root.COLUMN_TITLE, appLabel(context))
add(Root.COLUMN_SUMMARY, AlarmNotificationStrings.recordingsRootTitle(context))
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(
Root.COLUMN_FLAGS,
Root.FLAG_LOCAL_ONLY or
Root.FLAG_SUPPORTS_CREATE or
Root.FLAG_SUPPORTS_IS_CHILD
)
}
return cursor
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
addRow(cursor, resolve(documentId), documentId)
return cursor
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?,
): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
val parent = resolve(parentDocumentId)
// Newest recording first: it is the one the user just made.
val children = parent.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
for (child in children) {
addRow(cursor, child, documentIdFor(child))
}
return cursor
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
documentId != parentDocumentId &&
documentId.startsWith(
if (parentDocumentId == ROOT_ID) "$ROOT_ID/" else "$parentDocumentId/"
)
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = resolve(documentId)
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
}
override fun createDocument(
parentDocumentId: String,
mimeType: String,
displayName: String,
): String {
val parent = resolve(parentDocumentId)
val target = uniqueChild(parent, displayName)
val created =
if (Document.MIME_TYPE_DIR == mimeType) target.mkdir() else target.createNewFile()
if (!created) {
throw FileNotFoundException("could not create $displayName in $parentDocumentId")
}
notifyParent(parentDocumentId)
return documentIdFor(target)
}
override fun deleteDocument(documentId: String) {
val file = resolve(documentId)
if (!file.deleteRecursively()) {
throw FileNotFoundException("could not delete $documentId")
}
notifyParent(parentDocumentIdOf(documentId))
}
override fun renameDocument(documentId: String, displayName: String): String {
val file = resolve(documentId)
val target = File(file.parentFile, displayName)
if (target.exists() || !file.renameTo(target)) {
throw FileNotFoundException("could not rename $documentId to $displayName")
}
notifyParent(parentDocumentIdOf(documentId))
return documentIdFor(target)
}
override fun getDocumentType(documentId: String): String = mimeTypeOf(resolve(documentId))
private fun appLabel(context: Context): String =
context.applicationInfo.loadLabel(context.packageManager).toString()
private fun addRow(cursor: MatrixCursor, file: File, documentId: String) {
val isDirectory = file.isDirectory
var flags =
if (isDirectory) Document.FLAG_DIR_SUPPORTS_CREATE else Document.FLAG_SUPPORTS_WRITE
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME
cursor.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, documentId)
add(
Document.COLUMN_DISPLAY_NAME,
if (documentId == ROOT_ID) {
AlarmNotificationStrings.recordingsRootTitle(resolveContext())
} else {
file.name
}
)
add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file))
add(Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(Document.COLUMN_FLAGS, flags)
}
}
/**
* Maps a document id back to a file, refusing anything that escapes the
* published root -- a caller-supplied id must never reach a sibling of the
* recordings folder via `..` segments.
*/
private fun resolve(documentId: String): File {
val root = rootDirectory(resolveContext())
if (documentId == ROOT_ID) return root
if (!documentId.startsWith("$ROOT_ID/")) {
throw FileNotFoundException("unknown document id $documentId")
}
val relative = documentId.removePrefix("$ROOT_ID/")
val target = File(root, relative).canonicalFile
val rootPath = root.canonicalPath
if (target.path != rootPath && !target.path.startsWith("$rootPath${File.separator}")) {
throw FileNotFoundException("document id escapes the root: $documentId")
}
if (!target.exists()) throw FileNotFoundException("missing document $documentId")
return target
}
private fun documentIdFor(file: File): String {
val rootPath = rootDirectory(resolveContext()).canonicalPath
val filePath = file.canonicalPath
if (filePath == rootPath) return ROOT_ID
return "$ROOT_ID/${filePath.removePrefix("$rootPath${File.separator}").replace(File.separatorChar, '/')}"
}
private fun parentDocumentIdOf(documentId: String): String =
documentId.substringBeforeLast('/', ROOT_ID).takeIf { it.isNotBlank() } ?: ROOT_ID
private fun notifyParent(parentDocumentId: String) {
try {
val ctx = resolveContext()
ctx.contentResolver.notifyChange(
DocumentsContract.buildChildDocumentsUri(authority(ctx), parentDocumentId),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed parent=$parentDocumentId", error)
}
}
/** Appends ` (n)` before the extension until the name is free. */
private fun uniqueChild(parent: File, displayName: String): File {
var candidate = File(parent, displayName)
if (!candidate.exists()) return candidate
val dot = displayName.lastIndexOf('.')
val base = if (dot > 0) displayName.substring(0, dot) else displayName
val extension = if (dot > 0) displayName.substring(dot) else ""
var index = 1
while (candidate.exists()) {
candidate = File(parent, "$base ($index)$extension")
index++
}
return candidate
}
private fun mimeTypeOf(file: File): String {
if (file.isDirectory) return Document.MIME_TYPE_DIR
val extension = file.extension.lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
?: "application/octet-stream"
}
}
@@ -3,6 +3,17 @@
<files-path
name="files"
path="." />
<!--
path_provider's getApplicationDocumentsDirectory() maps to
context.getDir("flutter") -> <data>/app_flutter, a sibling of files/ that
no FileProvider tag covers directly. Without this root,
getUriForFile() throws for every radio recording and "open last
recording" fails. FileProvider canonicalizes roots, so the ../ hop
resolves to <data>/app_flutter.
-->
<files-path
name="app_flutter"
path="../app_flutter/" />
<cache-path
name="cache"
path="." />