From 1e33a79724d1e17b85ca9df21ddb896acda851de Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 25 Jul 2026 14:57:49 +0200 Subject: [PATCH] fix(recordings): open the recordings folder from the system file manager The recordings live in app-private storage (/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. --- android/app/src/main/AndroidManifest.xml | 17 + .../pluriwave/AlarmNotificationStrings.kt | 5 + .../es/freetimelab/pluriwave/MainActivity.kt | 123 ++++--- .../pluriwave/RecordingsDocumentsProvider.kt | 325 ++++++++++++++++++ .../src/main/res/xml/pluriwave_file_paths.xml | 11 + lib/servicios/servicio_alarmas_android.dart | 4 + ...icio_alarmas_pre_notice_template_test.dart | 1 + 7 files changed, 445 insertions(+), 41 deletions(-) create mode 100644 android/app/src/main/kotlin/es/freetimelab/pluriwave/RecordingsDocumentsProvider.kt 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) {