diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0579d5d..39f7c12 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -104,6 +104,23 @@
android:resource="@xml/pluriwave_file_paths" />
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt
index 622240b..64aa9a9 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt
@@ -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(
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index 9c294ae..dffe554 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -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//`. 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()
+ val candidates = mutableListOf>()
+
+ // 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",
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/RecordingsDocumentsProvider.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/RecordingsDocumentsProvider.kt
new file mode 100644
index 0000000..5071650
--- /dev/null
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/RecordingsDocumentsProvider.kt
@@ -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?): 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?): Cursor {
+ val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
+ addRow(cursor, resolve(documentId), documentId)
+ return cursor
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String,
+ projection: Array?,
+ 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"
+ }
+}
diff --git a/android/app/src/main/res/xml/pluriwave_file_paths.xml b/android/app/src/main/res/xml/pluriwave_file_paths.xml
index 8c6c1ea..bd274ff 100644
--- a/android/app/src/main/res/xml/pluriwave_file_paths.xml
+++ b/android/app/src/main/res/xml/pluriwave_file_paths.xml
@@ -3,6 +3,17 @@
+
+
diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart
index 4ccd7a3..94245ad 100644
--- a/lib/servicios/servicio_alarmas_android.dart
+++ b/lib/servicios/servicio_alarmas_android.dart
@@ -242,6 +242,10 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
+ // Title of the storage root that RecordingsDocumentsProvider publishes
+ // to the system file manager, so the recordings folder is browsable
+ // without leaving app-private storage.
+ 'recordingsRootTitle': l10n.recordingsFolderTitle,
'missedTitle': l10n.alarmMissedNotificationTitle,
'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText),
});
diff --git a/test/servicios/servicio_alarmas_pre_notice_template_test.dart b/test/servicios/servicio_alarmas_pre_notice_template_test.dart
index 9234f55..bc5300c 100644
--- a/test/servicios/servicio_alarmas_pre_notice_template_test.dart
+++ b/test/servicios/servicio_alarmas_pre_notice_template_test.dart
@@ -73,6 +73,7 @@ void main() {
'preNoticeChannelDescription',
'openFolderTitle',
'openRecordingTitle',
+ 'recordingsRootTitle',
'missedTitle',
];
for (final clave in claves) {