Compare commits
33
Commits
080d342de0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2891a5703e | ||
|
|
acf2ebb55f | ||
|
|
69eea0f2a1 | ||
|
|
0b7919e72e | ||
|
|
d7366bbf99 | ||
|
|
b1bf289e0d | ||
|
|
8fc3d99fbd | ||
|
|
a0fae57219 | ||
|
|
405dc18430 | ||
|
|
bcdf3d55c4 | ||
|
|
02609ec82c | ||
|
|
5f35ab7d6a | ||
|
|
241f81e535 | ||
|
|
a82dcc9c1b | ||
|
|
3449e2cb79 | ||
|
|
10bb017f4c | ||
|
|
a99df5d055 | ||
|
|
ab66f4985c | ||
|
|
d61c62540a | ||
|
|
25d5841d57 | ||
|
|
663fed5f41 | ||
|
|
4ca2813267 | ||
|
|
a2bed18937 | ||
|
|
e57f7bb17b | ||
|
|
fdddd95199 | ||
|
|
1bfd5a2348 | ||
|
|
4ea5d2056c | ||
|
|
b5940b2758 | ||
|
|
9efa6d8937 | ||
|
|
55fe50d07d | ||
|
|
2e15d05431 | ||
|
|
e9f47d47c2 | ||
|
|
186ff45105 |
+94
-17
@@ -68,7 +68,18 @@ jobs:
|
||||
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
||||
echo "✅ Keystore configurado"
|
||||
|
||||
- name: Bump versión patch + commit
|
||||
# PRO owns the version NAME; every branch advances the build NUMBER.
|
||||
#
|
||||
# Previously main also bumped its patch on every push, so main's semver
|
||||
# raced permanently ahead of PRO's (main hit 1.3.3 while the branch that
|
||||
# actually ships sat at 1.3.0). That buried the release artifacts under a
|
||||
# dev branch on builds.freetimelab.es, which sorts by version, and made
|
||||
# every main<->PRO merge conflict on pubspec.yaml.
|
||||
#
|
||||
# The build number still advances everywhere: Google Play requires it to
|
||||
# be monotonic across the whole app, so two branches must never mint the
|
||||
# same code.
|
||||
- name: Bump versión + commit
|
||||
run: |
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
git config user.name "ShanaiaBot"
|
||||
@@ -77,12 +88,20 @@ jobs:
|
||||
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
|
||||
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
|
||||
NEW_BUILD=$((BUILD + 1))
|
||||
# If the triggering commit explicitly pins the version name via the
|
||||
# [version set] marker, ship that semver as-is (a milestone like 1.0.0
|
||||
# or a major/minor jump the automatic patch bump cannot reach) and only
|
||||
# advance the build number, which Google Play requires to stay
|
||||
# monotonic. Otherwise keep the default automatic patch+build bump.
|
||||
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
|
||||
|
||||
# Look for [version set] across EVERY commit this push introduced,
|
||||
# not just the tip. `git pull` inserts an auto-generated merge commit
|
||||
# whose message carries no marker, which silently discarded a pinned
|
||||
# version name and bumped 1.3.0 to 1.3.1 behind our backs.
|
||||
RANGO="${{ gitea.event.before }}..${{ gitea.sha }}"
|
||||
if git log "$RANGO" --pretty=%B 2>/dev/null | grep -q '\[version set\]'; then
|
||||
MARCADOR="si"
|
||||
else
|
||||
MARCADOR="no"
|
||||
fi
|
||||
|
||||
if [ "$BRANCH" != "PRO" ] || [ "$MARCADOR" = "si" ]; then
|
||||
# Non-release branches never touch the name; PRO respects a pin.
|
||||
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
|
||||
else
|
||||
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
|
||||
@@ -91,6 +110,8 @@ jobs:
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
|
||||
fi
|
||||
|
||||
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
|
||||
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
|
||||
git add pubspec.yaml
|
||||
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
|
||||
@@ -237,12 +258,47 @@ jobs:
|
||||
- name: Build AAB release
|
||||
run: flutter build appbundle --release
|
||||
|
||||
# El nombre lleva RAMA y CÓDIGO DE VERSIÓN, no solo el semver.
|
||||
#
|
||||
# Antes, cada build de 1.3.0 se llamaba `pluriwave-v1.3.0.aab` y caía en
|
||||
# la misma carpeta, así que main y PRO se pisaban y tres builds distintos
|
||||
# eran indistinguibles una vez descargados: el navegador los guarda como
|
||||
# "(1)", "(2)"... y ya no se sabe cuál es cuál. Eso costó subir a Play
|
||||
# Console un código de versión ya usado, dos veces.
|
||||
#
|
||||
# Con `pluriwave-PRO-v1.3.0+156.aab` el archivo se identifica solo,
|
||||
# incluso semanas después y fuera de este repo.
|
||||
- name: Publicar en ftl-builds (Zimaboard)
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
APK_NOMBRE="pluriwave-v${VERSION}.apk"
|
||||
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
|
||||
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
|
||||
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
|
||||
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
|
||||
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
|
||||
# La rama va en el NOMBRE DE LA APP, no en una subcarpeta.
|
||||
#
|
||||
# El objetivo sigue siendo el de siempre: que main y PRO no se mezclen
|
||||
# en el portal, que ordena por número de versión y mostraba el build
|
||||
# de desarrollo como "última versión" por delante del de release.
|
||||
#
|
||||
# Pero la primera solución metía la rama como TERCER nivel
|
||||
# (pluriwave/main/v1.3.3/) y el portal indexa solo DOS —
|
||||
# <app>/<versión>/<ficheros> —, así que desde el 29-08 ningún build de
|
||||
# main volvió a aparecer en builds.freetimelab.es aunque el job saliera
|
||||
# verde: el scp subía bien, a una ruta que el indexador no lee. Nada
|
||||
# avisaba, y el echo de abajo se comía la rama y mandaba a la carpeta
|
||||
# antigua, que llevaba congelada desde el +157.
|
||||
#
|
||||
# Con la rama en el nombre, PRO conserva la entrada limpia "pluriwave"
|
||||
# y main tiene la suya, igual que ya conviven radar-foral y
|
||||
# radar-foral-android.
|
||||
if [ "$BRANCH" = "PRO" ]; then
|
||||
APP="pluriwave"
|
||||
else
|
||||
APP="pluriwave-$(echo "$BRANCH" | tr '/' '-')"
|
||||
fi
|
||||
DESTINO="/opt/ftl-builds/builds/${APP}/v${VERSION}"
|
||||
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
|
||||
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
|
||||
@@ -252,28 +308,46 @@ jobs:
|
||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
||||
build/app/outputs/bundle/release/app-release.aab \
|
||||
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
|
||||
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
|
||||
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
|
||||
# La ruta se imprime desde ${APP}, no a mano: la version anterior tenia
|
||||
# "pluriwave" escrito a fuego y mandaba a la carpeta equivocada cada
|
||||
# vez que se compilaba algo que no fuera PRO.
|
||||
echo "✅ APK: builds.freetimelab.es → ${APP} → v${VERSION} → ${APK_NOMBRE}"
|
||||
echo "✅ AAB: builds.freetimelab.es → ${APP} → v${VERSION} → ${AAB_NOMBRE}"
|
||||
|
||||
# La publicacion automatica en Google Play es OPCIONAL.
|
||||
#
|
||||
# Este paso hacia `exit 1` cuando faltaba el secreto, asi que TODA
|
||||
# compilacion de PRO terminaba en rojo por una funcion que nunca llego a
|
||||
# activarse: el secreto no se configuro nunca y las subidas a Play se han
|
||||
# hecho siempre a mano. Un rojo permanente entrena a ignorar los rojos, y
|
||||
# entonces el dia que falle algo de verdad tampoco se mira.
|
||||
#
|
||||
# Ahora se omite con un aviso. El AAB ya esta compilado, firmado y subido
|
||||
# a ftl-builds por el paso anterior, asi que no se pierde nada. El dia que
|
||||
# se configure el secreto, los tres pasos se activan solos.
|
||||
- name: Preparar credenciales de Google Play
|
||||
id: credenciales_play
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
|
||||
exit 1
|
||||
echo "disponible=no" >> "$GITHUB_OUTPUT"
|
||||
echo "AVISO: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON."
|
||||
echo "Se omite la publicacion en Google Play; sube el AAB a mano."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p fastlane/credentials
|
||||
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
|
||||
echo "disponible=si" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Instalar Fastlane
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
|
||||
run: |
|
||||
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
|
||||
|
||||
- name: Publicar AAB en Google Play Internal Testing
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
|
||||
env:
|
||||
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
|
||||
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
|
||||
@@ -291,8 +365,11 @@ jobs:
|
||||
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
||||
if [ "$BRANCH" = "PRO" ]; then
|
||||
# Solo se anuncia la subida a Play cuando de verdad ocurrio.
|
||||
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
|
||||
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
|
||||
elif [ "$BRANCH" = "PRO" ]; then
|
||||
MSG="${MSG}%0AEn builds.freetimelab.es · sube el AAB a Play a mano"
|
||||
else
|
||||
MSG="${MSG}%0APublicado en builds.freetimelab.es"
|
||||
fi
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<!-- Approximate location ONLY. The single consumer
|
||||
(EstadoBusqueda.cargarEmisorasCercanas) asks for LocationAccuracy.low
|
||||
and throws the fix away except for Placemark.isoCountryCode, so a
|
||||
country-level fix is all this app can use. Declaring
|
||||
ACCESS_FINE_LOCATION would also contradict the approved Data Safety
|
||||
declaration. geolocator builds its runtime request from whichever of
|
||||
the two permissions the merged manifest declares, so with COARSE
|
||||
alone the system dialog offers approximate precision only. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<!--
|
||||
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.content.pm.PackageManager
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.media.audiofx.Visualizer
|
||||
import android.app.AlarmManager
|
||||
@@ -26,6 +25,7 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import es.freetimelab.pluriwave.fileactions.FileActionsHandler
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -269,10 +269,31 @@ class MainActivity : AudioServiceActivity() {
|
||||
}
|
||||
activeInstance = this
|
||||
|
||||
// fix/android-auto-musica-local: los cuatro metodos SAF que solo
|
||||
// necesitan un ContentResolver viven en FileActionsHandler, dentro del
|
||||
// paquete plugin `packages/pluriwave_file_actions`. Alli
|
||||
// PluriWaveFileActionsPlugin los registra en TODOS los engines via
|
||||
// GeneratedPluginRegistrant -- incluido el headless que audio_service
|
||||
// crea para Android Auto, donde este configureFlutterEngine nunca
|
||||
// corre.
|
||||
//
|
||||
// Este engine SI tiene Activity, asi que instala UN solo handler para
|
||||
// todo el canal, superconjunto del del plugin: primero delega en el
|
||||
// handler compartido (misma y unica implementacion) y, si este no
|
||||
// reconoce el metodo, atiende sus propios metodos ligados a la
|
||||
// Activity (picker SAF e intents de la carpeta de grabaciones).
|
||||
//
|
||||
// El orden esta garantizado: GeneratedPluginRegistrant corre DENTRO
|
||||
// del constructor de FlutterEngine, y configureFlutterEngine solo
|
||||
// puede ejecutarse despues, con el engine ya construido. Este handler
|
||||
// siempre pisa al del plugin en una Activity, nunca al reves.
|
||||
val fileActionsHandler = FileActionsHandler(applicationContext)
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
fileActionsChannel
|
||||
).setMethodCallHandler { call, result ->
|
||||
if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler
|
||||
when (call.method) {
|
||||
"openDirectory" -> {
|
||||
val path = call.argument<String>("path")
|
||||
@@ -327,53 +348,11 @@ class MainActivity : AudioServiceActivity() {
|
||||
pendingMusicFolderResult = null
|
||||
}
|
||||
}
|
||||
"listAudioChildren" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
|
||||
)
|
||||
if (treeUri.isNullOrBlank()) {
|
||||
result.success(emptyList<Map<String, Any>>())
|
||||
} else {
|
||||
result.success(listAudioChildren(treeUri, parentDocumentId))
|
||||
}
|
||||
}
|
||||
"resolvePlayableUri" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val documentId = call.argument<String>("documentId")
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
|
||||
)
|
||||
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.success(resolvePlayableUri(treeUri, documentId))
|
||||
}
|
||||
}
|
||||
"hasPersistedPermission" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
|
||||
result.success(
|
||||
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
|
||||
)
|
||||
}
|
||||
// ---- android-auto-local-music-phase2 (static review only) ----
|
||||
"readAudioMetadataBatch" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
|
||||
)
|
||||
if (treeUri.isNullOrBlank()) {
|
||||
result.success(emptyList<Map<String, Any?>>())
|
||||
} else {
|
||||
result.success(readAudioMetadataBatch(treeUri, documentIds))
|
||||
}
|
||||
}
|
||||
// listAudioChildren / resolvePlayableUri /
|
||||
// hasPersistedPermission / readAudioMetadataBatch los
|
||||
// atiende FileActionsHandler arriba (item 3): no necesitan
|
||||
// Activity, asi que tienen que poder registrarse tambien en
|
||||
// un engine que no la tiene.
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -423,241 +402,6 @@ class MainActivity : AudioServiceActivity() {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
|
||||
* static review only — Design "Lazy per-folder enumeration, never an
|
||||
* eager tree dump"): [parentDocumentId] blank means the tree root
|
||||
* itself, otherwise the given subfolder's documentId. Filters files to
|
||||
* audio MIME types at the native layer (lean payload); each returned row
|
||||
* also carries `mime` so the Dart side can re-validate via
|
||||
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
|
||||
* empty list rather than throwing.
|
||||
*/
|
||||
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
val parentId = parentDocumentId.ifBlank {
|
||||
DocumentsContract.getTreeDocumentId(parsedTree)
|
||||
}
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
|
||||
val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
val resultado = mutableListOf<Map<String, Any>>()
|
||||
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||
while (cursor.moveToNext()) {
|
||||
val documentId = cursor.getString(idxDocId) ?: continue
|
||||
val nombre = cursor.getString(idxNombre) ?: continue
|
||||
val mime = cursor.getString(idxMime) ?: ""
|
||||
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
if (!esDirectorio && !mime.startsWith("audio/")) continue
|
||||
resultado.add(
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"nombre" to nombre,
|
||||
"esDirectorio" to esDirectorio,
|
||||
"mime" to mime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
resultado
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a leaf [documentId] within [treeUri] to its playable
|
||||
* `content://` URI (android-auto-local-music, static review only).
|
||||
* Returns `null` on any failure instead of throwing.
|
||||
*/
|
||||
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether [treeUri]'s read permission is still among
|
||||
* [android.content.ContentResolver.getPersistedUriPermissions]
|
||||
* (android-auto-local-music, static review only) — used for cold-start
|
||||
* / revoked-permission detection (Spec "Permission revoked or never
|
||||
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
|
||||
* any other failure.
|
||||
*/
|
||||
private fun hasPersistedPermission(treeUri: String): Boolean {
|
||||
return try {
|
||||
val parsed = Uri.parse(treeUri)
|
||||
contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission }
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
|
||||
* static review only — Design "Interfaces / Contracts"): for each of
|
||||
* [documentIds], extracts title/artist/bitrate/sample-rate and the
|
||||
* embedded picture via [extraerMetadatosPista]. Never throws across the
|
||||
* channel boundary — a malformed [treeUri] (or any other unexpected
|
||||
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
|
||||
* already isolated inside [extraerMetadatosPista].
|
||||
*/
|
||||
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts one [documentId]'s embedded metadata via
|
||||
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
|
||||
* review only — mirrors [listAudioChildren]/[resolvePlayableUri]'s
|
||||
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
|
||||
* public constant below API 31) is gated behind
|
||||
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
|
||||
* available since API 10 and read unconditionally. A resolvable
|
||||
* embedded picture is handed to [cachearArteEmbebido]; art-cache
|
||||
* failures degrade that single field to `null` without failing the
|
||||
* whole entry. On ANY failure for this [documentId] (unsupported
|
||||
* format, permission edge case, corrupt file), the row degrades to an
|
||||
* all-null-but-`documentId` entry instead of throwing —
|
||||
* `retriever.release()` always runs via `finally`.
|
||||
*/
|
||||
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
|
||||
retriever.setDataSource(this, documentUri)
|
||||
|
||||
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
|
||||
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
|
||||
val bitrate = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||
?.toIntOrNull()
|
||||
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
|
||||
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
|
||||
// public constant exists on this minSdk, so the raw key is
|
||||
// used directly, guarded by the version check above.
|
||||
retriever.extractMetadata(38)?.toIntOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val artUri = try {
|
||||
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
|
||||
} catch (error: Throwable) {
|
||||
Log.e(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
|
||||
error
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"titulo" to titulo,
|
||||
"artista" to artista,
|
||||
"bitrate" to bitrate,
|
||||
"sampleRate" to sampleRate,
|
||||
"artUri" to artUri
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
|
||||
error
|
||||
)
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"titulo" to null,
|
||||
"artista" to null,
|
||||
"bitrate" to null,
|
||||
"sampleRate" to null,
|
||||
"artUri" to null
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
retriever.release()
|
||||
} catch (_: Throwable) {
|
||||
// release() failing is not actionable — the retriever is
|
||||
// being discarded regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
|
||||
* static review only — Design ADR-1). Writes [picture] bytes to
|
||||
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
|
||||
* file already exists, so re-parsing the same track reuses it), returns
|
||||
* the `content://` URI served via the EXISTING
|
||||
* `${applicationId}.fileprovider` authority
|
||||
* (`AndroidManifest.xml:97-105`, `pluriwave_file_paths.xml`'s
|
||||
* `cache-path path="."` — confirmed present, zero manifest changes
|
||||
* needed) and trims `pluriwave_art/` via [trimArtCache]. `hash` uses
|
||||
* SHA-256 hex because a raw `documentId` may contain `:`/`/`, which are
|
||||
* illegal in filenames on most filesystems.
|
||||
*/
|
||||
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
|
||||
return try {
|
||||
val artDir = File(cacheDir, "pluriwave_art").apply { mkdirs() }
|
||||
val artFile = File(artDir, hashDocumentId(documentId))
|
||||
if (!artFile.exists()) {
|
||||
artFile.writeBytes(picture)
|
||||
}
|
||||
trimArtCache(artDir)
|
||||
FileProvider.getUriForFile(this, "$packageName.fileprovider", artFile).toString()
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hashDocumentId(documentId: String): String {
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
|
||||
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
|
||||
* total, deleting the OLDEST-by-mtime entries first. Kept as a
|
||||
* trivially reviewable loop — these files are native-owned, so
|
||||
* round-tripping names to Dart to pick deletions would add channel
|
||||
* chatter with no testability gain (the `delete()` is native
|
||||
* regardless, per ADR-1's rationale).
|
||||
*/
|
||||
private fun trimArtCache(artDir: File) {
|
||||
val maxArchivos = 256
|
||||
val maxBytes = 32L * 1024 * 1024
|
||||
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
|
||||
?: return
|
||||
var totalBytes = archivos.sumOf { it.length() }
|
||||
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
|
||||
val masViejo = archivos.removeAt(archivos.size - 1)
|
||||
totalBytes -= masViejo.length()
|
||||
masViejo.delete()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'estado/estado_radio.dart';
|
||||
import 'estado/estado_alarmas.dart';
|
||||
import 'estado/estado_idioma.dart';
|
||||
import 'estado/estado_navegacion.dart';
|
||||
import 'estado/estado_visualizador.dart';
|
||||
import 'servicios/servicio_anuncios.dart';
|
||||
import 'servicios/servicio_compras.dart';
|
||||
import 'widgets/banner_anuncio_superior.dart';
|
||||
@@ -122,6 +123,12 @@ class PluriWaveApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
||||
),
|
||||
// Sensitive-permission opt-in for the waveform visualizer's real
|
||||
// audio capture. Lives at the root because BOTH visualizer call
|
||||
// sites (the Escuchar hero and the full player) have to read it —
|
||||
// whichever of them mounts first is the one that would otherwise
|
||||
// trigger the RECORD_AUDIO request.
|
||||
ChangeNotifierProvider(create: (_) => EstadoVisualizador(prefs: prefs)),
|
||||
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
|
||||
// watches this instead of owning `_indice` locally.
|
||||
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
|
||||
@@ -205,6 +212,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
||||
// without a live native sink. Re-subscribe and re-seed the active device
|
||||
// (no-op when multi-device EQ is off).
|
||||
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
||||
// Silent, throttled license re-verification (refund revocation) and a
|
||||
// re-sync with any change the Android Auto path persisted meanwhile.
|
||||
// Fire-and-forget: never delays the resume, never shows anything.
|
||||
unawaited(context.read<EstadoEntitlement>().refrescarLicencia());
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -37,7 +37,9 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
_presetsPersonalizadosService =
|
||||
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
|
||||
_dispositivoAudio = dispositivoAudio,
|
||||
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
|
||||
_emisoraActualUuid = emisoraActualUuid ?? (() => null) {
|
||||
_escucharCambiosEqDesdeHandler();
|
||||
}
|
||||
|
||||
final ServicioAudio audio;
|
||||
final ServicioEcualizador servicio;
|
||||
@@ -84,6 +86,27 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
StreamSubscription<DispositivoAudio>? _deviceSub;
|
||||
Future<void>? _refrescoEnCurso;
|
||||
|
||||
/// Catches a car/notification-initiated EQ change that bypasses this
|
||||
/// class entirely (eq-sync-superficies): `accionEqToggle` calls
|
||||
/// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
|
||||
/// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
|
||||
/// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
|
||||
/// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
|
||||
/// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
|
||||
/// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
|
||||
/// tick (which the handler already re-emits on any EQ change via
|
||||
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
|
||||
/// the handler's current EQ state against our cached copy and adopt it on
|
||||
/// divergence.
|
||||
///
|
||||
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
|
||||
/// the flag and persists it itself, so this subscription no longer closes
|
||||
/// a persistence gap — it just keeps the phone's toggle showing what the
|
||||
/// engine is really doing. It also cannot be the fix on its own: it exists
|
||||
/// only while an [EstadoEcualizador] does, and the headless Android Auto
|
||||
/// engine that produced the bug report never builds one.
|
||||
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
|
||||
|
||||
PresetEcualizador get presetActual => _presetActual;
|
||||
PresetEcualizador get presetPrincipal => _presetPrincipal;
|
||||
bool get activo => _activo;
|
||||
@@ -337,6 +360,63 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Subscribes to [ServicioAudio.estadoStream] to catch a
|
||||
/// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
|
||||
/// doc for the full rationale).
|
||||
void _escucharCambiosEqDesdeHandler() {
|
||||
_suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
|
||||
unawaited(_resincronizarConHandler());
|
||||
});
|
||||
}
|
||||
|
||||
/// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
|
||||
/// [ServicioAudio.presetActual]) against our cached [_activo]/
|
||||
/// [_presetActual] and adopts the handler's value on divergence.
|
||||
///
|
||||
/// Deliberately never calls back into [audio] here (no
|
||||
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
|
||||
/// handler's own `_actualizarControlesEq()` re-push, which would tick
|
||||
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
|
||||
/// Only a local field write and [notifyListeners] happen here, so a
|
||||
/// divergence is resolved in a single pass.
|
||||
///
|
||||
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
|
||||
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
|
||||
/// `registrarHandler` injects, so the value is saved on every engine
|
||||
/// rather than only on one that happens to have built a widget tree. This
|
||||
/// method could never have been the owner of that fact: it only runs while
|
||||
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
|
||||
/// behind the bug report none ever does.
|
||||
///
|
||||
/// Wrapped in try/catch like every other handler-facing read in this
|
||||
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
|
||||
/// unexpected platform state that makes [audio]'s EQ getters unavailable
|
||||
/// must never crash the stream subscription — it just skips this tick.
|
||||
Future<void> _resincronizarConHandler() async {
|
||||
try {
|
||||
final activoHandler = audio.ecualizadorActivo;
|
||||
final presetHandler = audio.presetActual;
|
||||
|
||||
final activoDiverge = activoHandler != _activo;
|
||||
final presetDiverge = presetHandler != _presetActual;
|
||||
if (!activoDiverge && !presetDiverge) return;
|
||||
|
||||
if (activoDiverge) {
|
||||
// Display-only adopt: the handler already persisted this value
|
||||
// through its own write port before it ever reached us. See the
|
||||
// doc above.
|
||||
_activo = activoHandler;
|
||||
}
|
||||
if (presetDiverge) {
|
||||
_presetActual = presetHandler;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// See doc above — never let a resync failure crash the app.
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies [preset] to the audio engine and tracks it as current
|
||||
/// WITHOUT persisting it (used when switching stations).
|
||||
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
|
||||
@@ -583,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
|
||||
/// owns the outcome, and this superseded call must not apply a preset or
|
||||
/// persist a value the user has already changed their mind about.
|
||||
///
|
||||
/// The handler can also REFUSE the change: when the native `setEnabled`
|
||||
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
|
||||
/// flag back and skips its persistence write, so the value we optimistically
|
||||
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
|
||||
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
|
||||
/// learn that: on divergence we adopt the handler's real value and return
|
||||
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
|
||||
/// to disk that would resurrect it on the next start. The supersede check
|
||||
/// runs FIRST so a newer tap still owns the outcome; the read-back only
|
||||
/// speaks for a call nobody overtook.
|
||||
Future<void> cambiarActivo(bool activo) async {
|
||||
_activo = activo;
|
||||
notifyListeners();
|
||||
|
||||
await audio.setEcualizadorActivo(activo);
|
||||
if (_activo != activo) return;
|
||||
final aceptado = audio.ecualizadorActivo;
|
||||
if (aceptado != activo) {
|
||||
_activo = aceptado;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (activo) {
|
||||
await audio.aplicarPreset(_presetActual);
|
||||
if (_activo != activo) return;
|
||||
@@ -627,12 +724,21 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
|
||||
/// Replaces the whole EQ configuration (backup import path): persists it,
|
||||
/// re-applies the preset effective for the current station and notifies.
|
||||
///
|
||||
/// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When
|
||||
/// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT
|
||||
/// toggle is left untouched: an absent flag must never flip the user's live
|
||||
/// setting to an arbitrary value. When non-null, applies it through
|
||||
/// [cambiarActivo], the same path a manual toggle uses, so the import
|
||||
/// persists it AND pushes it to the live audio engine instead of just
|
||||
/// updating [_activo] in memory.
|
||||
Future<void> importarConfiguracion({
|
||||
required PresetEcualizador principal,
|
||||
required Map<String, PresetEcualizador> porEmisora,
|
||||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
bool? eqMultiDeviceEnabled,
|
||||
bool? activo,
|
||||
}) async {
|
||||
_presetPrincipal = principal;
|
||||
_presetsEmisoraMap
|
||||
@@ -667,12 +773,18 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
final presetEfectivoActual =
|
||||
uuid == null ? _presetPrincipal : _resolverPresetActivo();
|
||||
await aplicarPresetActivo(presetEfectivoActual);
|
||||
|
||||
if (activo != null) {
|
||||
await cambiarActivo(activo);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceSub?.cancel();
|
||||
_suscripcionEstadoAudioEq?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
|
||||
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
||||
import '../servicios/servicio_compras.dart';
|
||||
import '../servicios/verificacion_licencia.dart';
|
||||
|
||||
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
||||
/// premium unlock. Older builds that predate this key simply never read it —
|
||||
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
||||
/// builds").
|
||||
const _keyPremium = 'compra_premium_v1';
|
||||
/// builds"). Shared with the silent license re-verification
|
||||
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
|
||||
const _keyPremium = claveCompraPremium;
|
||||
|
||||
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
||||
/// Entitlement Read"): resolves the persisted premium flag directly from
|
||||
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
|
||||
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
||||
/// since no `Provider` exists on that path.
|
||||
class EstadoEntitlement extends ChangeNotifier {
|
||||
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
|
||||
: _prefs = prefs,
|
||||
_compras = compras {
|
||||
EstadoEntitlement({
|
||||
SharedPreferences? prefs,
|
||||
PuertoCompras? compras,
|
||||
DateTime Function()? reloj,
|
||||
}) : _prefs = prefs,
|
||||
_compras = compras,
|
||||
_reloj = reloj {
|
||||
final flujo = _compras;
|
||||
if (flujo != null) {
|
||||
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
|
||||
}
|
||||
_cargar();
|
||||
// The silent license check is chained AFTER the load and never awaited
|
||||
// by anyone: the persisted flag is served immediately, exactly as
|
||||
// before, and the check can only adjust it later, in the background.
|
||||
unawaited(_cargar().then((_) => _verificarLicencia()));
|
||||
}
|
||||
|
||||
/// The single non-consumable product id (Design "Interfaces / Contracts"),
|
||||
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
final PuertoCompras? _compras;
|
||||
|
||||
/// Injectable clock for the license check's throttle/spacing rules.
|
||||
final DateTime Function()? _reloj;
|
||||
StreamSubscription<EventoCompra>? _comprasSub;
|
||||
bool _desechado = false;
|
||||
|
||||
bool _esPremium = false;
|
||||
bool _compraEnCurso = false;
|
||||
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
|
||||
/// Fire-and-forget hook for app resume: re-syncs with the persisted flag
|
||||
/// (the Android Auto path may have changed it) and runs the throttled
|
||||
/// silent license check. Never throws, never touches [compraEnCurso] or
|
||||
/// [resultadoUsuario].
|
||||
Future<void> refrescarLicencia() async {
|
||||
try {
|
||||
_sincronizarConPrefs(await _resolverPrefs());
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][licencia] refresco fallido $e');
|
||||
}
|
||||
await _verificarLicencia();
|
||||
}
|
||||
|
||||
/// Runs [verificarLicencia] against the purchase port and mirrors any
|
||||
/// change of the persisted flag. Silent by construction: it only ever
|
||||
/// updates [esPremium] and notifies — no purchase-stream event, no
|
||||
/// [resultadoUsuario], no [compraEnCurso].
|
||||
Future<void> _verificarLicencia() async {
|
||||
final compras = _compras;
|
||||
if (compras == null || _desechado) return;
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await verificarLicencia(
|
||||
consultar: compras.consultarPropiedad,
|
||||
prefs: prefs,
|
||||
reloj: _reloj,
|
||||
);
|
||||
_sincronizarConPrefs(prefs);
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][licencia] verificacion fallida $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligns [esPremium] with the persisted flag. Safe against a racing
|
||||
/// [_desbloquear]: that one writes the prefs cache in the same synchronous
|
||||
/// block where it flips [_esPremium], so both always agree here.
|
||||
void _sincronizarConPrefs(SharedPreferences prefs) {
|
||||
if (_desechado) return;
|
||||
final premium = prefs.getBool(_keyPremium) ?? false;
|
||||
if (premium == _esPremium) return;
|
||||
_esPremium = premium;
|
||||
notifyListeners();
|
||||
// Either direction changes what the car may show (local music is
|
||||
// premium-gated), so the cached Android Auto tree is stale both ways.
|
||||
invalidarArbolAuto();
|
||||
}
|
||||
|
||||
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
||||
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
||||
/// duplicate charge is even attempted.
|
||||
@@ -171,22 +231,31 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _desbloquear() async {
|
||||
// Prefs resolved FIRST so the in-memory flip and the prefs-cache write
|
||||
// below happen in one synchronous block (`setBool` updates the cache
|
||||
// before awaiting the platform) — [_sincronizarConPrefs] can never
|
||||
// observe one without the other.
|
||||
final prefs = await _resolverPrefs();
|
||||
final yaEraPremium = _esPremium;
|
||||
_esPremium = true;
|
||||
_compraEnCurso = false;
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setBool(_keyPremium, true);
|
||||
final escritura = prefs.setBool(_keyPremium, true);
|
||||
// A real purchase/restore is fresh proof of ownership: drop any stale
|
||||
// absence streak of the silent license check.
|
||||
await reiniciarAusenciasLicencia(prefs);
|
||||
await escritura;
|
||||
notifyListeners();
|
||||
if (!yaEraPremium) {
|
||||
// Orchestrator-resolved open question (design.md): actively
|
||||
// invalidate the Android Auto browse cache on the free -> premium
|
||||
// transition, rather than waiting for the head unit's own re-bind.
|
||||
notificarDesbloqueoAuto();
|
||||
invalidarArbolAuto();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_desechado = true;
|
||||
_comprasSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -182,24 +182,41 @@ class EstadoGrabacion extends ChangeNotifier {
|
||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<bool> abrirUltimaGrabacion() async {
|
||||
final archivo = ultimoArchivo;
|
||||
if (archivo == null || !await archivo.exists()) {
|
||||
debugPrint('[PluriWave][recordings] last recording missing');
|
||||
/// Hands the recording at [ruta] to whatever player the user already has
|
||||
/// on THIS device (`ACTION_VIEW` through the native `openFile` method,
|
||||
/// over the app's own `FileProvider`). Nothing leaves the device: this is
|
||||
/// the "play my own copy elsewhere" action, not a share sheet.
|
||||
///
|
||||
/// Returns `false` when the file is gone or no installed app accepted the
|
||||
/// intent, so the caller can say so instead of failing in silence.
|
||||
///
|
||||
/// Static-review-only, like [abrirDirectorio] and every other method here
|
||||
/// that crosses `pluriwave/file_actions`: the channel has no handler under
|
||||
/// `flutter test`. The screens that call it inject a seam instead.
|
||||
Future<bool> abrirGrabacion(String ruta) async {
|
||||
final archivo = File(ruta);
|
||||
if (!await archivo.exists()) {
|
||||
debugPrint('[PluriWave][recordings] file missing: $ruta');
|
||||
return false;
|
||||
}
|
||||
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
|
||||
debugPrint('[PluriWave][recordings] opening file: $ruta');
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
||||
'path': archivo.path,
|
||||
'path': ruta,
|
||||
'mimeType': 'audio/*',
|
||||
});
|
||||
return abierto ?? false;
|
||||
}
|
||||
return launchUrl(
|
||||
Uri.file(archivo.path),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
return launchUrl(Uri.file(ruta), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<bool> abrirUltimaGrabacion() async {
|
||||
final archivo = ultimoArchivo;
|
||||
if (archivo == null) {
|
||||
debugPrint('[PluriWave][recordings] last recording missing');
|
||||
return false;
|
||||
}
|
||||
return abrirGrabacion(archivo.path);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
||||
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
||||
/// forget, same treatment [reproducir] already gives other non-critical
|
||||
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
||||
/// never block or fail actual playback.
|
||||
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'persistir ${emisora.uuid}',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
||||
void _escucharErroresReproduccion() {
|
||||
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
|
||||
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final actual = audio.emisoraActual;
|
||||
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
||||
_emisoraSeleccionada = actual;
|
||||
// Issue 4: an Android-Auto-initiated selection is a real station
|
||||
// change too — remember it the same way `reproducir` does.
|
||||
unawaited(_persistirUltimaEmisora(actual));
|
||||
// Issue 4's write used to live here as well. It is gone: the handler
|
||||
// persists every station itself from `_cambiarFuente`, which is the
|
||||
// same source change that moved `audio.emisoraActual` and is the
|
||||
// reason this branch runs at all. Writing again here would make the
|
||||
// key's final value depend on how two independent fire-and-forget
|
||||
// chains interleave on a fast station switch.
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
notifyListeners();
|
||||
// Issue 4: remembers the station the user just picked so it survives a
|
||||
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
||||
// below (a persistence failure here must never block playback).
|
||||
unawaited(_persistirUltimaEmisora(emisora));
|
||||
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
|
||||
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
|
||||
// below reaches for this very station — see
|
||||
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
|
||||
// left the key with TWO fire-and-forget writers whose relative order
|
||||
// decides the value after a fast A -> B switch, and this one cannot see
|
||||
// the revision guard that already cancels a superseded change.
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
if (revision != _revisionReproduccion) return;
|
||||
@@ -807,9 +795,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
|
||||
|
||||
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
|
||||
/// con presets por dispositivo y matriz multi-device).
|
||||
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
|
||||
/// Genera el JSON de toda la configuración (v4 — portabilidad completa
|
||||
/// con presets por dispositivo, matriz multi-device y el toggle
|
||||
/// on/off del ecualizador).
|
||||
/// La forma del sobre vive en [ServicioExportImport] (S4-R4).
|
||||
Future<Map<String, dynamic>> exportarConfig() async {
|
||||
final favs = await favoritos.obtenerTodos();
|
||||
final grupos = await favoritos.obtenerGrupos();
|
||||
@@ -837,6 +826,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
presetsPorDispositivo: ecualizador.presetsDispositivo,
|
||||
presetsMatriz: ecualizador.presetsMatriz,
|
||||
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
|
||||
// v4 extension — equalizer global on/off toggle.
|
||||
ecualizadorActivo: ecualizador.activo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,10 +841,11 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
/// Importa configuración desde un JSON exportado previamente.
|
||||
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
|
||||
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
|
||||
/// v3 (+ presets por dispositivo, presets matriz, toggle multi-device)
|
||||
/// y v4 (+ toggle on/off del ecualizador).
|
||||
Future<void> importarConfig(Map<String, dynamic> data) async {
|
||||
final version = data['version'] as int? ?? 1;
|
||||
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
|
||||
if (version > 4) throw Exception(_textos.unsupportedConfigVersion);
|
||||
|
||||
final prefs = await _resolverPrefs();
|
||||
|
||||
@@ -873,7 +865,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final favRaw = data['favoritos'] as List? ?? [];
|
||||
for (final raw in favRaw) {
|
||||
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
||||
await favoritos.agregar(emisora);
|
||||
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
|
||||
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
|
||||
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
|
||||
// los grupos restaurados arriba volvían como cascarones vacíos y todas
|
||||
// las emisoras aterrizaban en «Sin asignar».
|
||||
await favoritos.restaurarFavorito(emisora);
|
||||
}
|
||||
|
||||
// ── Emisoras custom ───────────────────────────────────────────────────
|
||||
@@ -932,12 +929,20 @@ class EstadoRadio extends ChangeNotifier {
|
||||
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
|
||||
}
|
||||
|
||||
// v4 extension: equalizer on/off toggle. Read unconditionally — the key
|
||||
// is simply absent on any pre-v4 backup, which resolves to `null` and
|
||||
// leaves the user's CURRENT toggle untouched (see
|
||||
// `EstadoEcualizador.importarConfiguracion` doc): an old backup must
|
||||
// never flip a live setting it never carried.
|
||||
final ecualizadorActivo = data['ecualizadorActivo'] as bool?;
|
||||
|
||||
await ecualizador.importarConfiguracion(
|
||||
principal: presetPrincipal,
|
||||
porEmisora: presetsPorEmisora,
|
||||
presetsDispositivo: presetsDispositivo,
|
||||
presetsMatriz: presetsMatriz,
|
||||
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
|
||||
activo: ecualizadorActivo,
|
||||
);
|
||||
|
||||
// ── Alarmas (v2) ──────────────────────────────────────────────────────
|
||||
@@ -945,7 +950,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final alarmasData = data['alarmas'];
|
||||
if (alarmasData is Map<String, dynamic>) {
|
||||
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
|
||||
// ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso.
|
||||
// EstadoAlarmas es un ChangeNotifier independiente y de larga vida
|
||||
// que ya cargó sus alarmas en memoria: NO relee este storage por sí
|
||||
// solo. El llamador (pantalla_ajustes_backup.dart) es responsable de
|
||||
// invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido
|
||||
// de `refrescarProgramacion()` tras un import exitoso; EstadoRadio
|
||||
// se mantiene deliberadamente sin depender de EstadoAlarmas.
|
||||
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Opt-in for reading the REAL audio level behind the waveform visualizer.
|
||||
///
|
||||
/// This is a sensitive-permission gate, not a cosmetic preference.
|
||||
/// `VisualizadorAudio` subscribes to the native `pluriwave/audio_visualizer`
|
||||
/// EventChannel only while [ondaRealHabilitada] is true, and that
|
||||
/// subscription is precisely what makes `MainActivity` request
|
||||
/// `RECORD_AUDIO`. So the flag must:
|
||||
///
|
||||
/// * default to `false`, so a fresh install never asks;
|
||||
/// * flip to `true` only from a place where the user has just been told what
|
||||
/// the permission is for (see `PantallaAjustesVisualizador`);
|
||||
/// * be revocable at any time with no friction at all.
|
||||
///
|
||||
/// Without it the visualizer animates its synthetic wave, which is exactly
|
||||
/// what it already did whenever the permission was denied — nothing about
|
||||
/// the app breaks.
|
||||
class EstadoVisualizador extends ChangeNotifier {
|
||||
EstadoVisualizador({SharedPreferences? prefs}) : _prefs = prefs {
|
||||
_cargar();
|
||||
}
|
||||
|
||||
/// Persisted key. Public so tests can assert persistence without
|
||||
/// duplicating the literal.
|
||||
static const String claveOndaReal = 'visualizador_onda_real_v1';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
bool _ondaRealHabilitada = false;
|
||||
|
||||
bool get ondaRealHabilitada => _ondaRealHabilitada;
|
||||
|
||||
Future<void> cambiarOndaReal(bool habilitada) async {
|
||||
if (habilitada == _ondaRealHabilitada) return;
|
||||
_ondaRealHabilitada = habilitada;
|
||||
notifyListeners();
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setBool(claveOndaReal, habilitada);
|
||||
}
|
||||
|
||||
Future<void> _cargar() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final guardado = prefs.getBool(claveOndaReal) ?? false;
|
||||
if (guardado == _ondaRealHabilitada) return;
|
||||
_ondaRealHabilitada = guardado;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _resolverPrefs() async =>
|
||||
_prefs ?? SharedPreferences.getInstance();
|
||||
}
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "تعذّر تحميل الدول.",
|
||||
"recordingActionDelete": "حذف",
|
||||
"recordingActionRename": "إعادة تسمية",
|
||||
"recordingActionShare": "مشاركة",
|
||||
"stationActionShare": "مشاركة المحطة",
|
||||
"recordingActionOpenIn": "فتح في تطبيق آخر",
|
||||
"recordingOpenNoAppError": "لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.",
|
||||
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
|
||||
"recordingRenameDialogTitle": "إعادة تسمية التسجيل",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "استعادة المشتريات",
|
||||
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
|
||||
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
|
||||
"premiumActivo": "النسخة المميزة مفعّلة"
|
||||
"premiumActivo": "النسخة المميزة مفعّلة",
|
||||
"premiumHojaTitulo": "افتح PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
|
||||
"premiumBeneficioAndroidAuto": "الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)",
|
||||
"premiumBeneficioGrabacion": "تسجيل المحطات",
|
||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
|
||||
"premiumAhoraNo": "ليس الآن",
|
||||
"autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.",
|
||||
"autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.",
|
||||
"autoCarpetaEscuchar": "الاستماع",
|
||||
"autoCarpetaFavoritos": "المفضلة",
|
||||
"autoCarpetaTodas": "كل المحطات",
|
||||
"autoCarpetaMisEmisoras": "محطاتي",
|
||||
"autoCarpetaMusicaLocal": "الموسيقى المحلية",
|
||||
"autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك",
|
||||
"autoCargarMas": "المزيد…",
|
||||
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
|
||||
"autoReproducirCarpeta": "تشغيل المجلد",
|
||||
"autoReproducirAleatorio": "تشغيل عشوائي",
|
||||
"autoPistaSinNombre": "مقطع بلا اسم",
|
||||
"recordingsPrivateUseNotice": "التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.",
|
||||
"visualizerRealWaveTitle": "الموجة الحقيقية للصوت",
|
||||
"visualizerRealWaveSubtitle": "تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.",
|
||||
"visualizerRealWavePermissionExplanation": "لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.",
|
||||
"visualizerRealWaveEnableAction": "تفعيل"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "দেশগুলো লোড করা যায়নি।",
|
||||
"recordingActionDelete": "মুছে ফেলুন",
|
||||
"recordingActionRename": "নাম পরিবর্তন করুন",
|
||||
"recordingActionShare": "শেয়ার করুন",
|
||||
"stationActionShare": "স্টেশন শেয়ার করুন",
|
||||
"recordingActionOpenIn": "অন্য অ্যাপে খুলুন",
|
||||
"recordingOpenNoAppError": "এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।",
|
||||
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
|
||||
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
|
||||
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
|
||||
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়"
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়",
|
||||
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
|
||||
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)",
|
||||
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
|
||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
|
||||
"premiumAhoraNo": "এখন নয়",
|
||||
"autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।",
|
||||
"autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।",
|
||||
"autoCarpetaEscuchar": "শুনুন",
|
||||
"autoCarpetaFavoritos": "প্রিয়",
|
||||
"autoCarpetaTodas": "সব স্টেশন",
|
||||
"autoCarpetaMisEmisoras": "আমার স্টেশন",
|
||||
"autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত",
|
||||
"autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন",
|
||||
"autoCargarMas": "আরও…",
|
||||
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
|
||||
"autoReproducirCarpeta": "ফোল্ডার চালান",
|
||||
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
|
||||
"autoPistaSinNombre": "নামহীন ট্র্যাক",
|
||||
"recordingsPrivateUseNotice": "রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।",
|
||||
"visualizerRealWaveTitle": "আসল অডিও তরঙ্গ",
|
||||
"visualizerRealWaveSubtitle": "বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।",
|
||||
"visualizerRealWavePermissionExplanation": "মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।",
|
||||
"visualizerRealWaveEnableAction": "চালু করুন"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Die Länder konnten nicht geladen werden.",
|
||||
"recordingActionDelete": "Löschen",
|
||||
"recordingActionRename": "Umbenennen",
|
||||
"recordingActionShare": "Teilen",
|
||||
"stationActionShare": "Sender teilen",
|
||||
"recordingActionOpenIn": "In anderer App öffnen",
|
||||
"recordingOpenNoAppError": "Keine App auf diesem Gerät kann diese Aufnahme abspielen.",
|
||||
"recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
|
||||
"recordingDeleteConfirmTitle": "Aufnahme löschen?",
|
||||
"recordingRenameDialogTitle": "Aufnahme umbenennen",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Käufe wiederherstellen",
|
||||
"compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
||||
"restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.",
|
||||
"premiumActivo": "Premium aktiv"
|
||||
"premiumActivo": "Premium aktiv",
|
||||
"premiumHojaTitulo": "PluriWave Premium freischalten",
|
||||
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
|
||||
"premiumBeneficioAndroidAuto": "Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)",
|
||||
"premiumBeneficioGrabacion": "Sender aufnehmen",
|
||||
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
|
||||
"premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.",
|
||||
"premiumAhoraNo": "Nicht jetzt",
|
||||
"autoErrorEmisoraPremium": "Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.",
|
||||
"autoErrorBusquedaSinResultados": "Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.",
|
||||
"autoCarpetaEscuchar": "Hören",
|
||||
"autoCarpetaFavoritos": "Favoriten",
|
||||
"autoCarpetaTodas": "Alle Sender",
|
||||
"autoCarpetaMisEmisoras": "Meine Sender",
|
||||
"autoCarpetaMusicaLocal": "Lokale Musik",
|
||||
"autoMusicaLocalNoDisponible": "Öffne PluriWave auf dem Handy, um deine Musik zu lesen",
|
||||
"autoCargarMas": "Mehr…",
|
||||
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
|
||||
"autoReproducirCarpeta": "Ordner abspielen",
|
||||
"autoReproducirAleatorio": "Zufallswiedergabe",
|
||||
"autoPistaSinNombre": "Unbenannter Titel",
|
||||
"recordingsPrivateUseNotice": "Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.",
|
||||
"visualizerRealWaveTitle": "Echte Audio-Wellenform",
|
||||
"visualizerRealWaveSubtitle": "Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.",
|
||||
"visualizerRealWavePermissionExplanation": "Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.",
|
||||
"visualizerRealWaveEnableAction": "Aktivieren"
|
||||
}
|
||||
|
||||
+30
-2
@@ -237,7 +237,9 @@
|
||||
"recordingsLibraryEmptyTitle": "No recordings yet",
|
||||
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
|
||||
"recordingActionRename": "Rename",
|
||||
"recordingActionShare": "Share",
|
||||
"stationActionShare": "Share station",
|
||||
"recordingActionOpenIn": "Open in another app",
|
||||
"recordingOpenNoAppError": "No app on this device can play this recording.",
|
||||
"recordingActionDelete": "Delete",
|
||||
"recordingRenameDialogTitle": "Rename recording",
|
||||
"recordingRenameLabel": "Name",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Restore purchases",
|
||||
"compraError": "We couldn't complete the purchase. Please try again.",
|
||||
"restauracionSinCompras": "We didn't find any previous purchase on this account.",
|
||||
"premiumActivo": "Premium active"
|
||||
"premiumActivo": "Premium active",
|
||||
"premiumHojaTitulo": "Unlock PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
|
||||
"premiumBeneficioAndroidAuto": "Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)",
|
||||
"premiumBeneficioGrabacion": "Station recording",
|
||||
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
|
||||
"premiumPagoUnico": "One-time purchase, forever. Not a subscription.",
|
||||
"premiumAhoraNo": "Not now",
|
||||
"autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.",
|
||||
"autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.",
|
||||
"autoCarpetaEscuchar": "Listen",
|
||||
"autoCarpetaFavoritos": "Favorites",
|
||||
"autoCarpetaTodas": "All stations",
|
||||
"autoCarpetaMisEmisoras": "My stations",
|
||||
"autoCarpetaMusicaLocal": "Local music",
|
||||
"autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music",
|
||||
"autoCargarMas": "More…",
|
||||
"autoOrdenarPorCalidad": "Sort by quality",
|
||||
"autoReproducirCarpeta": "Play folder",
|
||||
"autoReproducirAleatorio": "Shuffle play",
|
||||
"autoPistaSinNombre": "Untitled track",
|
||||
"recordingsPrivateUseNotice": "Recordings are for your own personal use. Please respect each station's rights and those of its content.",
|
||||
"visualizerRealWaveTitle": "Real audio waveform",
|
||||
"visualizerRealWaveSubtitle": "The bars follow the sound that is playing. When off, they animate on their own.",
|
||||
"visualizerRealWavePermissionExplanation": "Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.",
|
||||
"visualizerRealWaveEnableAction": "Turn on"
|
||||
}
|
||||
|
||||
+30
-2
@@ -237,7 +237,9 @@
|
||||
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
|
||||
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
|
||||
"recordingActionRename": "Renombrar",
|
||||
"recordingActionShare": "Compartir",
|
||||
"stationActionShare": "Compartir emisora",
|
||||
"recordingActionOpenIn": "Abrir en otra app",
|
||||
"recordingOpenNoAppError": "No hay ninguna app en este dispositivo que pueda reproducir la grabación.",
|
||||
"recordingActionDelete": "Eliminar",
|
||||
"recordingRenameDialogTitle": "Renombrar grabación",
|
||||
"recordingRenameLabel": "Nombre",
|
||||
@@ -863,5 +865,31 @@
|
||||
"restaurarCompras": "Restaurar compras",
|
||||
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
|
||||
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
|
||||
"premiumActivo": "Premium activo"
|
||||
"premiumActivo": "Premium activo",
|
||||
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
|
||||
"premiumBeneficioAndroidAuto": "Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)",
|
||||
"premiumBeneficioGrabacion": "Grabación de emisoras",
|
||||
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
|
||||
"premiumPagoUnico": "Pago único, para siempre. No es una suscripción.",
|
||||
"premiumAhoraNo": "Ahora no",
|
||||
"autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.",
|
||||
"autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.",
|
||||
"autoCarpetaEscuchar": "Escuchar",
|
||||
"autoCarpetaFavoritos": "Favoritos",
|
||||
"autoCarpetaTodas": "Todas las emisoras",
|
||||
"autoCarpetaMisEmisoras": "Mis emisoras",
|
||||
"autoCarpetaMusicaLocal": "Música Local",
|
||||
"autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música",
|
||||
"autoCargarMas": "Más…",
|
||||
"autoOrdenarPorCalidad": "Ordenar por calidad",
|
||||
"autoReproducirCarpeta": "Reproducir carpeta",
|
||||
"autoReproducirAleatorio": "Reproducir aleatorio",
|
||||
"autoPistaSinNombre": "Pista sin nombre",
|
||||
"recordingsPrivateUseNotice": "Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.",
|
||||
"visualizerRealWaveTitle": "Onda real del audio",
|
||||
"visualizerRealWaveSubtitle": "Las barras siguen el sonido que está sonando. Desactivado, se animan solas.",
|
||||
"visualizerRealWavePermissionExplanation": "Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.",
|
||||
"visualizerRealWaveEnableAction": "Activar"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Impossible de charger les pays.",
|
||||
"recordingActionDelete": "Supprimer",
|
||||
"recordingActionRename": "Renommer",
|
||||
"recordingActionShare": "Partager",
|
||||
"stationActionShare": "Partager la station",
|
||||
"recordingActionOpenIn": "Ouvrir dans une autre appli",
|
||||
"recordingOpenNoAppError": "Aucune application de cet appareil ne peut lire cet enregistrement.",
|
||||
"recordingDeleteConfirmMessage": "Cette action est irréversible.",
|
||||
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
|
||||
"recordingRenameDialogTitle": "Renommer l'enregistrement",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Restaurer les achats",
|
||||
"compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
|
||||
"restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.",
|
||||
"premiumActivo": "Premium actif"
|
||||
"premiumActivo": "Premium actif",
|
||||
"premiumHojaTitulo": "Débloquer PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
|
||||
"premiumBeneficioAndroidAuto": "Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)",
|
||||
"premiumBeneficioGrabacion": "Enregistrement des stations",
|
||||
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
|
||||
"premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.",
|
||||
"premiumAhoraNo": "Plus tard",
|
||||
"autoErrorEmisoraPremium": "Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.",
|
||||
"autoErrorBusquedaSinResultados": "Nous n'avons pas trouvé cette station. Essaie un autre nom.",
|
||||
"autoCarpetaEscuchar": "Écouter",
|
||||
"autoCarpetaFavoritos": "Favoris",
|
||||
"autoCarpetaTodas": "Toutes les stations",
|
||||
"autoCarpetaMisEmisoras": "Mes stations",
|
||||
"autoCarpetaMusicaLocal": "Musique locale",
|
||||
"autoMusicaLocalNoDisponible": "Ouvrez PluriWave sur votre téléphone pour lire votre musique",
|
||||
"autoCargarMas": "Plus…",
|
||||
"autoOrdenarPorCalidad": "Trier par qualité",
|
||||
"autoReproducirCarpeta": "Lire le dossier",
|
||||
"autoReproducirAleatorio": "Lecture aléatoire",
|
||||
"autoPistaSinNombre": "Piste sans nom",
|
||||
"recordingsPrivateUseNotice": "Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.",
|
||||
"visualizerRealWaveTitle": "Onde audio réelle",
|
||||
"visualizerRealWaveSubtitle": "Les barres suivent le son en cours. Désactivées, elles s'animent d'elles-mêmes.",
|
||||
"visualizerRealWavePermissionExplanation": "Android n'autorise la lecture du niveau audio qu'avec l'autorisation du microphone. PluriWave n'écoute ni n'enregistre le microphone : il mesure seulement le son qu'il diffuse déjà. Vous pouvez le désactiver à tout moment.",
|
||||
"visualizerRealWaveEnableAction": "Activer"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "देश लोड नहीं हो सके।",
|
||||
"recordingActionDelete": "हटाएं",
|
||||
"recordingActionRename": "नाम बदलें",
|
||||
"recordingActionShare": "शेयर करें",
|
||||
"stationActionShare": "स्टेशन शेयर करें",
|
||||
"recordingActionOpenIn": "दूसरे ऐप में खोलें",
|
||||
"recordingOpenNoAppError": "इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।",
|
||||
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
|
||||
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
|
||||
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
|
||||
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
|
||||
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
|
||||
"premiumActivo": "प्रीमियम सक्रिय"
|
||||
"premiumActivo": "प्रीमियम सक्रिय",
|
||||
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
|
||||
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)",
|
||||
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
|
||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
|
||||
"premiumAhoraNo": "अभी नहीं",
|
||||
"autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।",
|
||||
"autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।",
|
||||
"autoCarpetaEscuchar": "सुनें",
|
||||
"autoCarpetaFavoritos": "पसंदीदा",
|
||||
"autoCarpetaTodas": "सभी स्टेशन",
|
||||
"autoCarpetaMisEmisoras": "मेरे स्टेशन",
|
||||
"autoCarpetaMusicaLocal": "लोकल संगीत",
|
||||
"autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें",
|
||||
"autoCargarMas": "और…",
|
||||
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
|
||||
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
|
||||
"autoReproducirAleatorio": "शफ़ल चलाएँ",
|
||||
"autoPistaSinNombre": "बिना नाम का ट्रैक",
|
||||
"recordingsPrivateUseNotice": "रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।",
|
||||
"visualizerRealWaveTitle": "असली ऑडियो तरंग",
|
||||
"visualizerRealWaveSubtitle": "बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।",
|
||||
"visualizerRealWavePermissionExplanation": "Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।",
|
||||
"visualizerRealWaveEnableAction": "चालू करें"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Negara tidak dapat dimuat.",
|
||||
"recordingActionDelete": "Hapus",
|
||||
"recordingActionRename": "Ganti nama",
|
||||
"recordingActionShare": "Bagikan",
|
||||
"stationActionShare": "Bagikan stasiun",
|
||||
"recordingActionOpenIn": "Buka di aplikasi lain",
|
||||
"recordingOpenNoAppError": "Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.",
|
||||
"recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"recordingDeleteConfirmTitle": "Hapus rekaman?",
|
||||
"recordingRenameDialogTitle": "Ganti nama rekaman",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Pulihkan pembelian",
|
||||
"compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
|
||||
"restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.",
|
||||
"premiumActivo": "Premium aktif"
|
||||
"premiumActivo": "Premium aktif",
|
||||
"premiumHojaTitulo": "Buka PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
|
||||
"premiumBeneficioAndroidAuto": "Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)",
|
||||
"premiumBeneficioGrabacion": "Perekaman stasiun",
|
||||
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
|
||||
"premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.",
|
||||
"premiumAhoraNo": "Nanti saja",
|
||||
"autoErrorEmisoraPremium": "Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.",
|
||||
"autoErrorBusquedaSinResultados": "Kami tidak menemukan stasiun itu. Coba nama lain.",
|
||||
"autoCarpetaEscuchar": "Dengarkan",
|
||||
"autoCarpetaFavoritos": "Favorit",
|
||||
"autoCarpetaTodas": "Semua stasiun",
|
||||
"autoCarpetaMisEmisoras": "Stasiun saya",
|
||||
"autoCarpetaMusicaLocal": "Musik lokal",
|
||||
"autoMusicaLocalNoDisponible": "Buka PluriWave di ponsel untuk membaca musik Anda",
|
||||
"autoCargarMas": "Lainnya…",
|
||||
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
|
||||
"autoReproducirCarpeta": "Putar folder",
|
||||
"autoReproducirAleatorio": "Putar acak",
|
||||
"autoPistaSinNombre": "Trek tanpa nama",
|
||||
"recordingsPrivateUseNotice": "Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.",
|
||||
"visualizerRealWaveTitle": "Gelombang audio asli",
|
||||
"visualizerRealWaveSubtitle": "Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.",
|
||||
"visualizerRealWavePermissionExplanation": "Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.",
|
||||
"visualizerRealWaveEnableAction": "Aktifkan"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Non è stato possibile caricare i paesi.",
|
||||
"recordingActionDelete": "Elimina",
|
||||
"recordingActionRename": "Rinomina",
|
||||
"recordingActionShare": "Condividi",
|
||||
"stationActionShare": "Condividi stazione",
|
||||
"recordingActionOpenIn": "Apri in un'altra app",
|
||||
"recordingOpenNoAppError": "Nessuna app su questo dispositivo può riprodurre questa registrazione.",
|
||||
"recordingDeleteConfirmMessage": "Questa azione non può essere annullata.",
|
||||
"recordingDeleteConfirmTitle": "Eliminare la registrazione?",
|
||||
"recordingRenameDialogTitle": "Rinomina registrazione",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Ripristina acquisti",
|
||||
"compraError": "Non è stato possibile completare l'acquisto. Riprova.",
|
||||
"restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.",
|
||||
"premiumActivo": "Premium attivo"
|
||||
"premiumActivo": "Premium attivo",
|
||||
"premiumHojaTitulo": "Sblocca PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
|
||||
"premiumBeneficioAndroidAuto": "Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)",
|
||||
"premiumBeneficioGrabacion": "Registrazione delle stazioni",
|
||||
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
|
||||
"premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.",
|
||||
"premiumAhoraNo": "Non ora",
|
||||
"autoErrorEmisoraPremium": "Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.",
|
||||
"autoErrorBusquedaSinResultados": "Non abbiamo trovato quella stazione. Prova con un altro nome.",
|
||||
"autoCarpetaEscuchar": "Ascolta",
|
||||
"autoCarpetaFavoritos": "Preferiti",
|
||||
"autoCarpetaTodas": "Tutte le emittenti",
|
||||
"autoCarpetaMisEmisoras": "Le mie emittenti",
|
||||
"autoCarpetaMusicaLocal": "Musica locale",
|
||||
"autoMusicaLocalNoDisponible": "Apri PluriWave sul telefono per leggere la tua musica",
|
||||
"autoCargarMas": "Altro…",
|
||||
"autoOrdenarPorCalidad": "Ordina per qualità",
|
||||
"autoReproducirCarpeta": "Riproduci cartella",
|
||||
"autoReproducirAleatorio": "Riproduzione casuale",
|
||||
"autoPistaSinNombre": "Traccia senza nome",
|
||||
"recordingsPrivateUseNotice": "Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.",
|
||||
"visualizerRealWaveTitle": "Onda audio reale",
|
||||
"visualizerRealWaveSubtitle": "Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.",
|
||||
"visualizerRealWavePermissionExplanation": "Android consente di leggere il livello audio solo con l'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.",
|
||||
"visualizerRealWaveEnableAction": "Attiva"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "国の一覧を読み込めませんでした。",
|
||||
"recordingActionDelete": "削除",
|
||||
"recordingActionRename": "名前を変更",
|
||||
"recordingActionShare": "共有",
|
||||
"stationActionShare": "放送局を共有",
|
||||
"recordingActionOpenIn": "別のアプリで開く",
|
||||
"recordingOpenNoAppError": "この端末には、この録音を再生できるアプリがありません。",
|
||||
"recordingDeleteConfirmMessage": "この操作は元に戻せません。",
|
||||
"recordingDeleteConfirmTitle": "録音を削除しますか?",
|
||||
"recordingRenameDialogTitle": "録音の名前を変更",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "購入を復元",
|
||||
"compraError": "購入を完了できませんでした。もう一度お試しください。",
|
||||
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
|
||||
"premiumActivo": "プレミアム有効"
|
||||
"premiumActivo": "プレミアム有効",
|
||||
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
|
||||
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)",
|
||||
"premiumBeneficioGrabacion": "放送局の録音",
|
||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
|
||||
"premiumAhoraNo": "後で",
|
||||
"autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。",
|
||||
"autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。",
|
||||
"autoCarpetaEscuchar": "聴く",
|
||||
"autoCarpetaFavoritos": "お気に入り",
|
||||
"autoCarpetaTodas": "すべての局",
|
||||
"autoCarpetaMisEmisoras": "マイ局",
|
||||
"autoCarpetaMusicaLocal": "ローカルの音楽",
|
||||
"autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください",
|
||||
"autoCargarMas": "もっと見る…",
|
||||
"autoOrdenarPorCalidad": "音質順に並べ替え",
|
||||
"autoReproducirCarpeta": "フォルダを再生",
|
||||
"autoReproducirAleatorio": "シャッフル再生",
|
||||
"autoPistaSinNombre": "名称未設定のトラック",
|
||||
"recordingsPrivateUseNotice": "録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。",
|
||||
"visualizerRealWaveTitle": "実際の音声波形",
|
||||
"visualizerRealWaveSubtitle": "バーが再生中の音に合わせて動きます。オフのときは独自に動きます。",
|
||||
"visualizerRealWavePermissionExplanation": "Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。",
|
||||
"visualizerRealWaveEnableAction": "オンにする"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Não foi possível carregar os países.",
|
||||
"recordingActionDelete": "Excluir",
|
||||
"recordingActionRename": "Renomear",
|
||||
"recordingActionShare": "Compartilhar",
|
||||
"stationActionShare": "Compartilhar estação",
|
||||
"recordingActionOpenIn": "Abrir em outro app",
|
||||
"recordingOpenNoAppError": "Nenhum app deste dispositivo consegue reproduzir esta gravação.",
|
||||
"recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
|
||||
"recordingDeleteConfirmTitle": "Excluir gravação?",
|
||||
"recordingRenameDialogTitle": "Renomear gravação",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Restaurar compras",
|
||||
"compraError": "Não foi possível concluir a compra. Tente novamente.",
|
||||
"restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.",
|
||||
"premiumActivo": "Premium ativo"
|
||||
"premiumActivo": "Premium ativo",
|
||||
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
|
||||
"premiumBeneficioAndroidAuto": "Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)",
|
||||
"premiumBeneficioGrabacion": "Gravação de emissoras",
|
||||
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
|
||||
"premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.",
|
||||
"premiumAhoraNo": "Agora não",
|
||||
"autoErrorEmisoraPremium": "Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.",
|
||||
"autoErrorBusquedaSinResultados": "Não encontrámos essa estação. Tente outro nome.",
|
||||
"autoCarpetaEscuchar": "Ouvir",
|
||||
"autoCarpetaFavoritos": "Favoritos",
|
||||
"autoCarpetaTodas": "Todas as estações",
|
||||
"autoCarpetaMisEmisoras": "As minhas estações",
|
||||
"autoCarpetaMusicaLocal": "Música local",
|
||||
"autoMusicaLocalNoDisponible": "Abra o PluriWave no telemóvel para ler a sua música",
|
||||
"autoCargarMas": "Mais…",
|
||||
"autoOrdenarPorCalidad": "Ordenar por qualidade",
|
||||
"autoReproducirCarpeta": "Reproduzir pasta",
|
||||
"autoReproducirAleatorio": "Reprodução aleatória",
|
||||
"autoPistaSinNombre": "Faixa sem nome",
|
||||
"recordingsPrivateUseNotice": "As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.",
|
||||
"visualizerRealWaveTitle": "Onda de áudio real",
|
||||
"visualizerRealWaveSubtitle": "As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.",
|
||||
"visualizerRealWavePermissionExplanation": "O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.",
|
||||
"visualizerRealWaveEnableAction": "Ativar"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "Не удалось загрузить страны.",
|
||||
"recordingActionDelete": "Удалить",
|
||||
"recordingActionRename": "Переименовать",
|
||||
"recordingActionShare": "Поделиться",
|
||||
"stationActionShare": "Поделиться станцией",
|
||||
"recordingActionOpenIn": "Открыть в другом приложении",
|
||||
"recordingOpenNoAppError": "На этом устройстве нет приложения, способного воспроизвести эту запись.",
|
||||
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
|
||||
"recordingDeleteConfirmTitle": "Удалить запись?",
|
||||
"recordingRenameDialogTitle": "Переименовать запись",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "Восстановить покупки",
|
||||
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
|
||||
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
|
||||
"premiumActivo": "Премиум активен"
|
||||
"premiumActivo": "Премиум активен",
|
||||
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
|
||||
"premiumBeneficioAndroidAuto": "Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)",
|
||||
"premiumBeneficioGrabacion": "Запись радиостанций",
|
||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
|
||||
"premiumAhoraNo": "Не сейчас",
|
||||
"autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.",
|
||||
"autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.",
|
||||
"autoCarpetaEscuchar": "Слушать",
|
||||
"autoCarpetaFavoritos": "Избранное",
|
||||
"autoCarpetaTodas": "Все станции",
|
||||
"autoCarpetaMisEmisoras": "Мои станции",
|
||||
"autoCarpetaMusicaLocal": "Локальная музыка",
|
||||
"autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку",
|
||||
"autoCargarMas": "Ещё…",
|
||||
"autoOrdenarPorCalidad": "Сортировать по качеству",
|
||||
"autoReproducirCarpeta": "Воспроизвести папку",
|
||||
"autoReproducirAleatorio": "Случайное воспроизведение",
|
||||
"autoPistaSinNombre": "Трек без названия",
|
||||
"recordingsPrivateUseNotice": "Записи предназначены только для личного использования. Уважайте права станций и их контента.",
|
||||
"visualizerRealWaveTitle": "Реальная звуковая волна",
|
||||
"visualizerRealWaveSubtitle": "Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.",
|
||||
"visualizerRealWavePermissionExplanation": "Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.",
|
||||
"visualizerRealWaveEnableAction": "Включить"
|
||||
}
|
||||
|
||||
+30
-2
@@ -769,7 +769,9 @@
|
||||
"radioCountriesError": "无法加载国家列表。",
|
||||
"recordingActionDelete": "删除",
|
||||
"recordingActionRename": "重命名",
|
||||
"recordingActionShare": "分享",
|
||||
"stationActionShare": "分享电台",
|
||||
"recordingActionOpenIn": "用其他应用打开",
|
||||
"recordingOpenNoAppError": "此设备上没有可播放该录音的应用。",
|
||||
"recordingDeleteConfirmMessage": "此操作无法撤销。",
|
||||
"recordingDeleteConfirmTitle": "删除录音?",
|
||||
"recordingRenameDialogTitle": "重命名录音",
|
||||
@@ -904,5 +906,31 @@
|
||||
"restaurarCompras": "恢复购买",
|
||||
"compraError": "无法完成购买,请重试。",
|
||||
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
|
||||
"premiumActivo": "高级版已解锁"
|
||||
"premiumActivo": "高级版已解锁",
|
||||
"premiumHojaTitulo": "解锁 PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "全应用无广告",
|
||||
"premiumBeneficioAndroidAuto": "在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)",
|
||||
"premiumBeneficioGrabacion": "电台录音",
|
||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
|
||||
"premiumAhoraNo": "以后再说",
|
||||
"autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。",
|
||||
"autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。",
|
||||
"autoCarpetaEscuchar": "收听",
|
||||
"autoCarpetaFavoritos": "收藏",
|
||||
"autoCarpetaTodas": "全部电台",
|
||||
"autoCarpetaMisEmisoras": "我的电台",
|
||||
"autoCarpetaMusicaLocal": "本地音乐",
|
||||
"autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐",
|
||||
"autoCargarMas": "更多…",
|
||||
"autoOrdenarPorCalidad": "按音质排序",
|
||||
"autoReproducirCarpeta": "播放文件夹",
|
||||
"autoReproducirAleatorio": "随机播放",
|
||||
"autoPistaSinNombre": "未命名曲目",
|
||||
"recordingsPrivateUseNotice": "录音仅供你个人使用。请尊重各电台及其内容的权利。",
|
||||
"visualizerRealWaveTitle": "真实音频波形",
|
||||
"visualizerRealWaveSubtitle": "音条会跟随正在播放的声音。关闭时,音条会自行跳动。",
|
||||
"visualizerRealWavePermissionExplanation": "Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。",
|
||||
"visualizerRealWaveEnableAction": "开启"
|
||||
}
|
||||
|
||||
@@ -904,11 +904,23 @@ abstract class AppLocalizations {
|
||||
/// **'Renombrar'**
|
||||
String get recordingActionRename;
|
||||
|
||||
/// No description provided for @recordingActionShare.
|
||||
/// No description provided for @stationActionShare.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Compartir'**
|
||||
String get recordingActionShare;
|
||||
/// **'Compartir emisora'**
|
||||
String get stationActionShare;
|
||||
|
||||
/// No description provided for @recordingActionOpenIn.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Abrir en otra app'**
|
||||
String get recordingActionOpenIn;
|
||||
|
||||
/// No description provided for @recordingOpenNoAppError.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'No hay ninguna app en este dispositivo que pueda reproducir la grabación.'**
|
||||
String get recordingOpenNoAppError;
|
||||
|
||||
/// No description provided for @recordingActionDelete.
|
||||
///
|
||||
@@ -3367,6 +3379,162 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'Premium activo'**
|
||||
String get premiumActivo;
|
||||
|
||||
/// No description provided for @premiumHojaTitulo.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Desbloquea PluriWave Premium'**
|
||||
String get premiumHojaTitulo;
|
||||
|
||||
/// No description provided for @premiumBeneficioSinAnuncios.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Sin publicidad en toda la app'**
|
||||
String get premiumBeneficioSinAnuncios;
|
||||
|
||||
/// No description provided for @premiumBeneficioAndroidAuto.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)'**
|
||||
String get premiumBeneficioAndroidAuto;
|
||||
|
||||
/// No description provided for @premiumBeneficioGrabacion.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Grabación de emisoras'**
|
||||
String get premiumBeneficioGrabacion;
|
||||
|
||||
/// No description provided for @premiumBeneficioVacaciones.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Rangos de vacaciones para las alarmas'**
|
||||
String get premiumBeneficioVacaciones;
|
||||
|
||||
/// No description provided for @premiumBeneficioAlarmasIlimitadas.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Alarmas ilimitadas (el plan gratuito permite hasta 5)'**
|
||||
String get premiumBeneficioAlarmasIlimitadas;
|
||||
|
||||
/// No description provided for @premiumPagoUnico.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pago único, para siempre. No es una suscripción.'**
|
||||
String get premiumPagoUnico;
|
||||
|
||||
/// No description provided for @premiumAhoraNo.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ahora no'**
|
||||
String get premiumAhoraNo;
|
||||
|
||||
/// No description provided for @autoErrorEmisoraPremium.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'**
|
||||
String get autoErrorEmisoraPremium;
|
||||
|
||||
/// No description provided for @autoErrorBusquedaSinResultados.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'No hemos encontrado esa emisora. Prueba con otro nombre.'**
|
||||
String get autoErrorBusquedaSinResultados;
|
||||
|
||||
/// No description provided for @autoCarpetaEscuchar.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Escuchar'**
|
||||
String get autoCarpetaEscuchar;
|
||||
|
||||
/// No description provided for @autoCarpetaFavoritos.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Favoritos'**
|
||||
String get autoCarpetaFavoritos;
|
||||
|
||||
/// No description provided for @autoCarpetaTodas.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todas las emisoras'**
|
||||
String get autoCarpetaTodas;
|
||||
|
||||
/// No description provided for @autoCarpetaMisEmisoras.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Mis emisoras'**
|
||||
String get autoCarpetaMisEmisoras;
|
||||
|
||||
/// No description provided for @autoCarpetaMusicaLocal.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Música Local'**
|
||||
String get autoCarpetaMusicaLocal;
|
||||
|
||||
/// No description provided for @autoMusicaLocalNoDisponible.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Abre PluriWave en el móvil para leer tu música'**
|
||||
String get autoMusicaLocalNoDisponible;
|
||||
|
||||
/// No description provided for @autoCargarMas.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Más…'**
|
||||
String get autoCargarMas;
|
||||
|
||||
/// No description provided for @autoOrdenarPorCalidad.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ordenar por calidad'**
|
||||
String get autoOrdenarPorCalidad;
|
||||
|
||||
/// No description provided for @autoReproducirCarpeta.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Reproducir carpeta'**
|
||||
String get autoReproducirCarpeta;
|
||||
|
||||
/// No description provided for @autoReproducirAleatorio.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Reproducir aleatorio'**
|
||||
String get autoReproducirAleatorio;
|
||||
|
||||
/// No description provided for @autoPistaSinNombre.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pista sin nombre'**
|
||||
String get autoPistaSinNombre;
|
||||
|
||||
/// No description provided for @recordingsPrivateUseNotice.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.'**
|
||||
String get recordingsPrivateUseNotice;
|
||||
|
||||
/// No description provided for @visualizerRealWaveTitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Onda real del audio'**
|
||||
String get visualizerRealWaveTitle;
|
||||
|
||||
/// No description provided for @visualizerRealWaveSubtitle.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.'**
|
||||
String get visualizerRealWaveSubtitle;
|
||||
|
||||
/// No description provided for @visualizerRealWavePermissionExplanation.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.'**
|
||||
String get visualizerRealWavePermissionExplanation;
|
||||
|
||||
/// No description provided for @visualizerRealWaveEnableAction.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Activar'**
|
||||
String get visualizerRealWaveEnableAction;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -456,7 +456,14 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
String get recordingActionRename => 'إعادة تسمية';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'مشاركة';
|
||||
String get stationActionShare => 'مشاركة المحطة';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'فتح في تطبيق آخر';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'حذف';
|
||||
@@ -1863,4 +1870,90 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'النسخة المميزة مفعّلة';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'افتح PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'فترات إجازة للمنبهات';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico => 'دفعة واحدة، للأبد. ليس اشتراكًا.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'ليس الآن';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'لم نعثر على تلك المحطة. جرّب اسمًا آخر.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'الاستماع';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'المفضلة';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'كل المحطات';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'محطاتي';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'الموسيقى المحلية';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'افتح PluriWave على هاتفك لقراءة موسيقاك';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'المزيد…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'الترتيب حسب الجودة';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'تشغيل المجلد';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'تشغيل عشوائي';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'مقطع بلا اسم';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'الموجة الحقيقية للصوت';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'تفعيل';
|
||||
}
|
||||
|
||||
@@ -461,7 +461,14 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
String get recordingActionRename => 'নাম পরিবর্তন করুন';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'শেয়ার করুন';
|
||||
String get stationActionShare => 'স্টেশন শেয়ার করুন';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'অন্য অ্যাপে খুলুন';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'মুছে ফেলুন';
|
||||
@@ -1874,4 +1881,91 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'PluriWave Premium আনলক করুন';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'অ্যালার্মের জন্য ছুটির সময়কাল';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'এখন নয়';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'শুনুন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'প্রিয়';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'সব স্টেশন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'আমার স্টেশন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'স্থানীয় সঙ্গীত';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'আপনার গান পড়তে ফোনে PluriWave খুলুন';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'আরও…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'মান অনুসারে সাজান';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'ফোল্ডার চালান';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'এলোমেলোভাবে চালান';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'আসল অডিও তরঙ্গ';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'চালু করুন';
|
||||
}
|
||||
|
||||
@@ -464,7 +464,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get recordingActionRename => 'Umbenennen';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Teilen';
|
||||
String get stationActionShare => 'Sender teilen';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'In anderer App öffnen';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'Keine App auf diesem Gerät kann diese Aufnahme abspielen.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Löschen';
|
||||
@@ -1888,4 +1895,90 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium aktiv';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'PluriWave Premium freischalten';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'Urlaubszeiträume für Wecker';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico => 'Einmalzahlung, für immer. Kein Abonnement.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Nicht jetzt';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Hören';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoriten';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Alle Sender';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Meine Sender';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Lokale Musik';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Öffne PluriWave auf dem Handy, um deine Musik zu lesen';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Mehr…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Nach Qualität sortieren';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Ordner abspielen';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Zufallswiedergabe';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Unbenannter Titel';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Echte Audio-Wellenform';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Aktivieren';
|
||||
}
|
||||
|
||||
@@ -458,7 +458,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get recordingActionRename => 'Rename';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Share';
|
||||
String get stationActionShare => 'Share station';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Open in another app';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'No app on this device can play this recording.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Delete';
|
||||
@@ -1867,4 +1874,91 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium active';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Unlock PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Station recording';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'Vacation ranges for alarms';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Unlimited alarms (the free plan allows up to 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'One-time purchase, forever. Not a subscription.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Not now';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'This station is Premium. Open PluriWave on your phone to unlock it.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'We couldn\'t find that station. Try another name.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Listen';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favorites';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'All stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'My stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Local music';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Open PluriWave on your phone to read your music';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'More…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Sort by quality';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Play folder';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Shuffle play';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Untitled track';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Recordings are for your own personal use. Please respect each station\'s rights and those of its content.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Real audio waveform';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'The bars follow the sound that is playing. When off, they animate on their own.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Turn on';
|
||||
}
|
||||
|
||||
@@ -462,7 +462,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get recordingActionRename => 'Renombrar';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartir';
|
||||
String get stationActionShare => 'Compartir emisora';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Abrir en otra app';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'No hay ninguna app en este dispositivo que pueda reproducir la grabación.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Eliminar';
|
||||
@@ -1881,4 +1888,92 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium activo';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Desbloquea PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones =>
|
||||
'Rangos de vacaciones para las alarmas';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Alarmas ilimitadas (el plan gratuito permite hasta 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Pago único, para siempre. No es una suscripción.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Ahora no';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'No hemos encontrado esa emisora. Prueba con otro nombre.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Escuchar';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoritos';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Todas las emisoras';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Mis emisoras';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Música Local';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Abre PluriWave en el móvil para leer tu música';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Más…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordenar por calidad';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Reproducir carpeta';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Reproducir aleatorio';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Pista sin nombre';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Onda real del audio';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Activar';
|
||||
}
|
||||
|
||||
@@ -467,7 +467,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get recordingActionRename => 'Renommer';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Partager';
|
||||
String get stationActionShare => 'Partager la station';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Ouvrir dans une autre appli';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'Aucune application de cet appareil ne peut lire cet enregistrement.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Supprimer';
|
||||
@@ -1894,4 +1901,93 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium actif';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Débloquer PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios =>
|
||||
'Aucune publicité dans toute l\'application';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones =>
|
||||
'Périodes de vacances pour les alarmes';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Alarmes illimitées (la version gratuite en autorise jusqu\'à 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Achat unique, pour toujours. Ce n\'est pas un abonnement.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Plus tard';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Nous n\'avons pas trouvé cette station. Essaie un autre nom.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Écouter';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoris';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Toutes les stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Mes stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musique locale';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Ouvrez PluriWave sur votre téléphone pour lire votre musique';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Plus…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Trier par qualité';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Lire le dossier';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Lecture aléatoire';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Piste sans nom';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Onde audio réelle';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Les barres suivent le son en cours. Désactivées, elles s\'animent d\'elles-mêmes.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android n\'autorise la lecture du niveau audio qu\'avec l\'autorisation du microphone. PluriWave n\'écoute ni n\'enregistre le microphone : il mesure seulement le son qu\'il diffuse déjà. Vous pouvez le désactiver à tout moment.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Activer';
|
||||
}
|
||||
|
||||
@@ -459,7 +459,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get recordingActionRename => 'नाम बदलें';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'शेयर करें';
|
||||
String get stationActionShare => 'स्टेशन शेयर करें';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'दूसरे ऐप में खोलें';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'हटाएं';
|
||||
@@ -1867,4 +1874,91 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'प्रीमियम सक्रिय';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'PluriWave Premium अनलॉक करें';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'अलार्म के लिए छुट्टी की अवधि';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'अभी नहीं';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'सुनें';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'पसंदीदा';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'सभी स्टेशन';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'मेरे स्टेशन';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'लोकल संगीत';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'और…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'गुणवत्ता के अनुसार क्रमबद्ध करें';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'फ़ोल्डर चलाएँ';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'शफ़ल चलाएँ';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'असली ऑडियो तरंग';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'चालू करें';
|
||||
}
|
||||
|
||||
@@ -459,7 +459,14 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recordingActionRename => 'Ganti nama';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Bagikan';
|
||||
String get stationActionShare => 'Bagikan stasiun';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Buka di aplikasi lain';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Hapus';
|
||||
@@ -1878,4 +1885,91 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium aktif';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Buka PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'Rentang liburan untuk alarm';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Alarm tanpa batas (paket gratis mengizinkan hingga 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Pembelian sekali bayar, untuk selamanya. Bukan langganan.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Nanti saja';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Kami tidak menemukan stasiun itu. Coba nama lain.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Dengarkan';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favorit';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Semua stasiun';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Stasiun saya';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musik lokal';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Buka PluriWave di ponsel untuk membaca musik Anda';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Lainnya…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Urutkan menurut kualitas';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Putar folder';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Putar acak';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Trek tanpa nama';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Gelombang audio asli';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Aktifkan';
|
||||
}
|
||||
|
||||
@@ -463,7 +463,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get recordingActionRename => 'Rinomina';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Condividi';
|
||||
String get stationActionShare => 'Condividi stazione';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Apri in un\'altra app';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'Nessuna app su questo dispositivo può riprodurre questa registrazione.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Elimina';
|
||||
@@ -1891,4 +1898,93 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium attivo';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Sblocca PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios =>
|
||||
'Nessuna pubblicità in tutta l\'app';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones =>
|
||||
'Intervalli di vacanza per le sveglie';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Sveglie illimitate (il piano gratuito ne consente fino a 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Acquisto unico, per sempre. Non è un abbonamento.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Non ora';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Non abbiamo trovato quella stazione. Prova con un altro nome.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Ascolta';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Preferiti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Tutte le emittenti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Le mie emittenti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musica locale';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Apri PluriWave sul telefono per leggere la tua musica';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Altro…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordina per qualità';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Riproduci cartella';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Riproduzione casuale';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Traccia senza nome';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Onda audio reale';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android consente di leggere il livello audio solo con l\'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Attiva';
|
||||
}
|
||||
|
||||
@@ -445,7 +445,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get recordingActionRename => '名前を変更';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => '共有';
|
||||
String get stationActionShare => '放送局を共有';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => '別のアプリで開く';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError => 'この端末には、この録音を再生できるアプリがありません。';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => '削除';
|
||||
@@ -1812,4 +1818,87 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'プレミアム有効';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'PluriWave Premiumのロックを解除';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => '放送局の録音';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'アラームの休暇期間設定';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas => 'アラーム数無制限(無料プランは5個まで)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico => '買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => '後で';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados => 'その放送局は見つかりませんでした。別の名前をお試しください。';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => '聴く';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'お気に入り';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'すべての局';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'マイ局';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'ローカルの音楽';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'音楽を読み込むにはスマートフォンで PluriWave を開いてください';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'もっと見る…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => '音質順に並べ替え';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'フォルダを再生';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'シャッフル再生';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => '名称未設定のトラック';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => '実際の音声波形';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle => 'バーが再生中の音に合わせて動きます。オフのときは独自に動きます。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'オンにする';
|
||||
}
|
||||
|
||||
@@ -461,7 +461,14 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get recordingActionRename => 'Renomear';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Compartilhar';
|
||||
String get stationActionShare => 'Compartilhar estação';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Abrir em outro app';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'Nenhum app deste dispositivo consegue reproduzir esta gravação.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Excluir';
|
||||
@@ -1878,4 +1885,91 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Premium ativo';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Desbloqueie o PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'Períodos de férias para os alarmes';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Alarmes ilimitados (o plano gratuito permite até 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Pagamento único, para sempre. Não é uma assinatura.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Agora não';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Não encontrámos essa estação. Tente outro nome.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Ouvir';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoritos';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Todas as estações';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'As minhas estações';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Música local';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Abra o PluriWave no telemóvel para ler a sua música';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Mais…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordenar por qualidade';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Reproduzir pasta';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Reprodução aleatória';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Faixa sem nome';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Onda de áudio real';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Ativar';
|
||||
}
|
||||
|
||||
@@ -461,7 +461,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get recordingActionRename => 'Переименовать';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => 'Поделиться';
|
||||
String get stationActionShare => 'Поделиться станцией';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => 'Открыть в другом приложении';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError =>
|
||||
'На этом устройстве нет приложения, способного воспроизвести эту запись.';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => 'Удалить';
|
||||
@@ -1884,4 +1891,92 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => 'Премиум активен';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => 'Разблокировать PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios =>
|
||||
'Никакой рекламы во всём приложении';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => 'Запись радиостанций';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => 'Периоды отпуска для будильников';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas =>
|
||||
'Неограниченное количество будильников (бесплатный план позволяет до 5)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico =>
|
||||
'Единоразовая покупка, навсегда. Это не подписка.';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Не сейчас';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Мы не нашли такую станцию. Попробуйте другое название.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Слушать';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Избранное';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Все станции';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Мои станции';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Локальная музыка';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Откройте PluriWave на телефоне, чтобы прочитать вашу музыку';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Ещё…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Сортировать по качеству';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Воспроизвести папку';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Случайное воспроизведение';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Трек без названия';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice =>
|
||||
'Записи предназначены только для личного использования. Уважайте права станций и их контента.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => 'Реальная звуковая волна';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle =>
|
||||
'Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => 'Включить';
|
||||
}
|
||||
|
||||
@@ -443,7 +443,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get recordingActionRename => '重命名';
|
||||
|
||||
@override
|
||||
String get recordingActionShare => '分享';
|
||||
String get stationActionShare => '分享电台';
|
||||
|
||||
@override
|
||||
String get recordingActionOpenIn => '用其他应用打开';
|
||||
|
||||
@override
|
||||
String get recordingOpenNoAppError => '此设备上没有可播放该录音的应用。';
|
||||
|
||||
@override
|
||||
String get recordingActionDelete => '删除';
|
||||
@@ -1797,4 +1803,85 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumActivo => '高级版已解锁';
|
||||
|
||||
@override
|
||||
String get premiumHojaTitulo => '解锁 PluriWave Premium';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioSinAnuncios => '全应用无广告';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAndroidAuto =>
|
||||
'在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioGrabacion => '电台录音';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioVacaciones => '闹钟的假期时间段';
|
||||
|
||||
@override
|
||||
String get premiumBeneficioAlarmasIlimitadas => '无限闹钟(免费版最多支持5个)';
|
||||
|
||||
@override
|
||||
String get premiumPagoUnico => '一次性付费,永久使用,不是订阅。';
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => '以后再说';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados => '没有找到该电台。请换个名称再试。';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => '收听';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => '收藏';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => '全部电台';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => '我的电台';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => '本地音乐';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible => '请在手机上打开 PluriWave 以读取您的音乐';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => '更多…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => '按音质排序';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => '播放文件夹';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => '随机播放';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => '未命名曲目';
|
||||
|
||||
@override
|
||||
String get recordingsPrivateUseNotice => '录音仅供你个人使用。请尊重各电台及其内容的权利。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveTitle => '真实音频波形';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveSubtitle => '音条会跟随正在播放的声音。关闭时,音条会自行跳动。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWavePermissionExplanation =>
|
||||
'Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。';
|
||||
|
||||
@override
|
||||
String get visualizerRealWaveEnableAction => '开启';
|
||||
}
|
||||
|
||||
+140
-8
@@ -10,13 +10,17 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app.dart';
|
||||
import 'estado/estado_entitlement.dart';
|
||||
import 'servicios/arranque_audio.dart';
|
||||
import 'servicios/contexto_reproduccion.dart';
|
||||
import 'servicios/emisoras_destacadas.dart';
|
||||
import 'servicios/musica_local_auto.dart';
|
||||
import 'servicios/navegacion_auto.dart';
|
||||
import 'servicios/servicio_audio.dart';
|
||||
import 'servicios/servicio_audio_session.dart';
|
||||
import 'servicios/servicio_compras.dart';
|
||||
import 'servicios/servicio_consentimiento.dart';
|
||||
import 'servicios/servicio_ecualizador.dart';
|
||||
import 'servicios/servicio_presets_personalizados.dart';
|
||||
import 'servicios/verificacion_licencia.dart';
|
||||
import 'tema/pluriwave_tokens.dart';
|
||||
|
||||
const _anchoMinimoLandscape = 600.0;
|
||||
@@ -88,7 +92,7 @@ Future<void> main() async {
|
||||
//
|
||||
// Regression this fixes, self-inflicted by the reordering above: the root
|
||||
// menu decides whether to offer "Música Local" with
|
||||
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
|
||||
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
|
||||
// Moving ONLY the station source above the awaits meant the car could get
|
||||
// a root response in the window before this line ran, find a null source,
|
||||
// and be told there is no local music — and Android Auto caches the browse
|
||||
@@ -103,7 +107,7 @@ Future<void> main() async {
|
||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
|
||||
|
||||
// Cosmetic, and deliberately NOT awaited: a display preference must never
|
||||
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
|
||||
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
|
||||
// it again as soon as a real view exists, which is the only moment it can
|
||||
// actually take effect anyway.
|
||||
unawaited(aplicarPoliticaOrientacion());
|
||||
@@ -143,6 +147,16 @@ Future<void> main() async {
|
||||
// injected into every state/service below.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Silent license re-verification (refund revocation) for the Android Auto
|
||||
// path: this engine may be the headless one, with no widget tree and so no
|
||||
// `EstadoEntitlement`. The browse root triggers it fire-and-forget; the
|
||||
// shared in-flight guard in `verificarLicencia` keeps it to one query at a
|
||||
// time even when the phone UI checks too.
|
||||
registrarVerificacionLicenciaAuto(
|
||||
() =>
|
||||
verificarLicencia(consultar: compras.consultarPropiedad, prefs: prefs),
|
||||
);
|
||||
|
||||
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||
// injectable-prefs DI convention and same pre-init placement as the two
|
||||
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||
@@ -151,6 +165,18 @@ Future<void> main() async {
|
||||
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
|
||||
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
|
||||
|
||||
// eq-estado-unico items A/B: the handler's own link to the equalizer's
|
||||
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
|
||||
// `prefs` instance resolved just above — no widget tree, no Provider — so
|
||||
// it is available on EVERY engine, including the headless one Android Auto
|
||||
// starts. Before this, the persisted value only reached the handler
|
||||
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
|
||||
// runs: the handler played with the equalizer forced on while disk and the
|
||||
// phone UI both said off, and a toggle made in the car was lost on
|
||||
// restart. Passed as two narrow function ports, mirroring the
|
||||
// read-function convention used for the preset folder right above.
|
||||
final ecualizador = ServicioEcualizador(prefs: prefs);
|
||||
|
||||
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
|
||||
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
|
||||
// drops what it is given — so every exception `audio_service` catches
|
||||
@@ -179,7 +205,33 @@ Future<void> main() async {
|
||||
// radio; headphones unplugged pauses it. Shared by both the on-time and
|
||||
// degraded/late-completion paths below.
|
||||
void conectarHandler(PluriWaveAudioHandler handler) {
|
||||
registrarHandler(handler);
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerEqActivoPersistido: ecualizador.leerActivo,
|
||||
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
||||
// The PRESET's half of the same seam. Without it the handler enabled
|
||||
// the equalizer with its hardcoded FLAT preset on any engine where the
|
||||
// phone UI never ran — i.e. every headless Android Auto bind. There is
|
||||
// no write port: `EstadoEcualizador` still owns saving presets (a car
|
||||
// preset choice goes through it), so the handler only ever reads.
|
||||
leerPresetPersistido: ecualizador.leerPresetPrincipal,
|
||||
// Skip context («in which list am I»). Bound here, on the audio
|
||||
// bootstrap path of EVERY engine, precisely because the headless
|
||||
// Android Auto engine builds no widget tree and therefore no
|
||||
// `EstadoRadio`: a context only the phone UI could write would be a
|
||||
// context the car could never have.
|
||||
leerContextoSalto: contextoSaltoPersistido,
|
||||
guardarContextoSalto: guardarContextoSalto,
|
||||
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
|
||||
// reason as the skip context: `EstadoRadio` — which used to be its only
|
||||
// writer — belongs to the widget tree, and the Android Auto engine
|
||||
// builds none, so a session that happened only in the car never updated
|
||||
// the key and the head unit was offered whatever the PHONE last played.
|
||||
// The write port is now the key's single writer; the read port feeds the
|
||||
// cold-start metadata seed and the bare-`play()` resume.
|
||||
leerUltimaEmisora: ultimaEmisoraPersistida,
|
||||
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
|
||||
);
|
||||
// The handler is the only thing this app ever tears down
|
||||
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
||||
// with it and can never leak — same "register from main.dart" convention
|
||||
@@ -189,7 +241,7 @@ Future<void> main() async {
|
||||
unawaited(sesionAudio.configurar());
|
||||
}
|
||||
|
||||
Widget construirApp() => _OrientacionResponsiveApp(
|
||||
Widget construirApp() => OrientacionResponsiveApp(
|
||||
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
|
||||
);
|
||||
|
||||
@@ -268,20 +320,79 @@ Future<void> aplicarPoliticaOrientacion({
|
||||
}
|
||||
}
|
||||
|
||||
class _OrientacionResponsiveApp extends StatefulWidget {
|
||||
const _OrientacionResponsiveApp({required this.child});
|
||||
/// Whether the Android Auto browse tree must be invalidated right now
|
||||
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
|
||||
///
|
||||
/// The trigger used to be `View.maybeOf(context) != null` inside
|
||||
/// `didChangeDependencies`, latched once, on the premise that «a View means
|
||||
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
|
||||
/// wraps the tree in a `View` built from
|
||||
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
|
||||
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
|
||||
/// on the headless `audio_service` engine — which demonstrably reaches
|
||||
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
|
||||
/// the first `didChangeDependencies`. The one-shot latch was spent at the
|
||||
/// exact moment it could accomplish nothing (`_childrenSubjects` still
|
||||
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
|
||||
/// fire again, because `didChangeDependencies` does not re-run when an
|
||||
/// Activity later attaches to that same cached engine.
|
||||
///
|
||||
/// Two conditions replace it, both required:
|
||||
///
|
||||
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
|
||||
/// means «an Activity is attached and in the foreground». It reaches Dart
|
||||
/// exclusively through `SystemChannels.lifecycle` (or
|
||||
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
|
||||
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
|
||||
/// it, driven by the Activity's own `onResume`.
|
||||
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
|
||||
/// APPLICATION context and runs the Dart entrypoint immediately, with no
|
||||
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
|
||||
/// it on the headless engine.
|
||||
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
|
||||
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
|
||||
/// worth spending, and it is also the belt to `resumed`'s braces: even if
|
||||
/// a lifecycle event did somehow arrive during a headless cold start,
|
||||
/// nothing has subscribed yet, so the latch survives for the moment an
|
||||
/// Activity really does attach.
|
||||
///
|
||||
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
|
||||
/// not send twenty `notifyChildrenChanged` storms to the car.
|
||||
///
|
||||
/// Pure, so the whole policy is testable without an engine.
|
||||
@visibleForTesting
|
||||
bool debeInvalidarArbolAutoAlReanudar({
|
||||
required AppLifecycleState estado,
|
||||
required bool hayCocheSuscrito,
|
||||
required bool yaInvalidado,
|
||||
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
||||
|
||||
/// Root wrapper that keeps the orientation policy applied and owns the
|
||||
/// Android Auto browse-tree recovery hook.
|
||||
///
|
||||
/// Public only so a test can mount it and drive real lifecycle events
|
||||
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
|
||||
/// trigger shipped broken precisely because nothing could reach it.
|
||||
@visibleForTesting
|
||||
class OrientacionResponsiveApp extends StatefulWidget {
|
||||
const OrientacionResponsiveApp({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_OrientacionResponsiveApp> createState() =>
|
||||
State<OrientacionResponsiveApp> createState() =>
|
||||
_OrientacionResponsiveAppState();
|
||||
}
|
||||
|
||||
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
|
||||
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
|
||||
with WidgetsBindingObserver {
|
||||
ui.Display? _display;
|
||||
|
||||
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
|
||||
/// coche se dispara UNA sola vez. Ver
|
||||
/// [debeInvalidarArbolAutoAlReanudar].
|
||||
bool _arbolAutoInvalidado = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -295,6 +406,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
|
||||
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||
}
|
||||
|
||||
/// `resumed` es lo único que significa de verdad «ya hay Activity
|
||||
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
|
||||
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
|
||||
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
|
||||
/// resolver la música local; Android Auto no vuelve a preguntar por su
|
||||
/// cuenta, así que se lo decimos aquí. Ver
|
||||
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (!debeInvalidarArbolAutoAlReanudar(
|
||||
estado: state,
|
||||
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
|
||||
yaInvalidado: _arbolAutoInvalidado,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
_arbolAutoInvalidado = true;
|
||||
invalidarArbolAuto();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||
|
||||
@@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
|
||||
import '../../estado/estado_alarmas.dart';
|
||||
import '../../estado/estado_radio.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// Applies a successfully-parsed backup to BOTH independent notifiers that
|
||||
/// own pieces of it (fix/import-alarmas-y-paywall).
|
||||
///
|
||||
/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON
|
||||
/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate
|
||||
/// long-lived `ChangeNotifier` that loaded its alarms into memory at
|
||||
/// construction and never re-reads storage on its own — `EstadoRadio` stays
|
||||
/// deliberately free of a dependency on it. Without the two calls below the
|
||||
/// imported block is invisible to the running app: the UI keeps showing the
|
||||
/// pre-import alarms, a later edit would persist that stale in-memory list
|
||||
/// OVER the imported one, and the imported alarms would never be
|
||||
/// (re)scheduled with the Android native layer even after a restart.
|
||||
///
|
||||
/// Extracted as a top-level function (rather than inlined in `_importar`)
|
||||
/// so this exact production sequence — not a reimplementation of it — is
|
||||
/// directly unit-testable without depending on the `file_picker` platform
|
||||
/// channel or the confirmation dialog.
|
||||
Future<void> aplicarImportacionConfig(
|
||||
EstadoRadio estado,
|
||||
EstadoAlarmas alarmas,
|
||||
Map<String, dynamic> json,
|
||||
) async {
|
||||
await estado.importarConfig(json);
|
||||
// Re-reads from storage — clears ServicioAlarmas' in-memory cache so the
|
||||
// just-imported alarms/vacations/exceptions (same JSON block, same
|
||||
// notifier) replace the stale ones.
|
||||
await alarmas.cargarPersistidasSinRecalcular();
|
||||
// Recomputes next-run times against the (now fresh) imported data and
|
||||
// re-syncs every alarm with the Android native scheduler.
|
||||
await alarmas.refrescarProgramacion();
|
||||
}
|
||||
|
||||
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
|
||||
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
|
||||
/// only the panel header's icon and title were removed (the pushed screen's
|
||||
@@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget {
|
||||
if (confirmar != true) return;
|
||||
if (context.mounted) {
|
||||
final estado = context.read<EstadoRadio>();
|
||||
final alarmas = context.read<EstadoAlarmas>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await estado.importarConfig(json);
|
||||
await aplicarImportacionConfig(estado, alarmas, json);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportSuccess)),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../servicios/musica_local_auto.dart';
|
||||
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
@@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
|
||||
final uri = await _fuente.elegirCarpeta();
|
||||
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
|
||||
// picker's own "nothing changed" affordance.
|
||||
|
||||
// fix/android-auto-musica-local, item 4: acaba de aparecer música
|
||||
// local donde antes no había. Android Auto cachea la raíz y no
|
||||
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
|
||||
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
|
||||
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
|
||||
// el árbol del coche no depende de que esta pantalla siga viva.
|
||||
invalidarArbolAuto();
|
||||
|
||||
if (!context.mounted) return;
|
||||
setState(() {
|
||||
_carpetaActual = Future.value(uri);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../estado/estado_visualizador.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../widgets/pluri_glass_surface.dart';
|
||||
import '../../widgets/pluri_layout.dart';
|
||||
import '../../widgets/pluri_push_scaffold.dart';
|
||||
|
||||
/// AUDIO group · "Onda real del audio" — the point-of-intent opt-in for the
|
||||
/// waveform visualizer's microphone permission.
|
||||
///
|
||||
/// Before this screen existed, `RECORD_AUDIO` was requested the moment
|
||||
/// `VisualizadorAudio` subscribed to its native EventChannel, which the home
|
||||
/// screen's "Escuchar" hero does on the user's FIRST play. A radio app that
|
||||
/// pops "allow PluriWave to record audio?" the first time you press play is
|
||||
/// asking for a sensitive permission with zero context, and Play expects
|
||||
/// context.
|
||||
///
|
||||
/// Mirrors `PantallaAjustesSalidaAudio`'s point-of-intent shape (its
|
||||
/// BLUETOOTH_CONNECT request sits behind the multi-device toggle the same
|
||||
/// way), with one addition: the explanation is shown and accepted BEFORE the
|
||||
/// flag flips, since flipping it is what triggers the system dialog.
|
||||
class PantallaAjustesVisualizador extends StatelessWidget {
|
||||
const PantallaAjustesVisualizador({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return PluriPushScaffold(
|
||||
title: l10n.visualizerRealWaveTitle,
|
||||
body: ListView(
|
||||
padding: PluriLayout.pageContentPadding,
|
||||
children: const [_CuerpoVisualizador()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CuerpoVisualizador extends StatelessWidget {
|
||||
const _CuerpoVisualizador();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final estado = context.watch<EstadoVisualizador>();
|
||||
final habilitada = estado.ondaRealHabilitada;
|
||||
|
||||
return PluriGlassSurface(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// GestureDetector + custom row instead of SwitchListTile, for the
|
||||
// same reason PantallaAjustesSalidaAudio does it: ListTile ink
|
||||
// inside PluriGlassSurface's DecoratedBox trips a Material
|
||||
// assertion.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => _alternar(context, estado, !habilitada),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.visualizerRealWaveTitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.visualizerRealWaveSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: habilitada,
|
||||
onChanged: (valor) => _alternar(context, estado, valor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// The same text the confirmation dialog shows, kept permanently on
|
||||
// screen: a user who already granted the permission should be able
|
||||
// to re-read what it is for without toggling anything.
|
||||
Text(
|
||||
l10n.visualizerRealWavePermissionExplanation,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Turning it OFF is immediate — withdrawing a permission must never be
|
||||
/// harder than granting it. Turning it ON goes through the explanation
|
||||
/// first, and only a deliberate confirmation flips the flag.
|
||||
Future<void> _alternar(
|
||||
BuildContext context,
|
||||
EstadoVisualizador estado,
|
||||
bool habilitada,
|
||||
) async {
|
||||
if (!habilitada) {
|
||||
await estado.cambiarOndaReal(false);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmado = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
key: const ValueKey('visualizador-explicacion-permiso'),
|
||||
title: Text(l10n.visualizerRealWaveTitle),
|
||||
content: Text(l10n.visualizerRealWavePermissionExplanation),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l10n.visualizerRealWaveEnableAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmado != true) return;
|
||||
await estado.cambiarOndaReal(true);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_entitlement.dart';
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../estado/estado_visualizador.dart';
|
||||
import '../estado/estado_idioma.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
@@ -27,6 +28,7 @@ import 'ajustes/pantalla_ajustes_musica_local.dart';
|
||||
import 'ajustes/pantalla_ajustes_orden_listas.dart';
|
||||
import 'ajustes/pantalla_ajustes_salida_audio.dart';
|
||||
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
|
||||
import 'ajustes/pantalla_ajustes_visualizador.dart';
|
||||
import 'ajustes/widgets/fila_ajuste.dart';
|
||||
import 'pantalla_grabaciones.dart';
|
||||
|
||||
@@ -104,6 +106,9 @@ class _AjustesContent extends StatelessWidget {
|
||||
final esPremium = context.select<EstadoEntitlement, bool>(
|
||||
(e) => e.esPremium,
|
||||
);
|
||||
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
|
||||
(e) => e.ondaRealHabilitada,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -137,6 +142,22 @@ class _AjustesContent extends StatelessWidget {
|
||||
(_) => const PantallaAjustesSalidaAudio(),
|
||||
),
|
||||
),
|
||||
// Point-of-intent entry for the waveform visualizer's
|
||||
// microphone opt-in. It is a nav row, not an inline switch,
|
||||
// because the Settings root carries zero inline controls by
|
||||
// design — the switch and its explanation live on the detail
|
||||
// screen, which is also where the user reads what the
|
||||
// permission is for before granting it.
|
||||
FilaAjuste(
|
||||
icon: Icons.graphic_eq_rounded,
|
||||
titulo: l10n.visualizerRealWaveTitle,
|
||||
valor: ondaRealActiva ? l10n.equalizerActive : null,
|
||||
onTap:
|
||||
() => PluriPushScaffold.push(
|
||||
context,
|
||||
(_) => const PantallaAjustesVisualizador(),
|
||||
),
|
||||
),
|
||||
FilaAjuste(
|
||||
icon: Icons.bedtime_rounded,
|
||||
titulo: l10n.timerSectionTitle,
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart' show Share, XFile;
|
||||
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
@@ -90,19 +89,24 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
|
||||
|
||||
/// WU15: the recordings library — storage usage, browsable rows with
|
||||
/// inline playback, and a "⋮" menu constrained to exactly
|
||||
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
|
||||
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
||||
/// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
|
||||
/// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
|
||||
/// SETTINGS screen, not this browsable file list.
|
||||
class PantallaGrabaciones extends StatefulWidget {
|
||||
const PantallaGrabaciones({
|
||||
super.key,
|
||||
ReproductorGrabaciones? reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
Future<bool> Function(String ruta)? abrirEnOtraApp,
|
||||
}) : _reproductorInyectado = reproductor,
|
||||
_compartirInyectado = compartir;
|
||||
_abrirEnOtraAppInyectada = abrirEnOtraApp;
|
||||
|
||||
final ReproductorGrabaciones? _reproductorInyectado;
|
||||
final Future<void> Function(String ruta)? _compartirInyectado;
|
||||
|
||||
/// Seam for the local-open action. Was `compartir`, which handed the audio
|
||||
/// file to the system share sheet — redistribution of someone else's
|
||||
/// broadcast. It now opens the file in a player already installed on THIS
|
||||
/// device, and returns whether any app accepted it.
|
||||
final Future<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
|
||||
|
||||
@override
|
||||
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
|
||||
@@ -111,8 +115,9 @@ class PantallaGrabaciones extends StatefulWidget {
|
||||
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
late final ReproductorGrabaciones _reproductor =
|
||||
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
|
||||
late final Future<void> Function(String ruta) _compartir =
|
||||
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
|
||||
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
|
||||
widget._abrirEnOtraAppInyectada ??
|
||||
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
|
||||
|
||||
late Future<List<ArchivoGrabacion>> _grabaciones;
|
||||
final Map<String, Future<Duration?>> _duracionCache = {};
|
||||
@@ -153,8 +158,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
await _renombrar(archivo);
|
||||
return;
|
||||
}
|
||||
if (accion == 'share') {
|
||||
await _compartir(archivo.ruta);
|
||||
if (accion == 'open') {
|
||||
await _abrirLocalmente(archivo);
|
||||
return;
|
||||
}
|
||||
if (accion == 'delete') {
|
||||
@@ -162,6 +167,20 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the user's own recording in another app on the same device. A
|
||||
/// device with no audio viewer installed (and the native side's own
|
||||
/// fallback to the containing folder failing too) returns `false` — the
|
||||
/// action then says so instead of looking like a dead menu entry.
|
||||
Future<void> _abrirLocalmente(ArchivoGrabacion archivo) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final abierto = await _abrirEnOtraApp(archivo.ruta);
|
||||
if (!mounted || abierto) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.recordingOpenNoAppError)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _renombrar(ArchivoGrabacion archivo) async {
|
||||
final nuevoNombre = await showDialog<String>(
|
||||
context: context,
|
||||
@@ -286,6 +305,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Production-readiness pass: recording a broadcast holds up as
|
||||
// a private copy, and stops holding up the moment the product
|
||||
// reads as a redistribution tool. The library had no such
|
||||
// statement at all, while the manifest already publishes the
|
||||
// recordings folder to the system file manager
|
||||
// (RecordingsDocumentsProvider). Deliberately factual and
|
||||
// low-key — a footnote, not a warning banner — and always
|
||||
// visible, empty library included.
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
key: const ValueKey('grabaciones-aviso-uso-privado'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
l10n.recordingsPrivateUseNotice,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -373,7 +413,7 @@ class _FilaGrabacion extends StatelessWidget {
|
||||
// 44x44/radius-12 art placeholder (recordings carry no per-station
|
||||
// favicon, so this is a themed fallback square, not invented artwork),
|
||||
// name, meta line, a 24px play/pause affordance, and the SAME "-"
|
||||
// menu (Rename/Share/Delete) as before, just restyled.
|
||||
// menu (Rename/Open in another app/Delete) as before, just restyled.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
||||
child: Row(
|
||||
@@ -466,8 +506,8 @@ class _FilaGrabacion extends StatelessWidget {
|
||||
child: Text(l10n.recordingActionRename),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Text(l10n.recordingActionShare),
|
||||
value: 'open',
|
||||
child: Text(l10n.recordingActionOpenIn),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:shimmer/shimmer.dart' as shimmer;
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_navegacion.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../estado/estado_visualizador.dart';
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
@@ -471,6 +472,14 @@ class _EscucharHero extends StatelessWidget {
|
||||
// Audit 1.7 (t4 lines 66-68): 30 discrete
|
||||
// bottom-anchored bars, not a continuous stroke.
|
||||
barrasDiscretas: true,
|
||||
// Sensitive-permission gate: subscribing to the
|
||||
// native waveform channel is what makes Android ask
|
||||
// for RECORD_AUDIO, so it happens only after the
|
||||
// user opts in from Settings.
|
||||
capturaRealHabilitada:
|
||||
context
|
||||
.watch<EstadoVisualizador>()
|
||||
.ondaRealHabilitada,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:shimmer/shimmer.dart';
|
||||
import '../estado/estado_ecualizador.dart';
|
||||
import '../estado/estado_grabacion.dart';
|
||||
import '../estado/estado_radio.dart';
|
||||
import '../estado/estado_visualizador.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
@@ -185,6 +186,10 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
|
||||
color: tokens.warmCoral,
|
||||
altura: 40,
|
||||
barrasDiscretas: true,
|
||||
// Sensitive-permission gate: see the same note on the
|
||||
// Escuchar hero's visualizer in `pantalla_inicio.dart`.
|
||||
capturaRealHabilitada:
|
||||
context.watch<EstadoVisualizador>().ondaRealHabilitada,
|
||||
gradienteFinAlpha: 0.45,
|
||||
).pluriFadeIn(
|
||||
context,
|
||||
@@ -1133,7 +1138,11 @@ class _BandejaHerramientas extends StatelessWidget {
|
||||
child: _TileHerramienta(
|
||||
key: const Key('player-tool-share'),
|
||||
icon: Icons.share_rounded,
|
||||
label: l10n.recordingActionShare,
|
||||
// Shares the STATION — its name and its stream url — never an
|
||||
// audio file. This used to borrow `recordingActionShare`, the
|
||||
// recordings library's own menu label, which made one key stand
|
||||
// for two unrelated actions.
|
||||
label: l10n.stationActionShare,
|
||||
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,11 +5,17 @@ import 'package:flutter/material.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
|
||||
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
|
||||
/// "Timeout without re-init"): the vendored `audio_service` plugin's
|
||||
/// self-bind has no native timeout and an unhandled `onConnectionSuspended`
|
||||
/// case, so under bind contention (Android Auto cold start) the handshake
|
||||
/// can hang forever. Top-level const so tests can reference the production
|
||||
/// value without duplicating it.
|
||||
/// "Timeout without re-init"): the `audio_service` plugin's self-bind has no
|
||||
/// native timeout and an unhandled `onConnectionSuspended` case, so under
|
||||
/// bind contention (Android Auto cold start) the handshake can hang forever.
|
||||
/// Top-level const so tests can reference the production value without
|
||||
/// duplicating it.
|
||||
///
|
||||
/// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the
|
||||
/// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no
|
||||
/// `dependency_overrides`. Anyone reading the sentence above would go looking
|
||||
/// for a local copy to patch, and there is none — the behaviour described is
|
||||
/// upstream's, so the workaround has to live here.
|
||||
const timeoutArranqueAudio = Duration(seconds: 8);
|
||||
|
||||
/// Outcome of racing an `AudioService.init` future against
|
||||
|
||||
@@ -6,8 +6,10 @@ import '../modelos/pista_local.dart';
|
||||
/// instance rather than mutating in place, mirroring how
|
||||
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
|
||||
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
|
||||
/// the handler (which cannot be instantiated in unit tests — see this
|
||||
/// module's sibling test file's doc comment).
|
||||
/// the handler. (That last clause used to read "which cannot be instantiated
|
||||
/// in unit tests"; it can — see `construirControlesTransporte`'s doc in
|
||||
/// `servicio_audio.dart`. Keeping the queue logic out of the handler is
|
||||
/// still worth it, but for design reasons, not for that one.)
|
||||
class ColaLocal {
|
||||
const ColaLocal({required this.pistas, this.indice = 0});
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
|
||||
/// The skip context's persistence key.
|
||||
///
|
||||
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
|
||||
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
|
||||
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
|
||||
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
|
||||
/// never constructed there — a context only that class could write would be a
|
||||
/// context the car can never have.
|
||||
///
|
||||
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
|
||||
/// instead of silently leaving every driver context-less after an update.
|
||||
const claveContextoSalto = 'contexto_salto_v1';
|
||||
|
||||
/// Which LIST the driver is walking with the car's previous/next buttons.
|
||||
///
|
||||
/// The type is the durable part; the members are not. A group's contents
|
||||
/// change between sessions (the phone renames it, empties it, deletes it), so
|
||||
/// remembering the members would be remembering something that expires —
|
||||
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
|
||||
enum TipoContextoSalto {
|
||||
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
|
||||
grupoFavoritos,
|
||||
favoritos,
|
||||
misEmisoras,
|
||||
|
||||
/// The `populares` catalogue snapshot.
|
||||
todas,
|
||||
|
||||
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
|
||||
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
|
||||
destacadas,
|
||||
}
|
||||
|
||||
/// The remembered playback context: the smallest thing that still identifies
|
||||
/// the list on the other side of a process restart.
|
||||
class ContextoSalto {
|
||||
/// One favourites group, named by its stable id.
|
||||
const ContextoSalto.grupo(String grupoId)
|
||||
: tipo = TipoContextoSalto.grupoFavoritos,
|
||||
grupoFavoritosId = grupoId,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.favoritos()
|
||||
: tipo = TipoContextoSalto.favoritos,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.misEmisoras()
|
||||
: tipo = TipoContextoSalto.misEmisoras,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.todas()
|
||||
: tipo = TipoContextoSalto.todas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
/// The free tier's set, FROZEN in [uuids] order.
|
||||
const ContextoSalto.destacadas(List<String> uuids)
|
||||
: tipo = TipoContextoSalto.destacadas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = uuids;
|
||||
|
||||
final TipoContextoSalto tipo;
|
||||
|
||||
/// Set only for [TipoContextoSalto.grupoFavoritos].
|
||||
final String? grupoFavoritosId;
|
||||
|
||||
/// The frozen order, set only for [TipoContextoSalto.destacadas].
|
||||
///
|
||||
/// Every other type resolves against a list that HAS a stable, user-owned
|
||||
/// order (the favourites' `orden` column, the custom-stations file, the
|
||||
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
|
||||
/// the user just made on the phone. The free set is the exception:
|
||||
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
|
||||
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
|
||||
/// stops being the inverse of `next`. Freezing that order is the fix.
|
||||
final List<String> uuidsOrdenados;
|
||||
|
||||
Map<String, dynamic> aMapa() => {
|
||||
'tipo': tipo.name,
|
||||
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
|
||||
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
|
||||
};
|
||||
|
||||
/// Parses a persisted map, or `null` when it is unusable.
|
||||
///
|
||||
/// Tolerant on purpose: this payload survives app updates, backups and
|
||||
/// hand-edited preference files, and it is read from a steering-wheel
|
||||
/// button. An unreadable context must mean "derive it again", never a
|
||||
/// crash.
|
||||
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
|
||||
final tipoRaw = mapa['tipo'];
|
||||
if (tipoRaw is! String) return null;
|
||||
final tipo = TipoContextoSalto.values
|
||||
.where((t) => t.name == tipoRaw)
|
||||
.firstOrNull;
|
||||
if (tipo == null) return null;
|
||||
switch (tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = mapa['grupoId'];
|
||||
// A group context with no group is not a context.
|
||||
if (grupoId is! String || grupoId.isEmpty) return null;
|
||||
return ContextoSalto.grupo(grupoId);
|
||||
case TipoContextoSalto.favoritos:
|
||||
return const ContextoSalto.favoritos();
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return const ContextoSalto.misEmisoras();
|
||||
case TipoContextoSalto.todas:
|
||||
return const ContextoSalto.todas();
|
||||
case TipoContextoSalto.destacadas:
|
||||
final uuids = mapa['uuids'];
|
||||
if (uuids is! List) return null;
|
||||
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ContextoSalto &&
|
||||
other.tipo == tipo &&
|
||||
other.grupoFavoritosId == grupoFavoritosId &&
|
||||
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
tipo,
|
||||
grupoFavoritosId,
|
||||
Object.hashAll(uuidsOrdenados),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
|
||||
'uuids=${uuidsOrdenados.length})';
|
||||
|
||||
static bool _mismosUuids(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [contexto]. Never throws — a failed write costs the driver a
|
||||
/// re-derivation, an exception would cost them the station change.
|
||||
Future<void> guardarContextoSalto(
|
||||
ContextoSalto contexto, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
|
||||
} catch (_) {
|
||||
// Deliberately swallowed — see the doc above.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the persisted context, or `null` when there is none, the payload is
|
||||
/// unreadable, or prefs themselves fail.
|
||||
///
|
||||
/// Follows the same inject-or-`getInstance()` convention as
|
||||
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
|
||||
/// prefs without a platform channel.
|
||||
Future<ContextoSalto?> contextoSaltoPersistido({
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
final raw = resueltas.getString(claveContextoSalto);
|
||||
if (raw == null) return null;
|
||||
final decodificado = jsonDecode(raw);
|
||||
if (decodificado is! Map) return null;
|
||||
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
|
||||
/// empty list when it no longer resolves at all.
|
||||
///
|
||||
/// Pure — no handler, no prefs — so every degradation rule below is testable
|
||||
/// on its own. An empty result means "this memory has expired": the caller
|
||||
/// derives a fresh context instead (and, failing that, leaves playback alone —
|
||||
/// never jumps somewhere arbitrary mid-drive).
|
||||
///
|
||||
/// Degradation rules, all of them deliberate. The group chain is the owner's,
|
||||
/// decided from real use in the car:
|
||||
/// * remembered group ALIVE -> it is walked, even when the playing station
|
||||
/// has LEFT it (the caller then takes the group's first station) and even
|
||||
/// when it is down to a single member (skipping there simply leaves the
|
||||
/// driver where they are — a one-station group is still a group).
|
||||
/// * remembered group DELETED, or alive but EMPTY -> widen to all
|
||||
/// favourites, whether or not the playing station is still one of them:
|
||||
/// "if the whole group is gone, pick a station from the favourites".
|
||||
/// * no favourites left -> empty, i.e. the no-stations behaviour.
|
||||
/// * every OTHER context type still expires when the playing station left
|
||||
/// its list (unfavourited, removed from the catalogue snapshot) — the
|
||||
/// owner's decision was about the group chain only.
|
||||
/// * [TipoContextoSalto.destacadas] alone honours
|
||||
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
|
||||
List<Emisora> resolverListaContexto({
|
||||
required ContextoSalto contexto,
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
required List<Emisora> destacadas,
|
||||
required List<GrupoFavoritos> grupos,
|
||||
}) {
|
||||
bool contiene(List<Emisora> lista) =>
|
||||
lista.any((e) => e.uuid == actual.uuid);
|
||||
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = contexto.grupoFavoritosId;
|
||||
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
|
||||
return const [];
|
||||
}
|
||||
final existe = grupos.any((g) => g.id == grupoId);
|
||||
final miembros =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
|
||||
if (existe && miembros.isNotEmpty) {
|
||||
// A surviving group is honoured as-is. The station does NOT have to
|
||||
// still be in it — the caller takes the group's first station rather
|
||||
// than wandering off to another list.
|
||||
return miembros;
|
||||
}
|
||||
// Group deleted (or alive but empty, which offers no station to take):
|
||||
// widen to all favourites. Unlike the other context types this does not
|
||||
// require the station to still BE a favourite — the caller takes the
|
||||
// first one.
|
||||
return favoritos;
|
||||
case TipoContextoSalto.favoritos:
|
||||
return contiene(favoritos) ? favoritos : const [];
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return contiene(misEmisoras) ? misEmisoras : const [];
|
||||
case TipoContextoSalto.todas:
|
||||
return contiene(todas) ? todas : const [];
|
||||
case TipoContextoSalto.destacadas:
|
||||
// The frozen order is authoritative. `actual` is resolvable from itself
|
||||
// so a station frozen into the walk from a previous session still
|
||||
// resolves even when it never belonged to the curated set.
|
||||
final porUuid = <String, Emisora>{
|
||||
for (final e in destacadas) e.uuid: e,
|
||||
actual.uuid: actual,
|
||||
};
|
||||
final lista = <Emisora>[
|
||||
for (final uuid in contexto.uuidsOrdenados)
|
||||
if (porUuid[uuid] != null) porUuid[uuid]!,
|
||||
];
|
||||
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The free tier's frozen walk order: the curated set in its compiled-in
|
||||
/// order, with [actual] prepended when it does not belong to it.
|
||||
///
|
||||
/// Prepending rather than dropping keeps both buttons alive for a station left
|
||||
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
|
||||
/// playing station is not part of would make `emisoraVecina` return `null` and
|
||||
/// both buttons would be dead.
|
||||
List<String> uuidsCongeladosDestacadas({
|
||||
required Emisora actual,
|
||||
required List<Emisora> destacadas,
|
||||
}) => [
|
||||
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
|
||||
...destacadas.map((e) => e.uuid),
|
||||
];
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/emisora.dart';
|
||||
|
||||
/// The last-played station's persistence key.
|
||||
///
|
||||
/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora`
|
||||
/// (`lib/estado/estado_radio.dart`), which is the only writer. It is
|
||||
/// duplicated here rather than exported from there on purpose: this file has
|
||||
/// to be readable from the headless Android Auto engine, where `EstadoRadio`
|
||||
/// is never constructed, and importing a `ChangeNotifier` that pulls in the
|
||||
/// whole app-state graph just to read one string constant would drag the
|
||||
/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart`
|
||||
/// pins the literal so a rename on either side fails loudly.
|
||||
const claveUltimaEmisora = 'ultima_emisora_v1';
|
||||
|
||||
/// The stations a FREE-tier driver can browse and play in the car
|
||||
/// (fix/auto-quality-guidelines, item 6).
|
||||
///
|
||||
/// Compiled into the binary, on purpose. Everything else the car could show
|
||||
/// is empty on the bind a Play reviewer actually performs: a fresh install
|
||||
/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial
|
||||
/// key), `FuenteEmisorasAutoLocal.todas()` is literally
|
||||
/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network
|
||||
/// snapshot that a headless bind never fetches, favourites and custom
|
||||
/// stations are empty, and `ultima_emisora_v1` is absent. A curated const
|
||||
/// list is the ONLY thing that can put real, playable rows in front of that
|
||||
/// reviewer.
|
||||
///
|
||||
/// Deliberately small. This is not a catalogue — the catalogue is the
|
||||
/// premium feature. Six rows is enough to prove the app works and short
|
||||
/// enough to read at a glance from a driving position.
|
||||
///
|
||||
/// `favicon` is null for every entry on purpose: `artUriPara` then resolves
|
||||
/// the on-brand bundled `station_art_*` drawable, so a browse row needs no
|
||||
/// network at all to render its artwork.
|
||||
///
|
||||
/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these
|
||||
/// rows must resolve identically whether or not the catalogue is reachable,
|
||||
/// and a Radio Browser uuid we cannot re-fetch would be a promise this file
|
||||
/// cannot keep.
|
||||
const List<Emisora> emisorasDestacadas = [
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-fip',
|
||||
nombre: 'FIP',
|
||||
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-france-inter',
|
||||
nombre: 'France Inter',
|
||||
url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-deutschlandfunk',
|
||||
nombre: 'Deutschlandfunk',
|
||||
url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3',
|
||||
pais: 'Germany',
|
||||
codigoPais: 'DE',
|
||||
idioma: 'german',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-kexp',
|
||||
nombre: 'KEXP 90.3 FM',
|
||||
url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-radio-paradise',
|
||||
nombre: 'Radio Paradise',
|
||||
url: 'https://stream.radioparadise.com/mp3-128',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-soma-groove-salad',
|
||||
nombre: 'SomaFM Groove Salad',
|
||||
url: 'https://ice1.somafm.com/groovesalad-128-mp3',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
];
|
||||
|
||||
/// The free tier's complete, ordered station set: the last station the user
|
||||
/// actually played (when one is persisted) first, then [emisorasDestacadas],
|
||||
/// deduplicated by `uuid`.
|
||||
///
|
||||
/// Last-played goes first because it is the single row a returning driver is
|
||||
/// most likely to want, and because it is the only entry that can make the
|
||||
/// free folder feel like *their* app rather than a demo. It is NOT appended
|
||||
/// a second time when it already belongs to the curated set.
|
||||
///
|
||||
/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s
|
||||
/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a
|
||||
/// test can pin prefs without a platform channel.
|
||||
///
|
||||
/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a
|
||||
/// `SharedPreferences` failure, degrades to the curated set alone. This runs
|
||||
/// inside `getChildren`, and a browse call that throws is a dead folder.
|
||||
Future<List<Emisora>> resolverEmisorasDestacadas({
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
final ultima = await _ultimaEmisora(prefs: prefs);
|
||||
if (ultima == null) return emisorasDestacadas;
|
||||
return [
|
||||
ultima,
|
||||
...emisorasDestacadas.where((e) => e.uuid != ultima.uuid),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [uuid] belongs to [destacadas] — the predicate every play-path
|
||||
/// gate reads to tell "free content" from "the premium catalogue".
|
||||
///
|
||||
/// Pure, and takes the free universe rather than resolving it, so a caller
|
||||
/// that already holds the list (every one of them does — it also needs it to
|
||||
/// build the response) asks the question without a second prefs round trip.
|
||||
///
|
||||
/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a
|
||||
/// malformed id, and matching it against an entry with an empty uuid would be
|
||||
/// a resolution hole rather than a feature.
|
||||
bool esEmisoraGratuita(String? uuid, List<Emisora> destacadas) =>
|
||||
uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid);
|
||||
|
||||
/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For
|
||||
/// callers that do not already hold the list.
|
||||
Future<bool> esEmisoraGratuitaPorUuid(
|
||||
String uuid, {
|
||||
SharedPreferences? prefs,
|
||||
}) async =>
|
||||
esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs));
|
||||
|
||||
/// Reads the persisted last-played station, or `null` when there is none.
|
||||
///
|
||||
/// Public because the Android Auto "recent" browse root
|
||||
/// (`AudioService.recentRootId`) needs exactly this one station and nothing
|
||||
/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever
|
||||
/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every
|
||||
/// reconnect, and the platform expects a SINGLE resume item there — not a
|
||||
/// station list, and not an empty folder.
|
||||
///
|
||||
/// Tier-independent on purpose: this station is by definition one the user
|
||||
/// has already played on this device, so offering to resume it is never
|
||||
/// leaking premium content they have not already had.
|
||||
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
|
||||
_ultimaEmisora(prefs: prefs);
|
||||
|
||||
/// Writes [emisora] as the last-played station — the SINGLE writer of
|
||||
/// [claveUltimaEmisora].
|
||||
///
|
||||
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
|
||||
/// because the key has to be written from the engine Android Auto starts,
|
||||
/// which builds no widget tree and therefore never constructs `EstadoRadio`
|
||||
/// at all: a session that happened only in the car used to leave the key
|
||||
/// holding whatever the PHONE last played, so the head unit's resume row and
|
||||
/// the free tier's featured folder were both stale on the next connect.
|
||||
///
|
||||
/// Deliberately NOT swallowing failures here: the handler port that calls it
|
||||
/// traces and swallows (a persistence failure must never break playback),
|
||||
/// and a silent `catch` in BOTH places would make a dead write channel
|
||||
/// invisible from a car logcat.
|
||||
Future<void> guardarUltimaEmisoraPersistida(
|
||||
Emisora emisora, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
}
|
||||
|
||||
/// Reads the persisted last-played station, or `null` when there is none,
|
||||
/// the payload is unreadable, or prefs themselves fail.
|
||||
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
final raw = resueltas.getString(claveUltimaEmisora);
|
||||
if (raw == null) return null;
|
||||
final emisora = Emisora.fromMap(jsonDecode(raw) as Map<String, dynamic>);
|
||||
// A record with no uuid or no url cannot be turned into a playable
|
||||
// `emisora:<uuid>` row, so it is worse than absent: it would occupy the
|
||||
// first slot with a row that does nothing when tapped.
|
||||
if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null;
|
||||
return emisora;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
|
||||
return recortado.isEmpty ? nombreGenerico : recortado;
|
||||
}
|
||||
|
||||
/// Three-valued answer to «¿hay música local usable?»
|
||||
/// (fix/android-auto-musica-local).
|
||||
///
|
||||
/// Sustituye al `bool` anterior, que colapsaba dos causas MUY distintas en
|
||||
/// el mismo `false`:
|
||||
///
|
||||
/// * [noConfigurada] — no hay URI persistida, o el nativo respondió que el
|
||||
/// permiso ya no es válido (el usuario nunca eligió carpeta, o la
|
||||
/// revocó). Es la única respuesta que justifica ocultar el nodo.
|
||||
/// * [configurada] — hay URI persistida y el nativo confirma el permiso.
|
||||
/// * [canalNoDisponible] — hay URI persistida pero el canal
|
||||
/// `pluriwave/file_actions` NO tiene handler nativo, así que no se puede
|
||||
/// saber nada del permiso. Es lo que ocurre en el motor Flutter headless
|
||||
/// que `audio_service` levanta cuando Android Auto arranca la app sin
|
||||
/// Activity: `MainActivity.configureFlutterEngine` (único sitio donde se
|
||||
/// registra ese canal) nunca corre. NO significa «no hay carpeta».
|
||||
enum EstadoCarpetaLocal { noConfigurada, configurada, canalNoDisponible }
|
||||
|
||||
/// Browse-source abstraction for the local-music branch of the Android Auto
|
||||
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
|
||||
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
|
||||
@@ -78,9 +97,11 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
|
||||
/// browse domain, not a station source.
|
||||
abstract class FuenteMusicaLocalAuto {
|
||||
/// Whether a local-music root folder is picked AND its permission is
|
||||
/// still valid. Never throws — a revoked/never-granted permission
|
||||
/// degrades to `false` (Spec "Permission revoked or never granted").
|
||||
Future<bool> hayCarpetaConfigurada();
|
||||
/// still valid — o si esa pregunta no se puede contestar porque el canal
|
||||
/// nativo no existe en este motor. Never throws: cualquier fallo degrada
|
||||
/// a un valor de [EstadoCarpetaLocal], nunca a una excepción (Spec
|
||||
/// "Permission revoked or never granted").
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta();
|
||||
|
||||
/// Immediate children of [documentId] (`''` = the tree root itself), one
|
||||
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
|
||||
@@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hayCarpetaConfigurada() async {
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta() async {
|
||||
// Its OWN try, deliberately not merged with the channel one below.
|
||||
//
|
||||
// Never-throws restoration: the three-valued refactor moved this read
|
||||
// outside the try, and the only caller (`getChildren`'s root branch)
|
||||
// awaits it inline — so a prefs failure took the whole browse root down
|
||||
// and emptied the car, against this method's own interface doc.
|
||||
//
|
||||
// Kept SEPARATE because a prefs failure and a channel failure both
|
||||
// surface as `MissingPluginException`: one shared `on
|
||||
// MissingPluginException` clause would answer `canalNoDisponible` —
|
||||
// «hay carpeta pero no puedo comprobar el permiso» — for a store that
|
||||
// never told us whether a folder exists at all. That would put an
|
||||
// unreachable «Música Local» node in the car explaining a channel
|
||||
// problem that is not happening, which is precisely the collapse the
|
||||
// three-valued [EstadoCarpetaLocal] exists to prevent.
|
||||
//
|
||||
// `noConfigurada` is the honest answer here (the app cannot prove a
|
||||
// folder was ever picked) and is what this path returned before the
|
||||
// refactor, when the read still sat inside the catch-all below.
|
||||
final String? uri;
|
||||
try {
|
||||
uri = await _uriPersistida();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][musica_local] no se pudo leer la URI local: $e');
|
||||
return EstadoCarpetaLocal.noConfigurada;
|
||||
}
|
||||
if (uri == null || uri.isEmpty) return EstadoCarpetaLocal.noConfigurada;
|
||||
try {
|
||||
final uri = await _uriPersistida();
|
||||
if (uri == null || uri.isEmpty) return false;
|
||||
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
|
||||
'treeUri': uri,
|
||||
});
|
||||
return valido ?? false;
|
||||
} catch (_) {
|
||||
return valido == true
|
||||
? EstadoCarpetaLocal.configurada
|
||||
: EstadoCarpetaLocal.noConfigurada;
|
||||
} on MissingPluginException catch (e) {
|
||||
// El canal no tiene handler en ESTE motor. Antes esto caía en el
|
||||
// mismo `catch (_)` que un permiso revocado y devolvía `false`, que
|
||||
// es exactamente por lo que «Música Local» desaparecía del árbol de
|
||||
// Android Auto cuando el coche arrancaba la app sin Activity.
|
||||
debugPrint(
|
||||
'[PluriWave][musica_local] hasPersistedPermission sin handler '
|
||||
'nativo (motor sin Activity): $e',
|
||||
);
|
||||
return EstadoCarpetaLocal.canalNoDisponible;
|
||||
} catch (e) {
|
||||
// Cold-start / revoked-permission safety (Spec "Permission revoked or
|
||||
// never granted"): never throw, degrade to "not configured".
|
||||
return false;
|
||||
debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e');
|
||||
return EstadoCarpetaLocal.noConfigurada;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'contexto_reproduccion.dart';
|
||||
import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
@@ -200,10 +202,97 @@ abstract class FuenteEmisorasAuto {
|
||||
}) {}
|
||||
}
|
||||
|
||||
/// Every user-readable label of the Android Auto browse tree, already
|
||||
/// resolved to one locale by the caller.
|
||||
///
|
||||
/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can
|
||||
/// read gets translated. This bundle replaces the previous
|
||||
/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key"
|
||||
/// convention, which was defensible only while those labels sat deep inside
|
||||
/// a premium tree and stopped being defensible the moment Google Play
|
||||
/// reviewed the car surface on an English head unit.
|
||||
///
|
||||
/// It exists as a plain value object rather than an `AppLocalizations`
|
||||
/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same
|
||||
/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler,
|
||||
/// which can resolve localizations headlessly through
|
||||
/// `resolverLocalizacionesRespaldo`, builds one via
|
||||
/// `etiquetasArbolAutoDesde` and hands it in.
|
||||
///
|
||||
/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`,
|
||||
/// …). Those are ranges of Latin letters, not prose — translating them would
|
||||
/// make them lie about which filenames they contain.
|
||||
class EtiquetasArbolAuto {
|
||||
const EtiquetasArbolAuto({
|
||||
required this.escuchar,
|
||||
required this.favoritos,
|
||||
required this.todasLasEmisoras,
|
||||
required this.misEmisoras,
|
||||
required this.musicaLocal,
|
||||
required this.musicaLocalNoDisponible,
|
||||
required this.cargarMas,
|
||||
required this.ordenarPorCalidad,
|
||||
required this.reproducirCarpeta,
|
||||
required this.reproducirAleatorio,
|
||||
required this.pistaSinNombre,
|
||||
});
|
||||
|
||||
/// Fallback bundle for callers that have no localizations to hand: pure
|
||||
/// builder tests, and any future non-car consumer.
|
||||
///
|
||||
/// It is NOT what the car shows. `ServicioAudio` always injects a bundle
|
||||
/// resolved from `AppLocalizations`, in every browse and playback path
|
||||
/// that can produce a label — `etiquetas_arbol_auto_test.dart` is the
|
||||
/// guard that no NEW hardcoded label can be introduced alongside these.
|
||||
static const respaldo = EtiquetasArbolAuto(
|
||||
escuchar: 'Escuchar',
|
||||
favoritos: 'Favoritos',
|
||||
todasLasEmisoras: 'Todas las emisoras',
|
||||
misEmisoras: 'Mis emisoras',
|
||||
musicaLocal: 'Música Local',
|
||||
musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música',
|
||||
cargarMas: 'Más…',
|
||||
ordenarPorCalidad: 'Ordenar por calidad',
|
||||
reproducirCarpeta: 'Reproducir carpeta',
|
||||
reproducirAleatorio: 'Reproducir aleatorio',
|
||||
pistaSinNombre: 'Pista sin nombre',
|
||||
);
|
||||
|
||||
/// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]).
|
||||
final String escuchar;
|
||||
|
||||
/// Premium root folders.
|
||||
final String favoritos;
|
||||
final String todasLasEmisoras;
|
||||
final String misEmisoras;
|
||||
final String musicaLocal;
|
||||
|
||||
/// The non-playable row shown when the local-music folder cannot be read
|
||||
/// from the car ([ConstructorArbolAuto.idLocalNoLista]).
|
||||
final String musicaLocalNoDisponible;
|
||||
|
||||
/// Trailing "load more" row of every paged local-music view.
|
||||
final String cargarMas;
|
||||
|
||||
/// Local-folder navigation and action rows.
|
||||
final String ordenarPorCalidad;
|
||||
final String reproducirCarpeta;
|
||||
final String reproducirAleatorio;
|
||||
|
||||
/// Fallback title for a local file whose name is blank after stripping.
|
||||
final String pistaSinNombre;
|
||||
}
|
||||
|
||||
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
|
||||
/// resolution. No platform dependency — fully testable without a running
|
||||
/// car or a real `AudioHandler`.
|
||||
class ConstructorArbolAuto {
|
||||
const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo});
|
||||
|
||||
/// The already-localized labels this builder stamps onto every
|
||||
/// user-readable `MediaItem` it produces.
|
||||
final EtiquetasArbolAuto etiquetas;
|
||||
|
||||
/// Root folder ids (Design "media-id scheme"). The tree root itself is
|
||||
/// identified by [AudioService.browsableRootId], not by a constant here —
|
||||
/// the handler compares against it directly before calling [raiz].
|
||||
@@ -211,6 +300,17 @@ class ConstructorArbolAuto {
|
||||
static const idTodas = 'todas';
|
||||
static const idMisEmisoras = 'mis_emisoras';
|
||||
|
||||
/// Root folder id for the FREE tier's only browsable folder
|
||||
/// (fix/auto-quality-guidelines, item 8).
|
||||
///
|
||||
/// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and
|
||||
/// [idEcualizador] it has its own dedicated children ([hijosDestacadas]),
|
||||
/// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the
|
||||
/// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is
|
||||
/// empty on the bind a Play reviewer actually performs.
|
||||
static const idDestacadas = 'destacadas';
|
||||
|
||||
|
||||
/// Root folder id for the local-music browsable root (Design "media-id
|
||||
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
|
||||
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
|
||||
@@ -229,6 +329,17 @@ class ConstructorArbolAuto {
|
||||
/// hidden.
|
||||
static const idEcualizador = 'ecualizador';
|
||||
|
||||
/// Non-playable "no puedo leer la carpeta desde aquí" item
|
||||
/// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal]
|
||||
/// cuando el canal nativo `pluriwave/file_actions` no está disponible en
|
||||
/// este motor, así que abrir la carpeta tenía que dejar de mostrar una
|
||||
/// lista vacía: vacío se lee como «no tengo música», que es justo la
|
||||
/// conclusión equivocada. Este item dice qué pasa de verdad.
|
||||
///
|
||||
/// Colisión imposible con los prefijos `carpeta_local:` / `pista:` /
|
||||
/// `emisora:` / `grupo:` — no lleva ninguno de ellos.
|
||||
static const idLocalNoLista = 'musica_local_no_disponible';
|
||||
|
||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||
static const _maxItemsPorCarpeta = 50;
|
||||
|
||||
@@ -311,8 +422,10 @@ class ConstructorArbolAuto {
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||
};
|
||||
|
||||
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// optionally Música Local, Ecualizador), all non-playable.
|
||||
/// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos,
|
||||
/// Todas las emisoras, Mis emisoras and optionally Música Local for a
|
||||
/// premium driver; the single [idDestacadas] folder for a free one (see
|
||||
/// [premium] below).
|
||||
///
|
||||
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
||||
@@ -331,41 +444,78 @@ class ConstructorArbolAuto {
|
||||
///
|
||||
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`,
|
||||
/// keeping this builder itself synchronous and side-effect free.
|
||||
/// is configured") — the caller lo deriva de
|
||||
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
||||
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
|
||||
/// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a
|
||||
/// propósito, para que el tier gratuito ni siquiera pague ese round trip
|
||||
/// nativo), keeping this builder itself synchronous and side-effect free.
|
||||
///
|
||||
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
|
||||
/// same visible folder labels for every tier — "keeps the same visible
|
||||
/// folder labels for free users" is the explicit design choice, so a free
|
||||
/// driver still sees a real, familiar menu rather than a wall of "Función
|
||||
/// Premium" rows. The lock itself is enforced one level DOWN, at the
|
||||
/// `getChildren` choke point (see [itemPremiumBloqueado] and
|
||||
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
|
||||
/// folders as a free user reveals the lock there, never here.
|
||||
/// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
|
||||
/// to be accepted and ignored, on the theory that "the root keeps the same
|
||||
/// visible folder labels for free users" was friendlier than a reduced
|
||||
/// menu. It was not: every one of those four folders dead-ended on a single
|
||||
/// non-playable "Función Premium" row, and Google Play cited exactly that
|
||||
/// against the Android for Cars App Quality Guidelines.
|
||||
///
|
||||
/// The free root is therefore ONE browsable folder, [idDestacadas], and the
|
||||
/// premium-only folders are OMITTED rather than shown-and-blocked: a folder
|
||||
/// a driver cannot use is worse than a folder that is not there.
|
||||
///
|
||||
/// It must stay at least one BROWSABLE item, never a bare playable one:
|
||||
/// `audio_service` 0.18.18 discards `rootHints`
|
||||
/// (`AudioService.java:817-826`), so this code cannot detect whether the
|
||||
/// head unit accepts a `FLAG_PLAYABLE` root child, and the documented
|
||||
/// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is
|
||||
/// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on
|
||||
/// such a unit.
|
||||
///
|
||||
/// Every label here comes from [etiquetas], already resolved to the head
|
||||
/// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the
|
||||
/// four premium folders.
|
||||
///
|
||||
/// The four premium ones used to be hardcoded Spanish, on the theory that
|
||||
/// they were leaf rows deep inside a tree only a user who had already
|
||||
/// chosen the app would reach. That was never a rule, only an untested
|
||||
/// assumption, and it is retired: anything a user can read gets
|
||||
/// translated. `escuchar` was localized first (it is 100% of what a free
|
||||
/// Play reviewer sees), which is exactly why the rest had to follow.
|
||||
///
|
||||
/// [tituloDestacadas] stays as an explicit per-call override of
|
||||
/// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle.
|
||||
List<MediaItem> raiz({
|
||||
required bool incluirMusicaLocal,
|
||||
required bool premium,
|
||||
}) => [
|
||||
_carpeta(idFavoritos, 'Favoritos'),
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||
];
|
||||
String? tituloDestacadas,
|
||||
}) =>
|
||||
premium
|
||||
? [
|
||||
_carpeta(idFavoritos, etiquetas.favoritos),
|
||||
_carpeta(idTodas, etiquetas.todasLasEmisoras),
|
||||
_carpeta(idMisEmisoras, etiquetas.misEmisoras),
|
||||
if (incluirMusicaLocal)
|
||||
_carpeta(idMusicaLocal, etiquetas.musicaLocal),
|
||||
]
|
||||
: [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)];
|
||||
|
||||
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
|
||||
/// the single non-playable item every non-root folder collapses to for a
|
||||
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
|
||||
/// label in this file (never routed through `AppLocalizations` —
|
||||
/// established convention, see [_tituloMasLocal]'s doc).
|
||||
static const idPremiumInfo = 'premium:info';
|
||||
/// The free tier's playable station rows (fix/auto-quality-guidelines,
|
||||
/// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
|
||||
/// folders use, capped like every other folder.
|
||||
///
|
||||
/// Separate from [hijos] because that path is gated on [_idsCarpetas] and
|
||||
/// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold
|
||||
/// headless bind — which is precisely the bind this folder has to survive.
|
||||
/// An empty [emisoras] returns `[]` rather than any placeholder row: a
|
||||
/// non-playable row in the car tree is the thing Play cited.
|
||||
List<MediaItem> hijosDestacadas(List<Emisora> emisoras) =>
|
||||
emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
|
||||
/// The single locked item shown for ANY non-root folder when the browsing
|
||||
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
|
||||
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
|
||||
/// crash (Spec "Free-tier user selects a locked item").
|
||||
MediaItem itemPremiumBloqueado() => MediaItem(
|
||||
id: idPremiumInfo,
|
||||
title: 'Función Premium',
|
||||
/// El item de [idLocalNoLista]. Rotulado con
|
||||
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
|
||||
/// head unit. No reproducible — seleccionarlo es un no-op.
|
||||
MediaItem itemLocalNoDisponible() => MediaItem(
|
||||
id: idLocalNoLista,
|
||||
title: etiquetas.musicaLocalNoDisponible,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -464,20 +614,15 @@ class ConstructorArbolAuto {
|
||||
return (documentId, pagina);
|
||||
}
|
||||
|
||||
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
|
||||
/// (Design ADR-5) — matches every other car-tree label in this file
|
||||
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
|
||||
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
|
||||
static const _tituloMasLocal = 'Más…';
|
||||
|
||||
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
|
||||
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
|
||||
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
|
||||
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
|
||||
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
|
||||
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
|
||||
MediaItem(
|
||||
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
|
||||
title: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -602,41 +747,41 @@ class ConstructorArbolAuto {
|
||||
/// for small folders.
|
||||
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
|
||||
|
||||
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
|
||||
/// The "sort by quality" mode-entry `MediaItem` (Design ADR-4):
|
||||
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
|
||||
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
|
||||
/// Hardcoded Spanish label, matching every other car-tree label in this
|
||||
/// file — never routed through `AppLocalizations` (established
|
||||
/// car-tree-label precedent, see [_tituloMasLocal]).
|
||||
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
|
||||
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
|
||||
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
|
||||
'Ordenar por calidad',
|
||||
etiquetas.ordenarPorCalidad,
|
||||
);
|
||||
|
||||
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
|
||||
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
|
||||
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
|
||||
/// alphabetical-range label (e.g. `'A-F'`), matching every other
|
||||
/// car-tree label in this file — never routed through `AppLocalizations`.
|
||||
/// round-trips via [bucketLocalDesde].
|
||||
///
|
||||
/// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one
|
||||
/// user-visible car-tree string that deliberately does NOT go through
|
||||
/// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames
|
||||
/// actually start with, so translating it would make it lie.
|
||||
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
|
||||
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
|
||||
|
||||
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
|
||||
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
|
||||
/// matching every other car-tree label in this file — never routed
|
||||
/// through `AppLocalizations`.
|
||||
/// The "play folder" playable action item (Design ADR-5): id
|
||||
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
|
||||
/// [EtiquetasArbolAuto.reproducirCarpeta].
|
||||
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
|
||||
title: 'Reproducir carpeta',
|
||||
title: etiquetas.reproducirCarpeta,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
|
||||
/// The "shuffle play" playable action item (Design ADR-5),
|
||||
/// mirrors [_itemReproducirCarpeta].
|
||||
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
|
||||
title: 'Reproducir aleatorio',
|
||||
title: etiquetas.reproducirAleatorio,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
@@ -718,7 +863,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
|
||||
title: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -732,7 +877,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
|
||||
title: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -812,7 +957,7 @@ class ConstructorArbolAuto {
|
||||
final titulo =
|
||||
(tituloMeta != null && tituloMeta.isNotEmpty)
|
||||
? tituloMeta
|
||||
: _tituloDesdeNombre(nodo.nombre);
|
||||
: _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
|
||||
final artUriMeta = meta?.artUri?.trim();
|
||||
final artUri =
|
||||
(artUriMeta != null && artUriMeta.isNotEmpty)
|
||||
@@ -929,24 +1074,56 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
|
||||
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
|
||||
/// other resolution. For the root itself this NEVER blocks (the root always
|
||||
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
|
||||
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
|
||||
/// [premium], it returns the single locked item regardless of what the id
|
||||
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
|
||||
/// before a downgrade is blocked exactly the same way as a legitimate
|
||||
/// current folder id (android-auto-media spec "Free-Tier Browse Never
|
||||
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
|
||||
/// caller should proceed with its normal resolution (root, or premium).
|
||||
/// Whether [parentMediaId] is content the FREE tier is allowed to browse
|
||||
/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
|
||||
/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
|
||||
/// whose uuid belongs to [destacadas].
|
||||
///
|
||||
/// Everything else — the catalogue folders, favourites, custom stations,
|
||||
/// local music, the equalizer folder, group folders, local tracks, and any
|
||||
/// station uuid that is not in the free set — is premium content.
|
||||
///
|
||||
/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
|
||||
/// is testable without prefs or a handler.
|
||||
bool idPermitidoEnFree(
|
||||
String parentMediaId, {
|
||||
required List<Emisora> destacadas,
|
||||
}) {
|
||||
if (parentMediaId == AudioService.browsableRootId) return true;
|
||||
if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true;
|
||||
if (!parentMediaId.startsWith(_prefijoEmisora)) return false;
|
||||
final uuid = parentMediaId.substring(_prefijoEmisora.length);
|
||||
if (uuid.isEmpty) return false;
|
||||
return destacadas.any((e) => e.uuid == uuid);
|
||||
}
|
||||
|
||||
/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren`
|
||||
/// choke point, called BEFORE any other resolution.
|
||||
///
|
||||
/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to
|
||||
/// content-scoping. It used to answer ANY non-root id, for a free-tier user,
|
||||
/// with a single non-playable "Función Premium" row — which is what Google
|
||||
/// Play cited on version code 157 ("clicking on stop button makes the entire
|
||||
/// app useless" was the headline, but the browse tree it was reviewed
|
||||
/// against was four folders that each dead-ended on that row). A
|
||||
/// non-playable row reachable from a head unit's CACHED tree is a citation
|
||||
/// waiting to happen, so there is no longer any code path that can produce
|
||||
/// one: the blocked branch returns the free tier's own playable stations.
|
||||
///
|
||||
/// Returns `null` when the caller should proceed with its normal resolution
|
||||
/// (premium, or free-tier content the free tier owns).
|
||||
///
|
||||
/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the
|
||||
/// caller resolves it once per browse. Passing an empty list is legal and
|
||||
/// yields an empty blocked response — still never a dead row.
|
||||
List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
||||
required String parentMediaId,
|
||||
required bool premium,
|
||||
required List<Emisora> destacadas,
|
||||
}) {
|
||||
if (parentMediaId == AudioService.browsableRootId) return null;
|
||||
if (premium) return null;
|
||||
return [ConstructorArbolAuto().itemPremiumBloqueado()];
|
||||
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
|
||||
return ConstructorArbolAuto().hijosDestacadas(destacadas);
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
||||
@@ -958,17 +1135,21 @@ List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
||||
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
|
||||
/// never called and no exception propagates (Spec "Unknown or stale media
|
||||
/// id").
|
||||
Future<void> reproducirPorMediaId(
|
||||
///
|
||||
/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines,
|
||||
/// item 12). The caller needs to tell "played" from "resolved to nothing"
|
||||
/// so the second case can publish an explained error to the car instead of
|
||||
/// leaving the driver with a tap that did nothing and said nothing.
|
||||
Future<bool> reproducirPorMediaId(
|
||||
String id, {
|
||||
required FuenteEmisorasAuto fuente,
|
||||
required Future<void> Function(MediaItem) reproducir,
|
||||
}) async {
|
||||
if (!id.startsWith(_prefijoEmisora)) return;
|
||||
final uuid = id.substring(_prefijoEmisora.length);
|
||||
if (uuid.isEmpty) return;
|
||||
final uuid = uuidDeMediaIdEmisora(id);
|
||||
if (uuid == null) return false;
|
||||
|
||||
final emisora = await fuente.porUuid(uuid);
|
||||
if (emisora == null) return;
|
||||
if (emisora == null) return false;
|
||||
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
@@ -984,6 +1165,58 @@ Future<void> reproducirPorMediaId(
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
await reproducir(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The uuid inside an `emisora:<uuid>` media id, or `null` for any other
|
||||
/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a
|
||||
/// folder id) and an empty tail both answer `null`.
|
||||
///
|
||||
/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path
|
||||
/// entitlement gate has to ask the same question `reproducirPorMediaId` asks,
|
||||
/// one step earlier: "is this a station id, and which station?".
|
||||
String? uuidDeMediaIdEmisora(String id) {
|
||||
if (!id.startsWith(_prefijoEmisora)) return null;
|
||||
final uuid = id.substring(_prefijoEmisora.length);
|
||||
return uuid.isEmpty ? null : uuid;
|
||||
}
|
||||
|
||||
/// A [FuenteEmisorasAuto] over nothing but the free tier's station set
|
||||
/// (fix/auto-quality-guidelines, item 12).
|
||||
///
|
||||
/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the
|
||||
/// window between the headless Android Auto engine starting and `main.dart`
|
||||
/// registering the real source. A tap arriving in that window used to return
|
||||
/// in silence; the free set is compiled into the binary, so it can always be
|
||||
/// answered.
|
||||
///
|
||||
/// Reports the free stations through [todas] (they are, from the car's point
|
||||
/// of view, everything there is) and nothing through the curated lists, which
|
||||
/// a headless bind could not populate anyway.
|
||||
class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto {
|
||||
FuenteEmisorasAutoDestacadas(this._destacadas);
|
||||
|
||||
final List<Emisora> _destacadas;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> favoritos() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> todas() async => _destacadas;
|
||||
|
||||
@override
|
||||
Future<List<GrupoFavoritos>> grupos() async => const [];
|
||||
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async {
|
||||
for (final emisora in _destacadas) {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which list previous/next should walk for [actual]: the NARROWEST context
|
||||
@@ -1014,6 +1247,52 @@ List<Emisora> listaParaSaltoEmisora({
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
final contexto = contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
);
|
||||
if (contexto == null) return const [];
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
return favoritos
|
||||
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
|
||||
.toList();
|
||||
case TipoContextoSalto.favoritos:
|
||||
return favoritos;
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return misEmisoras;
|
||||
case TipoContextoSalto.todas:
|
||||
return todas;
|
||||
case TipoContextoSalto.destacadas:
|
||||
// Never produced by [contextoParaSaltoEmisora] — the free set is
|
||||
// resolved by the handler, which owns the entitlement read.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
|
||||
/// — so it can be remembered across a process restart.
|
||||
///
|
||||
/// The car kills and restarts the engine on every reconnect, and a list of
|
||||
/// stations is not something that survives that: its members change while the
|
||||
/// app is dead. The NAME of the list does survive, which is what
|
||||
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
|
||||
/// whatever the lists hold next time.
|
||||
///
|
||||
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
|
||||
/// and the remembered context can never disagree (pinned by a test that runs
|
||||
/// both over the same scenarios).
|
||||
///
|
||||
/// Returns `null` when [actual] belongs to none of the three lists — the
|
||||
/// caller then has no context to remember and leaves playback alone.
|
||||
ContextoSalto? contextoParaSaltoEmisora({
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
Emisora? enLista(List<Emisora> lista) {
|
||||
for (final e in lista) {
|
||||
@@ -1031,13 +1310,13 @@ List<Emisora> listaParaSaltoEmisora({
|
||||
if (grupo != GrupoFavoritos.sinAsignarId) {
|
||||
final delGrupo =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
|
||||
if (delGrupo.length > 1) return delGrupo;
|
||||
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
|
||||
}
|
||||
return favoritos;
|
||||
return const ContextoSalto.favoritos();
|
||||
}
|
||||
if (enLista(misEmisoras) != null) return misEmisoras;
|
||||
if (enLista(todas) != null) return todas;
|
||||
return const [];
|
||||
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
|
||||
if (enLista(todas) != null) return const ContextoSalto.todas();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The station before or after [actual] in [lista], wrapping around at both
|
||||
@@ -1164,25 +1443,22 @@ Future<void> seleccionarPresetEqPorMediaId(
|
||||
if (!activo) await activarEcualizador(true);
|
||||
}
|
||||
|
||||
/// Fallback title (Design "Title = filename minus extension") for a blank
|
||||
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
||||
/// matching every other car-tree label in this file (`'Favoritos'`,
|
||||
/// `'Música Local'`, etc.), none of which go through `AppLocalizations`.
|
||||
const _tituloLocalFallback = 'Pista sin nombre';
|
||||
|
||||
/// Filename → display title (Design "Title = filename minus extension"):
|
||||
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
|
||||
/// dot, or the dot is the first character — e.g. a hidden file like
|
||||
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
|
||||
/// blank.
|
||||
String _tituloDesdeNombre(String nombre) {
|
||||
/// `.mp3`), falling back to [sinNombre] when the result would be blank.
|
||||
///
|
||||
/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than
|
||||
/// hardcoded: it is a title the driver reads, so it is translated like every
|
||||
/// other car-tree label.
|
||||
String _tituloDesdeNombre(String nombre, String sinNombre) {
|
||||
final recortado = nombre.trim();
|
||||
if (recortado.isEmpty) return _tituloLocalFallback;
|
||||
if (recortado.isEmpty) return sinNombre;
|
||||
final ultimoPunto = recortado.lastIndexOf('.');
|
||||
final sinExtension =
|
||||
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
||||
final resultado = sinExtension.trim();
|
||||
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
||||
return resultado.isEmpty ? sinNombre : resultado;
|
||||
}
|
||||
|
||||
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
||||
@@ -1455,12 +1731,13 @@ Future<void> reproducirCarpetaLocal(
|
||||
Future<MediaItem?> construirMediaItemColaLocal(
|
||||
NodoLocal nodo, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
|
||||
if (contentUri == null || contentUri.isEmpty) return null;
|
||||
return MediaItem(
|
||||
id: contentUri,
|
||||
title: _tituloDesdeDocumentId(nodo.documentId),
|
||||
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
|
||||
album: 'PluriWave',
|
||||
// Item 3: a queued local track had NO artUri at all before — reuses
|
||||
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
||||
@@ -1524,8 +1801,9 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
|
||||
Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
String parentMediaId, {
|
||||
required FuenteMusicaLocalAuto? fuente,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||
|
||||
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
|
||||
// routing order is irrelevant to correctness (every prefix in this file
|
||||
@@ -1584,13 +1862,24 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
if (fuente == null) return const [];
|
||||
try {
|
||||
final nodos = await fuente.hijos(documentId);
|
||||
return await constructor.itemsLocales(
|
||||
final items = await constructor.itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: documentId,
|
||||
pagina: pagina,
|
||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||
fuente: fuente,
|
||||
);
|
||||
// fix/android-auto-musica-local: si no salió NADA, el motivo importa.
|
||||
// Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]`
|
||||
// igual que una carpeta realmente vacía, y una carpeta vacía en el
|
||||
// coche se lee como «no tengo música». El estado se consulta SOLO en
|
||||
// ese caso vacío, así que la ruta normal no paga ningún round trip
|
||||
// extra.
|
||||
if (items.isEmpty &&
|
||||
await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) {
|
||||
return [constructor.itemLocalNoDisponible()];
|
||||
}
|
||||
return items;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
@@ -1605,11 +1894,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
||||
/// Now Playing title consistent with what the user tapped without requiring
|
||||
/// a second native round trip.
|
||||
String _tituloDesdeDocumentId(String documentId) {
|
||||
String _tituloDesdeDocumentId(String documentId, String sinNombre) {
|
||||
final ultimaBarra = documentId.lastIndexOf('/');
|
||||
final segmento =
|
||||
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
||||
return _tituloDesdeNombre(segmento);
|
||||
return _tituloDesdeNombre(segmento, sinNombre);
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
||||
@@ -1628,6 +1917,7 @@ Future<void> reproducirPistaLocal(
|
||||
String id, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
required Future<void> Function(MediaItem) reproducir,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
if (!esPistaMediaId(id)) return;
|
||||
final documentId = id.substring(_prefijoPista.length);
|
||||
@@ -1638,7 +1928,7 @@ Future<void> reproducirPistaLocal(
|
||||
|
||||
final pista = PistaLocal(
|
||||
documentId: documentId,
|
||||
titulo: _tituloDesdeDocumentId(documentId),
|
||||
titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
|
||||
contentUri: contentUri,
|
||||
);
|
||||
|
||||
@@ -1733,6 +2023,16 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
return _snapshotTodas ?? const [];
|
||||
}
|
||||
|
||||
/// Resolves a station uuid across every list this source can reach.
|
||||
///
|
||||
/// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST
|
||||
/// (fix/auto-quality-guidelines, item 7). It has to be searched at all
|
||||
/// because on a cold headless bind the three lists above are all empty —
|
||||
/// `todas()` is `_snapshotTodas ?? const []`, favourites and custom
|
||||
/// stations have nothing persisted on a fresh install — so a curated
|
||||
/// `emisora:<uuid>` resolved to `null` and tapping the row did NOTHING.
|
||||
/// It is searched last so a live catalogue/favourite record for the same
|
||||
/// uuid (richer metadata, the user's own group assignment) still wins.
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async {
|
||||
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
|
||||
@@ -1741,6 +2041,9 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
}
|
||||
for (final emisora in await resolverEmisorasDestacadas()) {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,28 @@ const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378';
|
||||
/// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock).
|
||||
const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
|
||||
|
||||
/// Real id in release builds only; test id everywhere else (debug/profile,
|
||||
/// including internal-testing-track builds run via `flutter run --release`
|
||||
/// on a personal device — see the "never tap your own ads" note above).
|
||||
/// TESTING-PHASE SWITCH. While `true`, release builds serve Google's official
|
||||
/// TEST ad units instead of the real ones, so none of the closed-testing
|
||||
/// human testers can generate invalid traffic against the AdMob account
|
||||
/// (they cannot be registered as AdMob test devices). Flip to `false` for
|
||||
/// the production release — that is the ONLY change needed to start serving
|
||||
/// real ads. This does NOT affect the AdMob application id in
|
||||
/// `AndroidManifest.xml`, which stays real in every build (it only
|
||||
/// initializes the SDK and carries none of the click risk).
|
||||
const usarAnunciosDePruebaEnRelease = true;
|
||||
|
||||
/// Real id in release builds only, and only once [usarAnunciosDePruebaEnRelease]
|
||||
/// is flipped to `false`; test id everywhere else (debug/profile, including
|
||||
/// internal-testing-track builds run via `flutter run --release` on a
|
||||
/// personal device — see the "never tap your own ads" note above).
|
||||
const bannerAdUnitId =
|
||||
kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba;
|
||||
kReleaseMode && !usarAnunciosDePruebaEnRelease
|
||||
? _bannerAdUnitIdReal
|
||||
: bannerAdUnitIdPrueba;
|
||||
const interstitialAdUnitId =
|
||||
kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba;
|
||||
kReleaseMode && !usarAnunciosDePruebaEnRelease
|
||||
? _interstitialAdUnitIdReal
|
||||
: interstitialAdUnitIdPrueba;
|
||||
|
||||
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
|
||||
/// the entitlement gate for both surfaces, the interstitial's session
|
||||
|
||||
+2238
-259
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
|
||||
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
||||
@@ -38,6 +39,25 @@ class EventoCompra {
|
||||
final String? mensaje;
|
||||
}
|
||||
|
||||
/// Outcome of the SILENT ownership query ([PuertoCompras.consultarPropiedad])
|
||||
/// used to re-verify the persisted premium flag (refund revocation).
|
||||
///
|
||||
/// Only [poseida] and [noPoseida] are definitive answers from Play; anything
|
||||
/// that is not a clean answer (offline, billing unavailable, query error,
|
||||
/// exception, timeout, pending purchase) is [desconocido], which the
|
||||
/// verification policy treats as "change nothing" (fail-open, ADR-2).
|
||||
enum ResultadoVerificacionLicencia {
|
||||
/// Play reports the premium product as purchased on this account.
|
||||
poseida,
|
||||
|
||||
/// Play answered successfully and the premium product is NOT among the
|
||||
/// account's purchases (e.g. refunded or revoked).
|
||||
noPoseida,
|
||||
|
||||
/// No trustworthy answer — never used to revoke.
|
||||
desconocido,
|
||||
}
|
||||
|
||||
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
|
||||
/// this port, never on `in_app_purchase` directly — matches
|
||||
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
|
||||
@@ -55,13 +75,24 @@ abstract class PuertoCompras {
|
||||
|
||||
/// Re-queries Play Billing for a prior purchase on this account.
|
||||
Future<void> restaurar();
|
||||
|
||||
/// Silently asks the store whether this account currently owns the
|
||||
/// premium product. Unlike [restaurar], it NEVER emits on [eventos] (the
|
||||
/// premium sheet listens there) and never throws: every failure maps to
|
||||
/// [ResultadoVerificacionLicencia.desconocido].
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad();
|
||||
}
|
||||
|
||||
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
|
||||
/// depends on [PuertoCompras] instead.
|
||||
class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
|
||||
: _iap = inAppPurchase ?? InAppPurchase.instance {
|
||||
ServicioComprasPlayBilling({
|
||||
InAppPurchase? inAppPurchase,
|
||||
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
|
||||
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
|
||||
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
|
||||
_consultarComprasPasadasInyectada = consultarComprasPasadas,
|
||||
_limiteConsultaPropiedad = limiteConsultaPropiedad {
|
||||
_sub = _iap.purchaseStream.listen(
|
||||
_alRecibirCompras,
|
||||
onError: (Object error) {
|
||||
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
static const idProducto = 'pluriwave_premium';
|
||||
|
||||
final InAppPurchase _iap;
|
||||
|
||||
/// Test seam for [consultarPropiedad]; `null` in production, where the
|
||||
/// Android platform addition's `queryPastPurchases` is used.
|
||||
final Future<QueryPurchaseDetailsResponse> Function()?
|
||||
_consultarComprasPasadasInyectada;
|
||||
|
||||
/// Upper bound for [consultarPropiedad]: a hung BillingClient connection
|
||||
/// resolves to [ResultadoVerificacionLicencia.desconocido].
|
||||
final Duration _limiteConsultaPropiedad;
|
||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||
StreamSubscription<List<PurchaseDetails>>? _sub;
|
||||
|
||||
@@ -125,14 +165,48 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||
try {
|
||||
final disponible = await _iap.isAvailable().timeout(
|
||||
_limiteConsultaPropiedad,
|
||||
);
|
||||
if (!disponible) return ResultadoVerificacionLicencia.desconocido;
|
||||
final consultar =
|
||||
_consultarComprasPasadasInyectada ??
|
||||
() =>
|
||||
_iap
|
||||
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
|
||||
.queryPastPurchases();
|
||||
// `queryPastPurchases` reads the account's purchases straight from
|
||||
// the BillingClient: unlike `restorePurchases` it does NOT push them
|
||||
// into `purchaseStream`, so the premium sheet never sees this check.
|
||||
final respuesta = await consultar().timeout(_limiteConsultaPropiedad);
|
||||
return resultadoDesdeComprasPasadas(
|
||||
respuesta.pastPurchases,
|
||||
conError: respuesta.error != null,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][compras] consultarPropiedad -> desconocido $e');
|
||||
return ResultadoVerificacionLicencia.desconocido;
|
||||
}
|
||||
}
|
||||
|
||||
void _alRecibirCompras(List<PurchaseDetails> compras) {
|
||||
if (compras.isEmpty) {
|
||||
// `restorePurchases()` with nothing to restore completes without ever
|
||||
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is
|
||||
// no per-call correlation in this stream, so this fires on ANY empty
|
||||
// batch. In practice `queryPastPurchases`/`restorePurchases` on an
|
||||
// account with nothing to restore is the only source of an empty
|
||||
// batch this stream would ever emit.
|
||||
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
|
||||
// (`in_app_purchase_android` does `_purchaseUpdatedController.add(
|
||||
// pastPurchases)` unconditionally) — there is no per-call correlation
|
||||
// in this stream, so this fires on ANY empty batch. In practice
|
||||
// `restorePurchases` on an account with nothing to restore is the only
|
||||
// source of an empty batch this stream would ever emit.
|
||||
//
|
||||
// Returning silently here (as this did before) left
|
||||
// [TipoEventoCompra.noEncontrada] NEVER emitted, so
|
||||
// `EstadoEntitlement._compraEnCurso` stayed `true` forever and
|
||||
// `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy.
|
||||
// A paywall that cannot be paid.
|
||||
_eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada));
|
||||
return;
|
||||
}
|
||||
for (final compra in compras) {
|
||||
@@ -169,6 +243,33 @@ EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Pure mapping from a `queryPastPurchases` answer to the typed ownership
|
||||
/// result (same port-boundary rationale as [eventoDesdeEstadoCompra]).
|
||||
///
|
||||
/// The premium product present as purchased/restored is proof of ownership
|
||||
/// even when the answer also carries an error (the query spans in-app AND
|
||||
/// subscriptions, and a failure of the latter is irrelevant here). A pending
|
||||
/// entry is not a clean answer. Absence only counts as [noPoseida] when the
|
||||
/// query succeeded without error.
|
||||
ResultadoVerificacionLicencia resultadoDesdeComprasPasadas(
|
||||
List<PurchaseDetails> compras, {
|
||||
required bool conError,
|
||||
}) {
|
||||
final delProducto = compras.where(
|
||||
(c) => c.productID == ServicioComprasPlayBilling.idProducto,
|
||||
);
|
||||
final comprada = delProducto.any(
|
||||
(c) =>
|
||||
c.status == PurchaseStatus.purchased ||
|
||||
c.status == PurchaseStatus.restored,
|
||||
);
|
||||
if (comprada) return ResultadoVerificacionLicencia.poseida;
|
||||
if (conError || delProducto.isNotEmpty) {
|
||||
return ResultadoVerificacionLicencia.desconocido;
|
||||
}
|
||||
return ResultadoVerificacionLicencia.noPoseida;
|
||||
}
|
||||
|
||||
extension<T> on List<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
||||
@@ -238,6 +238,45 @@ class ServicioEcualizador {
|
||||
await prefs.setBool(_keyActivo, activo);
|
||||
}
|
||||
|
||||
/// The persisted equalizer on/off flag, or `null` when the user has never
|
||||
/// touched the toggle.
|
||||
///
|
||||
/// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads
|
||||
/// ONE key and runs none of the migrations, because its caller is
|
||||
/// `registrarHandler`, on the audio bootstrap path of EVERY engine —
|
||||
/// including the headless one Android Auto starts, where there is no
|
||||
/// widget tree and `EstadoEcualizador` never exists. It must stay cheap
|
||||
/// and it must never mutate anything.
|
||||
///
|
||||
/// `null` is preserved rather than collapsed to a default so that
|
||||
/// `estadoEqInicial` — not this service — owns the "never persisted"
|
||||
/// policy in exactly one place.
|
||||
Future<bool?> leerActivo() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
return prefs.getBool(_keyActivo);
|
||||
}
|
||||
|
||||
/// The persisted principal preset, or `null` when the user has never saved
|
||||
/// one.
|
||||
///
|
||||
/// The exact sibling of [leerActivo] and narrow for the same reason: its
|
||||
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
|
||||
/// engine — including the headless one Android Auto starts, where there is
|
||||
/// no widget tree and `EstadoEcualizador` never exists to push a preset
|
||||
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
|
||||
/// and mutates nothing.
|
||||
///
|
||||
/// `null` (nothing saved, or an unreadable value) is preserved rather than
|
||||
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
|
||||
/// not this service — decides what "never persisted" means, and so a seed
|
||||
/// with nothing to say does not overwrite anything.
|
||||
Future<PresetEcualizador?> leerPresetPrincipal() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyPresetPrincipal);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return _leerPresetPrincipal(prefs);
|
||||
}
|
||||
|
||||
Future<void> eliminarPorEmisora(String uuid) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final mapa = _leerPresetsPorEmisora(prefs);
|
||||
|
||||
@@ -7,26 +7,33 @@ import '../modelos/preset_ecualizador.dart';
|
||||
/// Owns the backup (export/import) JSON serialization (S4-R4).
|
||||
///
|
||||
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
|
||||
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
|
||||
/// export stays at v2 for backward compat with the old app. State APPLICATION
|
||||
/// (writing favorites, EQ, alarms back into the app) stays in
|
||||
/// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the
|
||||
/// equalizer's global ON/OFF toggle). When the version-N extension
|
||||
/// parameters are all omitted the export stays at the lower version for
|
||||
/// backward compat with older app builds. State APPLICATION (writing
|
||||
/// favorites, EQ, alarms back into the app) stays in
|
||||
/// `EstadoRadio.importarConfig` — this service only owns serialization,
|
||||
/// parsing and the envelope shape.
|
||||
class ServicioExportImport {
|
||||
const ServicioExportImport();
|
||||
|
||||
/// Current backup schema version (v3 — multi-device EQ).
|
||||
static const int versionActual = 3;
|
||||
/// Current backup schema version (v4 — equalizer on/off toggle).
|
||||
static const int versionActual = 4;
|
||||
|
||||
/// v3 version constant (multi-device EQ) kept for clarity.
|
||||
static const int versionV3 = 3;
|
||||
|
||||
/// Legacy v2 version constant kept for clarity.
|
||||
static const int versionV2 = 2;
|
||||
|
||||
/// Builds the export envelope.
|
||||
///
|
||||
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
|
||||
/// [versionActual] (3) is written. When both are omitted the call behaves
|
||||
/// identically to the original v2 format (version key stays 2) so old
|
||||
/// backups keep round-tripping without version bumps.
|
||||
/// When [presetsPorDispositivo] or [presetsMatriz] or
|
||||
/// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3)
|
||||
/// is written. When [ecualizadorActivo] is ALSO provided (non-null),
|
||||
/// [versionActual] (4) is written. Omitting all of them behaves identically
|
||||
/// to the original v2 format (version key stays 2) so old backups keep
|
||||
/// round-tripping without version bumps.
|
||||
///
|
||||
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
|
||||
/// and passes through untouched (no re-parsing here).
|
||||
@@ -45,14 +52,27 @@ class ServicioExportImport {
|
||||
Map<String, PresetEcualizador>? presetsPorDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
bool? eqMultiDeviceEnabled,
|
||||
// v4 extension — the equalizer's global ON/OFF toggle. Omitting it
|
||||
// produces a v3 (or v2)-compatible export.
|
||||
bool? ecualizadorActivo,
|
||||
}) {
|
||||
final tieneExtensionesV3 =
|
||||
presetsPorDispositivo != null ||
|
||||
presetsMatriz != null ||
|
||||
eqMultiDeviceEnabled != null;
|
||||
final tieneExtensionV4 = ecualizadorActivo != null;
|
||||
|
||||
final int version;
|
||||
if (tieneExtensionV4) {
|
||||
version = versionActual;
|
||||
} else if (tieneExtensionesV3) {
|
||||
version = versionV3;
|
||||
} else {
|
||||
version = versionV2;
|
||||
}
|
||||
|
||||
final envelope = <String, dynamic>{
|
||||
'version': tieneExtensionesV3 ? versionActual : versionV2,
|
||||
'version': version,
|
||||
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
|
||||
// Favorites + groups (preserves grupo_id assignments per station).
|
||||
// The protected "sin asignar" group is implicit and never exported.
|
||||
@@ -88,6 +108,11 @@ class ServicioExportImport {
|
||||
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
|
||||
}
|
||||
|
||||
// v4 extension: only written when explicitly provided.
|
||||
if (tieneExtensionV4) {
|
||||
envelope['ecualizadorActivo'] = ecualizadorActivo;
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,43 @@ class ServicioFavoritos {
|
||||
);
|
||||
}
|
||||
|
||||
/// Restaura un favorito tal como estaba en el dispositivo de origen,
|
||||
/// preservando su `orden` y su `grupo_id`.
|
||||
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
|
||||
///
|
||||
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
|
||||
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
|
||||
/// `orden` al final de la lista, cosa correcta para una emisora recién
|
||||
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
|
||||
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
|
||||
/// que los grupos volvieran vacíos tras restaurar.
|
||||
///
|
||||
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
|
||||
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
|
||||
/// que una copia editada a mano o restaurada a medias no puede dejar
|
||||
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
|
||||
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
|
||||
Future<void> restaurarFavorito(Emisora emisora) async {
|
||||
final db = await _database;
|
||||
final existe =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
|
||||
[emisora.grupoFavoritosId],
|
||||
),
|
||||
) ??
|
||||
0;
|
||||
final restaurada =
|
||||
existe > 0
|
||||
? emisora
|
||||
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
|
||||
await db.insert(
|
||||
'favoritos',
|
||||
restaurada.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> eliminarGrupo(String id) async {
|
||||
if (id == GrupoFavoritos.sinAsignarId) return;
|
||||
final db = await _database;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'servicio_compras.dart';
|
||||
|
||||
export 'servicio_compras.dart' show ResultadoVerificacionLicencia;
|
||||
|
||||
/// Versioned persistence key (Design ADR-1) of the permanent, non-consumable
|
||||
/// premium unlock. Owned here so the headless verification below and
|
||||
/// `EstadoEntitlement` read/write exactly ONE key.
|
||||
const claveCompraPremium = 'compra_premium_v1';
|
||||
|
||||
/// Epoch millis of the last verification that got a DEFINITIVE answer
|
||||
/// ([ResultadoVerificacionLicencia.poseida] / [noPoseida]).
|
||||
const claveUltimaVerificacionLicencia = 'licencia_ultima_verificacion_ms';
|
||||
|
||||
/// Epoch millis of the last ATTEMPT, whatever its outcome.
|
||||
const claveUltimoIntentoLicencia = 'licencia_ultimo_intento_ms';
|
||||
|
||||
/// Consecutive definitive "not owned" answers while the flag was premium.
|
||||
const claveAusenciasLicencia = 'licencia_ausencias_consecutivas';
|
||||
|
||||
/// Epoch millis of the FIRST absence of the current streak.
|
||||
const clavePrimeraAusenciaLicencia = 'licencia_primera_ausencia_ms';
|
||||
|
||||
/// After a definitive answer, Play is not asked again for this long.
|
||||
const intervaloVerificacionLicencia = Duration(hours: 24);
|
||||
|
||||
/// After an attempt without a definitive answer (offline, billing
|
||||
/// unavailable, error), the retry waits at least this long, so resuming the
|
||||
/// app or reconnecting the car while offline never hammers Play.
|
||||
const intervaloReintentoLicencia = Duration(hours: 1);
|
||||
|
||||
/// Minimum time between the first absence and the one that confirms it —
|
||||
/// a transient empty Play Store cache must never revoke a paying user.
|
||||
const separacionMinimaAusencias = Duration(hours: 12);
|
||||
|
||||
/// Definitive absences required (spaced by [separacionMinimaAusencias])
|
||||
/// before the premium flag is revoked.
|
||||
const ausenciasParaRevocar = 2;
|
||||
|
||||
/// What a [verificarLicencia] run changed in the persisted flag.
|
||||
enum CambioLicencia {
|
||||
/// Nothing changed (throttled, unknown answer, or state already right).
|
||||
sinCambios,
|
||||
|
||||
/// The flag went false -> true (e.g. a reinstall of a paying user).
|
||||
desbloqueada,
|
||||
|
||||
/// The flag went true -> false (refund confirmed twice, spaced apart).
|
||||
revocada,
|
||||
}
|
||||
|
||||
/// The run currently in flight, shared by every caller in this isolate
|
||||
/// (phone UI and Android Auto), so only ONE ownership query runs at a time.
|
||||
Future<CambioLicencia>? _verificacionEnCurso;
|
||||
|
||||
/// Silent, headless-safe license re-verification (refund revocation).
|
||||
///
|
||||
/// No `BuildContext`, no purchase-stream events, never throws, and every
|
||||
/// non-definitive outcome leaves the persisted state untouched (fail-open,
|
||||
/// ADR-2) — PRO is never removed for lack of connectivity. Callers fire and
|
||||
/// forget it; it must never sit on a startup or browse path.
|
||||
///
|
||||
/// Rules:
|
||||
/// * throttled by [intervaloVerificacionLicencia] after a definitive answer
|
||||
/// and by [intervaloReintentoLicencia] after any attempt; a clock that
|
||||
/// went backwards never blocks it;
|
||||
/// * [ResultadoVerificacionLicencia.poseida]: flag forced to `true` (silent
|
||||
/// unlock if it was `false`), absence streak cleared;
|
||||
/// * [ResultadoVerificacionLicencia.noPoseida] with the flag `true`: the
|
||||
/// streak grows, and the flag is revoked only once it reaches
|
||||
/// [ausenciasParaRevocar] AND at least [separacionMinimaAusencias] passed
|
||||
/// since its first absence; with the flag `false` there is nothing to do;
|
||||
/// * [ResultadoVerificacionLicencia.desconocido] (or an exception): nothing
|
||||
/// changes, not even the streak.
|
||||
Future<CambioLicencia> verificarLicencia({
|
||||
required Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||
SharedPreferences? prefs,
|
||||
DateTime Function()? reloj,
|
||||
}) {
|
||||
final enCurso = _verificacionEnCurso;
|
||||
if (enCurso != null) return enCurso;
|
||||
final ejecucion = _verificar(
|
||||
consultar: consultar,
|
||||
prefs: prefs,
|
||||
reloj: reloj ?? DateTime.now,
|
||||
);
|
||||
_verificacionEnCurso = ejecucion;
|
||||
unawaited(
|
||||
ejecucion.whenComplete(() {
|
||||
if (identical(_verificacionEnCurso, ejecucion)) {
|
||||
_verificacionEnCurso = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return ejecucion;
|
||||
}
|
||||
|
||||
/// Clears the absence streak — a real purchase/restore is fresh proof of
|
||||
/// ownership, so a stale streak must not survive it.
|
||||
Future<void> reiniciarAusenciasLicencia(SharedPreferences prefs) async {
|
||||
await prefs.remove(claveAusenciasLicencia);
|
||||
await prefs.remove(clavePrimeraAusenciaLicencia);
|
||||
}
|
||||
|
||||
Future<CambioLicencia> _verificar({
|
||||
required Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||
required SharedPreferences? prefs,
|
||||
required DateTime Function() reloj,
|
||||
}) async {
|
||||
try {
|
||||
final p = prefs ?? await SharedPreferences.getInstance();
|
||||
final ahora = reloj();
|
||||
if (_dentroDeVentana(
|
||||
p,
|
||||
claveUltimaVerificacionLicencia,
|
||||
ahora,
|
||||
intervaloVerificacionLicencia,
|
||||
) ||
|
||||
_dentroDeVentana(
|
||||
p,
|
||||
claveUltimoIntentoLicencia,
|
||||
ahora,
|
||||
intervaloReintentoLicencia,
|
||||
)) {
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
await p.setInt(claveUltimoIntentoLicencia, ahora.millisecondsSinceEpoch);
|
||||
|
||||
ResultadoVerificacionLicencia resultado;
|
||||
try {
|
||||
resultado = await consultar();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][licencia] consulta fallida -> sin cambios $e');
|
||||
resultado = ResultadoVerificacionLicencia.desconocido;
|
||||
}
|
||||
|
||||
switch (resultado) {
|
||||
case ResultadoVerificacionLicencia.desconocido:
|
||||
return CambioLicencia.sinCambios;
|
||||
case ResultadoVerificacionLicencia.poseida:
|
||||
await p.setInt(
|
||||
claveUltimaVerificacionLicencia,
|
||||
ahora.millisecondsSinceEpoch,
|
||||
);
|
||||
await reiniciarAusenciasLicencia(p);
|
||||
if (p.getBool(claveCompraPremium) ?? false) {
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
await p.setBool(claveCompraPremium, true);
|
||||
return CambioLicencia.desbloqueada;
|
||||
case ResultadoVerificacionLicencia.noPoseida:
|
||||
await p.setInt(
|
||||
claveUltimaVerificacionLicencia,
|
||||
ahora.millisecondsSinceEpoch,
|
||||
);
|
||||
if (!(p.getBool(claveCompraPremium) ?? false)) {
|
||||
await reiniciarAusenciasLicencia(p);
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
return _registrarAusencia(p, ahora);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail-open (ADR-2): a prefs failure never touches the entitlement.
|
||||
debugPrint('[PluriWave][licencia] verificacion fallida -> sin cambios $e');
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CambioLicencia> _registrarAusencia(
|
||||
SharedPreferences p,
|
||||
DateTime ahora,
|
||||
) async {
|
||||
final ausencias = (p.getInt(claveAusenciasLicencia) ?? 0) + 1;
|
||||
final primeraMs = p.getInt(clavePrimeraAusenciaLicencia);
|
||||
if (primeraMs == null || ausencias == 1) {
|
||||
await p.setInt(claveAusenciasLicencia, 1);
|
||||
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
final separacion = ahora.difference(
|
||||
DateTime.fromMillisecondsSinceEpoch(primeraMs),
|
||||
);
|
||||
if (separacion.isNegative) {
|
||||
// The clock went backwards: restart the spacing from now (delays the
|
||||
// revocation, never hastens it).
|
||||
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
|
||||
await p.setInt(claveAusenciasLicencia, ausencias);
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
if (ausencias >= ausenciasParaRevocar &&
|
||||
separacion >= separacionMinimaAusencias) {
|
||||
await p.setBool(claveCompraPremium, false);
|
||||
await reiniciarAusenciasLicencia(p);
|
||||
return CambioLicencia.revocada;
|
||||
}
|
||||
await p.setInt(claveAusenciasLicencia, ausencias);
|
||||
return CambioLicencia.sinCambios;
|
||||
}
|
||||
|
||||
/// Whether [clave]'s timestamp is less than [ventana] before [ahora]. A
|
||||
/// timestamp in the future (clock moved backwards) does NOT throttle.
|
||||
bool _dentroDeVentana(
|
||||
SharedPreferences p,
|
||||
String clave,
|
||||
DateTime ahora,
|
||||
Duration ventana,
|
||||
) {
|
||||
final ms = p.getInt(clave);
|
||||
if (ms == null) return false;
|
||||
final transcurrido = ahora.difference(
|
||||
DateTime.fromMillisecondsSinceEpoch(ms),
|
||||
);
|
||||
return !transcurrido.isNegative && transcurrido < ventana;
|
||||
}
|
||||
+177
-95
@@ -40,112 +40,194 @@ class HojaPremium extends StatelessWidget {
|
||||
),
|
||||
child: PluriGlassSurface(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.workspace_premium_rounded,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.funcionPremium,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
// The sheet's height is data-dependent: five benefit bullets whose
|
||||
// wrapped line count varies per locale, plus an optional
|
||||
// purchase/restore result banner. Together they already overflow a
|
||||
// short viewport by a couple of pixels, and a translation one word
|
||||
// longer would make it worse. Scrolling is the only shape that
|
||||
// cannot overflow, and it costs nothing when everything fits.
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.workspace_premium_rounded,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// FIX 3 (code review): user-facing feedback for a failed
|
||||
// purchase/restore, or a restore that found nothing — before
|
||||
// this, `resultadoUsuario` had ZERO UI, so the spinner just
|
||||
// stopped with no feedback at all. Never the raw
|
||||
// `EventoCompra.mensaje` developer string — always the mapped,
|
||||
// generic localized message.
|
||||
if (entitlement.resultadoUsuario != null)
|
||||
Padding(
|
||||
key: const ValueKey('hoja-premium-resultado'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
color:
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? l10n.compraError
|
||||
: l10n.restauracionSinCompras,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.premiumHojaTitulo,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('hoja-premium-resultado-descartar'),
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
onPressed: () => entitlement.consumirResultadoUsuario(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
|
||||
// paywall): a purchase sheet the user cannot easily escape is
|
||||
// a dark pattern and a Play policy risk. Reachable without
|
||||
// buying or restoring, same weight as any other icon button.
|
||||
IconButton(
|
||||
key: const ValueKey('hoja-premium-cerrar'),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: l10n.closeAction,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (entitlement.esPremium)
|
||||
Padding(
|
||||
key: const ValueKey('hoja-premium-activo'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
l10n.premiumActivo,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
const SizedBox(height: 12),
|
||||
// Concrete, honest value list — accuracy is non-negotiable here:
|
||||
// these five are the ONLY things premium unlocks. The phone
|
||||
// equalizer stays free for everyone and must NEVER appear here;
|
||||
// only its Android Auto surface is affected, as a consequence of
|
||||
// Auto itself being gated.
|
||||
//
|
||||
// The Android Auto line describes what PRO adds in the CAR — the
|
||||
// full catalogue, favourites, my stations, local music — because
|
||||
// the free tier already gets a real, playable featured folder
|
||||
// there. A bare "Android Auto" bullet sold something free users
|
||||
// already have.
|
||||
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
|
||||
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
|
||||
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
|
||||
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
|
||||
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.premiumPagoUnico,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// FIX 3 (code review): user-facing feedback for a failed
|
||||
// purchase/restore, or a restore that found nothing — before
|
||||
// this, `resultadoUsuario` had ZERO UI, so the spinner just
|
||||
// stopped with no feedback at all. Never the raw
|
||||
// `EventoCompra.mensaje` developer string — always the mapped,
|
||||
// generic localized message.
|
||||
if (entitlement.resultadoUsuario != null)
|
||||
Padding(
|
||||
key: const ValueKey('hoja-premium-resultado'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 18,
|
||||
color:
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entitlement.resultadoUsuario ==
|
||||
ResultadoEntitlementUsuario.error
|
||||
? l10n.compraError
|
||||
: l10n.restauracionSinCompras,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('hoja-premium-resultado-descartar'),
|
||||
icon: const Icon(Icons.close_rounded, size: 18),
|
||||
onPressed: () => entitlement.consumirResultadoUsuario(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('hoja-premium-comprar'),
|
||||
if (entitlement.esPremium)
|
||||
Padding(
|
||||
key: const ValueKey('hoja-premium-activo'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
l10n.premiumActivo,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('hoja-premium-comprar'),
|
||||
onPressed:
|
||||
entitlement.compraEnCurso
|
||||
? null
|
||||
: () => entitlement.comprar(),
|
||||
icon:
|
||||
entitlement.compraEnCurso
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.lock_open_rounded),
|
||||
label: Text(l10n.desbloquearPremium),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('hoja-premium-restaurar'),
|
||||
onPressed:
|
||||
entitlement.compraEnCurso
|
||||
? null
|
||||
: () => entitlement.comprar(),
|
||||
icon:
|
||||
entitlement.compraEnCurso
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.lock_open_rounded),
|
||||
label: Text(l10n.desbloquearPremium),
|
||||
: () => entitlement.restaurar(),
|
||||
child: Text(l10n.restaurarCompras),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('hoja-premium-restaurar'),
|
||||
onPressed:
|
||||
entitlement.compraEnCurso
|
||||
? null
|
||||
: () => entitlement.restaurar(),
|
||||
child: Text(l10n.restaurarCompras),
|
||||
),
|
||||
],
|
||||
if (!entitlement.esPremium) ...[
|
||||
const SizedBox(height: 4),
|
||||
// Clearly-labelled, always-reachable decline — same weight as
|
||||
// any other secondary action, never made harder to find than
|
||||
// buying (hard constraint: no dark patterns, no guilt-shaming
|
||||
// decline copy).
|
||||
TextButton(
|
||||
key: const ValueKey('hoja-premium-ahora-no'),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
child: Text(l10n.premiumAhoraNo),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One concrete, honest value-list row (fix/import-alarmas-y-paywall).
|
||||
class _BeneficioPremium extends StatelessWidget {
|
||||
const _BeneficioPremium({required this.texto});
|
||||
|
||||
final String texto;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 18,
|
||||
color: PluriWaveTokens.brand,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(texto, style: Theme.of(context).textTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,17 @@ class VisualizadorAudio extends StatefulWidget {
|
||||
/// player: `.45`, t4 line 121), so this is a parameter, not a constant.
|
||||
final double gradienteFinAlpha;
|
||||
|
||||
/// Whether the user has opted in to reading the REAL audio level.
|
||||
///
|
||||
/// Subscribing to the native `pluriwave/audio_visualizer` EventChannel is
|
||||
/// what makes `MainActivity.startVisualizerWhenAllowed` request
|
||||
/// `RECORD_AUDIO`, so this flag is the Dart-side gate on a sensitive
|
||||
/// permission, not a cosmetic preference. Defaults to `false`: without the
|
||||
/// opt-in the widget never touches the channel and animates the synthetic
|
||||
/// wave instead, which is exactly what it already did whenever the
|
||||
/// permission was denied.
|
||||
final bool capturaRealHabilitada;
|
||||
|
||||
const VisualizadorAudio({
|
||||
super.key,
|
||||
required this.estadoStream,
|
||||
@@ -42,6 +53,7 @@ class VisualizadorAudio extends StatefulWidget {
|
||||
this.anchuraTotal = double.infinity,
|
||||
this.barrasDiscretas = false,
|
||||
this.gradienteFinAlpha = 0.3,
|
||||
this.capturaRealHabilitada = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -101,9 +113,31 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
_sincronizarOndaReal();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VisualizadorAudio oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// The opt-in can be revoked while this widget is mounted; dropping the
|
||||
// subscription is what makes the native side release the Visualizer.
|
||||
if (oldWidget.capturaRealHabilitada != widget.capturaRealHabilitada) {
|
||||
if (!widget.capturaRealHabilitada) {
|
||||
unawaited(_ondaSubscription?.cancel());
|
||||
_ondaSubscription = null;
|
||||
_ultimaOndaReal = null;
|
||||
}
|
||||
_sincronizarOndaReal();
|
||||
}
|
||||
}
|
||||
|
||||
void _sincronizarOndaReal() {
|
||||
final sessionId = _sessionId;
|
||||
final puedeCapturar = sessionId != null && sessionId > 0 && _activo;
|
||||
// `capturaRealHabilitada` first: subscribing to the EventChannel is what
|
||||
// triggers the native RECORD_AUDIO request, so the opt-in gates the
|
||||
// subscription itself, never just the rendering of its result.
|
||||
final puedeCapturar =
|
||||
widget.capturaRealHabilitada &&
|
||||
sessionId != null &&
|
||||
sessionId > 0 &&
|
||||
_activo;
|
||||
|
||||
if (!puedeCapturar) {
|
||||
unawaited(_ondaSubscription?.cancel());
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
group = "es.freetimelab.pluriwave.fileactions"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
// Sin bloque `buildscript` a proposito: este modulo solo se construye desde
|
||||
// `android/settings.gradle.kts` de la app, cuyo `pluginManagement` ya pone
|
||||
// AGP 8.11.1 y Kotlin 2.2.20 en el classpath compartido. Declarar aqui otro
|
||||
// classpath de AGP arriesga un choque de versiones con el de la app.
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "es.freetimelab.pluriwave.fileactions"
|
||||
|
||||
compileSdk = 36
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs("src/main/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Igual que `flutter.minSdkVersion` en Flutter 3.44 (FlutterExtension.kt).
|
||||
minSdk = 24
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// `androidx.core.content.FileProvider`, para servir la caratula embebida
|
||||
// cacheada desde la autoridad `${applicationId}.fileprovider` que declara
|
||||
// el manifiesto del modulo de app.
|
||||
implementation("androidx.core:core-ktx:1.16.0")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'pluriwave_file_actions'
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
package es.freetimelab.pluriwave.fileactions
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Activity-FREE half of the `pluriwave/file_actions` channel
|
||||
* (fix/android-auto-musica-local, item 3).
|
||||
*
|
||||
* These four methods -- `hasPersistedPermission`, `listAudioChildren`,
|
||||
* `resolvePlayableUri`, `readAudioMetadataBatch` -- only ever needed a
|
||||
* [ContentResolver][android.content.ContentResolver], which is an
|
||||
* app-scoped API: they never touch an Activity, a window, or
|
||||
* `startActivityForResult`. They were nevertheless trapped inside
|
||||
* `MainActivity.configureFlutterEngine`, the ONE place in the whole repo
|
||||
* that installed a handler on this channel.
|
||||
*
|
||||
* That is the reported bug: when Android Auto binds the MediaBrowserService
|
||||
* before the phone app has been opened, `audio_service` builds a bare
|
||||
* `FlutterEngine` with no Activity, `configureFlutterEngine` never runs, the
|
||||
* channel has no handler at all, and every `invokeMethod` on it throws
|
||||
* `MissingPluginException`. Dart could not tell that apart from "permission
|
||||
* revoked" and silently dropped "Musica Local" from the car's browse tree.
|
||||
*
|
||||
* Living in a real plugin package is what makes them registerable on ANY
|
||||
* engine: [PluriWaveFileActionsPlugin] is listed in
|
||||
* `GeneratedPluginRegistrant`, which the `FlutterEngine(Context)` constructor
|
||||
* runs by itself, headless engine included. An app-module class never could.
|
||||
*
|
||||
* `pickMusicFolder` and the recordings-folder intents are deliberately NOT
|
||||
* here: they need `startActivityForResult` / `startActivity` plus an
|
||||
* `onActivityResult` callback, so they stay on `MainActivity` in the app
|
||||
* module, which delegates everything else to this same class -- so both
|
||||
* engines answer the four SAF methods identically, from ONE implementation.
|
||||
*/
|
||||
class FileActionsHandler(private val context: Context) {
|
||||
|
||||
private val tag = "PluriWave"
|
||||
|
||||
/**
|
||||
* Answers [call] if it is one of the Activity-free methods, replying
|
||||
* through [result] and returning `true`. Returns `false` -- WITHOUT
|
||||
* touching [result] -- for anything else, so `MainActivity` can fall
|
||||
* through to its own Activity-bound methods on the same channel.
|
||||
*/
|
||||
fun manejar(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"listAudioChildren" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
|
||||
)
|
||||
if (treeUri.isNullOrBlank()) {
|
||||
result.success(emptyList<Map<String, Any>>())
|
||||
} else {
|
||||
result.success(listAudioChildren(treeUri, parentDocumentId))
|
||||
}
|
||||
}
|
||||
"resolvePlayableUri" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val documentId = call.argument<String>("documentId")
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
|
||||
)
|
||||
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.success(resolvePlayableUri(treeUri, documentId))
|
||||
}
|
||||
}
|
||||
"hasPersistedPermission" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
|
||||
result.success(
|
||||
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
|
||||
)
|
||||
}
|
||||
"readAudioMetadataBatch" -> {
|
||||
val treeUri = call.argument<String>("treeUri")
|
||||
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
|
||||
Log.d(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
|
||||
)
|
||||
if (treeUri.isNullOrBlank()) {
|
||||
result.success(emptyList<Map<String, Any?>>())
|
||||
} else {
|
||||
result.success(readAudioMetadataBatch(treeUri, documentIds))
|
||||
}
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Traza el rechazo de un metodo que exige Activity, para que el log
|
||||
* distinga "no hay Activity aqui" de "el canal no existe". Lo usa
|
||||
* [PluriWaveFileActionsPlugin] antes de responder `notImplemented()`.
|
||||
*/
|
||||
fun trazarNoDisponibleSinActividad(metodo: String) {
|
||||
Log.d(tag, "file_actions.$metodo needs an Activity; not available here")
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
|
||||
* static review only -- Design "Lazy per-folder enumeration, never an
|
||||
* eager tree dump"): [parentDocumentId] blank means the tree root
|
||||
* itself, otherwise the given subfolder's documentId. Filters files to
|
||||
* audio MIME types at the native layer (lean payload); each returned row
|
||||
* also carries `mime` so the Dart side can re-validate via
|
||||
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
|
||||
* empty list rather than throwing.
|
||||
*/
|
||||
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
val parentId = parentDocumentId.ifBlank {
|
||||
DocumentsContract.getTreeDocumentId(parsedTree)
|
||||
}
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
|
||||
val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
val resultado = mutableListOf<Map<String, Any>>()
|
||||
context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
|
||||
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
|
||||
while (cursor.moveToNext()) {
|
||||
val documentId = cursor.getString(idxDocId) ?: continue
|
||||
val nombre = cursor.getString(idxNombre) ?: continue
|
||||
val mime = cursor.getString(idxMime) ?: ""
|
||||
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
if (!esDirectorio && !mime.startsWith("audio/")) continue
|
||||
resultado.add(
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"nombre" to nombre,
|
||||
"esDirectorio" to esDirectorio,
|
||||
"mime" to mime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
resultado
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a leaf [documentId] within [treeUri] to its playable
|
||||
* `content://` URI (android-auto-local-music, static review only).
|
||||
* Returns `null` on any failure instead of throwing.
|
||||
*/
|
||||
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether [treeUri]'s read permission is still among
|
||||
* [android.content.ContentResolver.getPersistedUriPermissions]
|
||||
* (android-auto-local-music, static review only) -- used for cold-start
|
||||
* / revoked-permission detection (Spec "Permission revoked or never
|
||||
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
|
||||
* any other failure.
|
||||
*
|
||||
* Persisted URI grants are taken by the app, not by the Activity, so
|
||||
* this answers identically on an engine with no Activity -- which is
|
||||
* exactly why it belongs in this class.
|
||||
*/
|
||||
private fun hasPersistedPermission(treeUri: String): Boolean {
|
||||
return try {
|
||||
val parsed = Uri.parse(treeUri)
|
||||
context.contentResolver.persistedUriPermissions.any {
|
||||
it.uri == parsed && it.isReadPermission
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
|
||||
* static review only -- Design "Interfaces / Contracts"): for each of
|
||||
* [documentIds], extracts title/artist/bitrate/sample-rate and the
|
||||
* embedded picture via [extraerMetadatosPista]. Never throws across the
|
||||
* channel boundary -- a malformed [treeUri] (or any other unexpected
|
||||
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
|
||||
* already isolated inside [extraerMetadatosPista].
|
||||
*/
|
||||
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
|
||||
return try {
|
||||
val parsedTree = Uri.parse(treeUri)
|
||||
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts one [documentId]'s embedded metadata via
|
||||
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
|
||||
* review only -- mirrors [listAudioChildren]/[resolvePlayableUri]'s
|
||||
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
|
||||
* public constant below API 31) is gated behind
|
||||
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
|
||||
* available since API 10 and read unconditionally. A resolvable
|
||||
* embedded picture is handed to [cachearArteEmbebido]; art-cache
|
||||
* failures degrade that single field to `null` without failing the
|
||||
* whole entry. On ANY failure for this [documentId] (unsupported
|
||||
* format, permission edge case, corrupt file), the row degrades to an
|
||||
* all-null-but-`documentId` entry instead of throwing --
|
||||
* `retriever.release()` always runs via `finally`.
|
||||
*/
|
||||
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return try {
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
|
||||
retriever.setDataSource(context, documentUri)
|
||||
|
||||
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
|
||||
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
|
||||
val bitrate = retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||
?.toIntOrNull()
|
||||
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
|
||||
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
|
||||
// public constant exists on this minSdk, so the raw key is
|
||||
// used directly, guarded by the version check above.
|
||||
retriever.extractMetadata(38)?.toIntOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val artUri = try {
|
||||
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
|
||||
} catch (error: Throwable) {
|
||||
Log.e(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
|
||||
error
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"titulo" to titulo,
|
||||
"artista" to artista,
|
||||
"bitrate" to bitrate,
|
||||
"sampleRate" to sampleRate,
|
||||
"artUri" to artUri
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(
|
||||
tag,
|
||||
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
|
||||
error
|
||||
)
|
||||
mapOf(
|
||||
"documentId" to documentId,
|
||||
"titulo" to null,
|
||||
"artista" to null,
|
||||
"bitrate" to null,
|
||||
"sampleRate" to null,
|
||||
"artUri" to null
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
retriever.release()
|
||||
} catch (_: Throwable) {
|
||||
// release() failing is not actionable -- the retriever is
|
||||
// being discarded regardless.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
|
||||
* static review only -- Design ADR-1). Writes [picture] bytes to
|
||||
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
|
||||
* file already exists, so re-parsing the same track reuses it), returns
|
||||
* the `content://` URI served via the EXISTING
|
||||
* `${applicationId}.fileprovider` authority
|
||||
* (`AndroidManifest.xml`, `pluriwave_file_paths.xml`'s
|
||||
* `cache-path path="."`) and trims `pluriwave_art/` via [trimArtCache].
|
||||
* `hash` uses SHA-256 hex because a raw `documentId` may contain
|
||||
* `:`/`/`, which are illegal in filenames on most filesystems.
|
||||
*/
|
||||
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
|
||||
return try {
|
||||
val artDir = File(context.cacheDir, "pluriwave_art").apply { mkdirs() }
|
||||
val artFile = File(artDir, hashDocumentId(documentId))
|
||||
if (!artFile.exists()) {
|
||||
artFile.writeBytes(picture)
|
||||
}
|
||||
trimArtCache(artDir)
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
artFile
|
||||
).toString()
|
||||
} catch (error: Throwable) {
|
||||
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hashDocumentId(documentId: String): String {
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
|
||||
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
|
||||
* total, deleting the OLDEST-by-mtime entries first. Kept as a
|
||||
* trivially reviewable loop -- these files are native-owned, so
|
||||
* round-tripping names to Dart to pick deletions would add channel
|
||||
* chatter with no testability gain (the `delete()` is native
|
||||
* regardless, per ADR-1's rationale).
|
||||
*/
|
||||
private fun trimArtCache(artDir: File) {
|
||||
val maxArchivos = 256
|
||||
val maxBytes = 32L * 1024 * 1024
|
||||
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
|
||||
?: return
|
||||
var totalBytes = archivos.sumOf { it.length() }
|
||||
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
|
||||
val masViejo = archivos.removeAt(archivos.size - 1)
|
||||
totalBytes -= masViejo.length()
|
||||
masViejo.delete()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "pluriwave/file_actions"
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package es.freetimelab.pluriwave.fileactions
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Registra `pluriwave/file_actions` en TODOS los FlutterEngine de la app.
|
||||
*
|
||||
* ## Por que un paquete plugin y no una clase del modulo de app
|
||||
*
|
||||
* `AudioServicePlugin.getFlutterEngine` (audio_service 0.18.18,
|
||||
* `AudioServicePlugin.java:70-75`) construye el engine compartido con
|
||||
* `new FlutterEngine(context.getApplicationContext())`. Ese constructor
|
||||
* encadena hasta el maestro con `automaticallyRegisterPlugins = true`
|
||||
* (verificado en el bytecode de `FlutterEngine`: `FlutterEngine(Context)` ->
|
||||
* `FlutterEngine(Context, String[])` con `iconst_1`) y ejecuta
|
||||
* `GeneratedPluginRegister.registerGeneratedPlugins(this)`, que reflexiona
|
||||
* sobre `io.flutter.plugins.GeneratedPluginRegistrant`.
|
||||
*
|
||||
* Es decir: el engine headless registra PLUGINS por si mismo. Por eso
|
||||
* `shared_preferences` y `just_audio` ya funcionan cuando Android Auto arranca
|
||||
* la app con el movil bloqueado, y por eso un handler instalado unicamente en
|
||||
* `MainActivity.configureFlutterEngine` no podia funcionar nunca ahi: sin
|
||||
* Activity, `configureFlutterEngine` jamas se ejecuta, el canal se queda sin
|
||||
* handler y cada `invokeMethod` lanza `MissingPluginException`. El nodo
|
||||
* "Musica Local" desaparecia del arbol del coche.
|
||||
*
|
||||
* ## Reparto con MainActivity (decision deliberada)
|
||||
*
|
||||
* Este plugin atiende SOLO los cuatro metodos que no necesitan Activity
|
||||
* ([FileActionsHandler.manejar]) y responde `notImplemented()` al resto, que es
|
||||
* la respuesta correcta en un engine sin Activity: `pickMusicFolder`,
|
||||
* `openDirectory`, `viewDirectory` y `openFile` no pueden funcionar sin una.
|
||||
*
|
||||
* En el engine CON Activity ambos escriben en el mismo canal, y gana el
|
||||
* ultimo: el orden es estructural, no casual. `GeneratedPluginRegistrant` corre
|
||||
* dentro del constructor de `FlutterEngine`, o sea antes de que el engine
|
||||
* exista como argumento; `FlutterActivityAndFragmentDelegate.onAttach` llama a
|
||||
* `host.configureFlutterEngine(flutterEngine)` despues, necesariamente con un
|
||||
* engine ya construido. Asi que en una Activity siempre gana el handler
|
||||
* combinado de `MainActivity`, que es el superconjunto: delega los cuatro
|
||||
* metodos SAF en esta MISMA clase [FileActionsHandler] y añade los suyos.
|
||||
*
|
||||
* Se descarto hacer el plugin `ActivityAware` y moverle tambien los metodos con
|
||||
* Activity: obligaria a trasladar ~400 lineas de malabares de `Intent`
|
||||
* (FileProvider, DocumentsUI, fallbacks de `ACTION_VIEW`) mas el round trip de
|
||||
* `onActivityResult`, todo ello sin cobertura de `flutter test`, para arreglar
|
||||
* un bug que no los toca. El reparto de arriba deja UNA sola implementacion de
|
||||
* la logica compartida, que era el objetivo real.
|
||||
*/
|
||||
class PluriWaveFileActionsPlugin : FlutterPlugin {
|
||||
|
||||
private var canal: MethodChannel? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
// applicationContext a proposito: los cuatro metodos solo usan el
|
||||
// ContentResolver y la cacheDir del proceso, asi que sobreviven a
|
||||
// cualquier Activity y valen igual en el engine headless.
|
||||
val handler = FileActionsHandler(binding.applicationContext)
|
||||
canal = MethodChannel(binding.binaryMessenger, FileActionsHandler.CHANNEL).apply {
|
||||
setMethodCallHandler { call, result ->
|
||||
if (!handler.manejar(call, result)) {
|
||||
handler.trazarNoDisponibleSinActividad(call.method)
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
// Solo ocurre al destruir el engine, de modo que nunca puede pisar el
|
||||
// handler combinado que instala MainActivity sobre este mismo canal.
|
||||
canal?.setMethodCallHandler(null)
|
||||
canal = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Este paquete NO expone API Dart.
|
||||
///
|
||||
/// Existe por una sola razon estructural: un `MethodChannel` registrado desde
|
||||
/// el modulo de aplicacion (`MainActivity.configureFlutterEngine`) solo vive en
|
||||
/// el engine que tiene Activity. `audio_service` construye ademas un
|
||||
/// FlutterEngine *headless* (`AudioServicePlugin.getFlutterEngine`, que llama a
|
||||
/// `new FlutterEngine(context.getApplicationContext())`) cuando Android Auto
|
||||
/// enlaza el `MediaBrowserService` con la app cerrada. Ese constructor invoca
|
||||
/// `GeneratedPluginRegister.registerGeneratedPlugins`, que reflexiona sobre
|
||||
/// `io.flutter.plugins.GeneratedPluginRegistrant`; es decir, registra los
|
||||
/// PLUGINS, nunca una clase suelta del modulo de app.
|
||||
///
|
||||
/// Empaquetando el lado nativo aqui, `GeneratedPluginRegistrant` lo instala en
|
||||
/// los dos engines sin tocar el manifiesto ni forkear `audio_service`.
|
||||
///
|
||||
/// Los llamantes Dart siguen usando `MethodChannel('pluriwave/file_actions')`
|
||||
/// directamente (`lib/servicios/musica_local_auto.dart`,
|
||||
/// `lib/estado/estado_grabacion.dart`), asi que este fichero se queda vacio a
|
||||
/// proposito: cualquier fachada aqui seria una segunda forma de decir lo mismo.
|
||||
library;
|
||||
@@ -0,0 +1,23 @@
|
||||
name: pluriwave_file_actions
|
||||
description: >-
|
||||
Canal nativo `pluriwave/file_actions` de PluriWave empaquetado como plugin
|
||||
Flutter, para que quede registrado en TODOS los FlutterEngine de la app --
|
||||
incluido el engine headless que audio_service crea cuando Android Auto
|
||||
arranca el MediaBrowserService sin Activity.
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: es.freetimelab.pluriwave.fileactions
|
||||
pluginClass: PluriWaveFileActionsPlugin
|
||||
+8
-1
@@ -366,7 +366,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
in_app_purchase_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_purchase_android
|
||||
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
||||
@@ -629,6 +629,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pluriwave_file_actions:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/pluriwave_file_actions"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+14
-1
@@ -1,7 +1,7 @@
|
||||
name: pluriwave
|
||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||
publish_to: 'none'
|
||||
version: 1.3.0+153
|
||||
version: 1.3.3+164
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
@@ -55,6 +55,19 @@ dependencies:
|
||||
|
||||
# In-app purchase
|
||||
in_app_purchase: ^3.2.0
|
||||
# Direct dependency only for `InAppPurchaseAndroidPlatformAddition
|
||||
# .queryPastPurchases` (silent license re-verification in
|
||||
# `ServicioComprasPlayBilling.consultarPropiedad`).
|
||||
in_app_purchase_android: ^0.5.0
|
||||
|
||||
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
|
||||
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
||||
# headless que audio_service crea al arrancar desde Android Auto. Sin esto
|
||||
# el canal solo existia en el engine de MainActivity y "Musica Local"
|
||||
# desaparecia del arbol del coche. No expone API Dart: los llamantes siguen
|
||||
# usando MethodChannel('pluriwave/file_actions').
|
||||
pluriwave_file_actions:
|
||||
path: packages/pluriwave_file_actions
|
||||
|
||||
# Song recognition (activar con AudD key)
|
||||
# permission_handler: ^11.3.1
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Play production-readiness guard over the declared Android permissions.
|
||||
///
|
||||
/// There is no Dart seam around `Geolocator` (it is a static facade, not an
|
||||
/// injectable port), so the only honest place to pin the permission surface
|
||||
/// is the manifest file itself. That is deliberate: the risk this guards is
|
||||
/// a *declaration* mismatch with the Data Safety form, which is a property
|
||||
/// of the manifest, not of any Dart call.
|
||||
void main() {
|
||||
late String manifiesto;
|
||||
|
||||
setUpAll(() {
|
||||
manifiesto =
|
||||
File('android/app/src/main/AndroidManifest.xml').readAsStringSync();
|
||||
});
|
||||
|
||||
bool declara(String permiso) =>
|
||||
manifiesto.contains('android.permission.$permiso');
|
||||
|
||||
group('permisos de ubicacion', () {
|
||||
test('NO declara ACCESS_FINE_LOCATION: la app solo resuelve un codigo ISO '
|
||||
'de pais y el Data Safety enviado declara ubicacion aproximada', () {
|
||||
expect(
|
||||
declara('ACCESS_FINE_LOCATION'),
|
||||
isFalse,
|
||||
reason:
|
||||
'The only location consumer is '
|
||||
'EstadoBusqueda.cargarEmisorasCercanas, which asks for '
|
||||
'LocationAccuracy.low and reduces the fix to '
|
||||
'Placemark.isoCountryCode. Declaring FINE contradicts the '
|
||||
'approved "approximate location" Data Safety declaration.',
|
||||
);
|
||||
});
|
||||
|
||||
test('SI declara ACCESS_COARSE_LOCATION: sin ninguno de los dos, el '
|
||||
'plugin lanza PermissionUndefinedException', () {
|
||||
expect(declara('ACCESS_COARSE_LOCATION'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/main.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import 'helpers/handlers_audio.dart';
|
||||
|
||||
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
|
||||
///
|
||||
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
|
||||
/// `didChangeDependencies`, con un latch de un solo uso y este comentario:
|
||||
/// «Que exista una View significa que hay Activity». La premisa es FALSA.
|
||||
///
|
||||
/// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de
|
||||
/// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay
|
||||
/// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que
|
||||
/// en el motor headless que `audio_service` levanta sin Activity —el mismo
|
||||
/// que demostrablemente llega a `runApp`, ver la doc de
|
||||
/// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el
|
||||
/// PRIMER `didChangeDependencies`.
|
||||
///
|
||||
/// Consecuencia: el latch se gastaba durante el arranque headless, justo en
|
||||
/// el instante en que no podía conseguir nada (`_childrenSubjects` sigue
|
||||
/// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`,
|
||||
/// un no-op silencioso). Y no podía volver a dispararse nunca, porque
|
||||
/// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una
|
||||
/// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en
|
||||
/// los dos motores.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
|
||||
test('resumed + coche ya suscrito + latch libre invalida', () {
|
||||
expect(
|
||||
debeInvalidarArbolAutoAlReanudar(
|
||||
estado: AppLifecycleState.resumed,
|
||||
hayCocheSuscrito: true,
|
||||
yaInvalidado: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('sin suscripción del coche NO invalida — y por tanto no gasta el '
|
||||
'latch en el arranque headless', () {
|
||||
expect(
|
||||
debeInvalidarArbolAutoAlReanudar(
|
||||
estado: AppLifecycleState.resumed,
|
||||
hayCocheSuscrito: false,
|
||||
yaInvalidado: false,
|
||||
),
|
||||
isFalse,
|
||||
reason:
|
||||
'notificarHijosCambiaron solo empuja a un sujeto que ya existe, '
|
||||
'así que invalidar antes de que el coche se suscriba a NADA es '
|
||||
'demostrablemente un no-op',
|
||||
);
|
||||
});
|
||||
|
||||
test('ningún estado del ciclo de vida distinto de resumed invalida', () {
|
||||
for (final estado in [
|
||||
AppLifecycleState.detached,
|
||||
AppLifecycleState.inactive,
|
||||
AppLifecycleState.hidden,
|
||||
AppLifecycleState.paused,
|
||||
]) {
|
||||
expect(
|
||||
debeInvalidarArbolAutoAlReanudar(
|
||||
estado: estado,
|
||||
hayCocheSuscrito: true,
|
||||
yaInvalidado: false,
|
||||
),
|
||||
isFalse,
|
||||
reason:
|
||||
'$estado no significa «hay una Activity adjunta en primer '
|
||||
'plano»; solo resumed lo significa',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('con el latch ya gastado no vuelve a invalidar (nada de tormenta '
|
||||
'de notificaciones)', () {
|
||||
expect(
|
||||
debeInvalidarArbolAutoAlReanudar(
|
||||
estado: AppLifecycleState.resumed,
|
||||
hayCocheSuscrito: true,
|
||||
yaInvalidado: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('OrientacionResponsiveApp — cableado real del disparador', () {
|
||||
testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es '
|
||||
'exactamente por qué el disparador anterior no valía', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
||||
);
|
||||
|
||||
expect(
|
||||
View.maybeOf(tester.element(find.byType(SizedBox))),
|
||||
isNotNull,
|
||||
reason:
|
||||
'wrapWithDefaultView envuelve el árbol en una View o lanza '
|
||||
'StateError: no hay ningún motor bajo runApp sin View',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
|
||||
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
|
||||
'para cuando el coche por fin navegue', (tester) async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
var invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
invalidaciones,
|
||||
0,
|
||||
reason: 'el primer frame no prueba que haya Activity',
|
||||
);
|
||||
|
||||
// Incluso si un evento de ciclo de vida llegara en frío: el coche no
|
||||
// ha navegado nada todavía, así que no hay ningún sujeto al que
|
||||
// empujar y el latch debe sobrevivir.
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
expect(invalidaciones, 0);
|
||||
|
||||
// Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la
|
||||
// siguiente vuelta a primer plano sí encuentra algo que invalidar.
|
||||
handler.subscribeToChildren(AudioService.browsableRootId);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(invalidaciones, 1);
|
||||
});
|
||||
|
||||
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
|
||||
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
// El coche navegó la raíz durante el arranque headless: el sujeto
|
||||
// existe y el head unit tiene el listado cacheado.
|
||||
final eventos = <Map<String, dynamic>>[];
|
||||
final sub = handler
|
||||
.subscribeToChildren(AudioService.browsableRootId)
|
||||
.listen(eventos.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
eventos,
|
||||
isEmpty,
|
||||
reason: 'todavía no hay Activity, solo una View',
|
||||
);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
eventos,
|
||||
hasLength(1),
|
||||
reason:
|
||||
'esta es la ÚNICA vía de recuperación cuando el registro del '
|
||||
'canal pluriwave/file_actions falló en el motor headless',
|
||||
);
|
||||
|
||||
// Y no una por cada rebote de ciclo de vida.
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(eventos, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('hayCocheSuscritoAlArbol', () {
|
||||
test('es false sin handler suscrito y true en cuanto el coche navega un '
|
||||
'id', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
expect(hayCocheSuscritoAlArbol(), isFalse);
|
||||
|
||||
handler.subscribeToChildren(AudioService.browsableRootId);
|
||||
|
||||
expect(hayCocheSuscritoAlArbol(), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall):
|
||||
/// `EstadoRadio.importarConfig` writes the imported alarm block straight to
|
||||
/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived
|
||||
/// `ChangeNotifier` that loaded its alarms into memory at construction and
|
||||
/// never re-reads on its own. These tests exercise the EXACT sequence the
|
||||
/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs
|
||||
/// after a successful import: `EstadoRadio.importarConfig` followed by
|
||||
/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` +
|
||||
/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker
|
||||
/// platform channel and the confirmation dialog, which are pure UI
|
||||
/// plumbing already covered by `pantalla_ajustes_backup_test.dart`.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// A PRIVATE per-test file, never the shared `test/fixtures/` one:
|
||||
// `EstadoRadio.importarConfig` unconditionally calls
|
||||
// `_guardarEmisorasCustom()`, which WRITES to whatever
|
||||
// `resolverArchivoCustom` resolves to — pointing that at the shared
|
||||
// fixture previously clobbered its committed BOM on disk as a side
|
||||
// effect of running this file's tests.
|
||||
tempDir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave_estado_alarmas_import_test',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
Future<File> archivoCustomVacio() async {
|
||||
final file = File('${tempDir.path}/emisoras_custom.json');
|
||||
if (!file.existsSync()) {
|
||||
await file.writeAsString('[]');
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
Map<String, dynamic> jsonAlarma(AlarmaMusical a) => {
|
||||
'id': a.id,
|
||||
'nombre': a.nombre,
|
||||
'activa': a.activa,
|
||||
'hora': a.hora,
|
||||
'minuto': a.minuto,
|
||||
'tipoProgramacion': a.tipoProgramacion.name,
|
||||
'diasSemana': a.diasSemana,
|
||||
};
|
||||
|
||||
const alarmaVieja = AlarmaMusical(
|
||||
id: 'vieja',
|
||||
nombre: 'Alarma vieja (pre-import)',
|
||||
hora: 6,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [1, 2, 3, 4, 5],
|
||||
);
|
||||
|
||||
const alarmaImportada = AlarmaMusical(
|
||||
id: 'importada',
|
||||
nombre: 'Alarma importada',
|
||||
hora: 8,
|
||||
minuto: 15,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [6, 7],
|
||||
);
|
||||
|
||||
/// Builds the pair the app wires together: `EstadoRadio` (owns
|
||||
/// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload +
|
||||
/// re-scheduling this bugfix adds), sharing ONE `SharedPreferences`
|
||||
/// instance exactly like the real app's provider tree does.
|
||||
Future<
|
||||
({
|
||||
EstadoRadio radio,
|
||||
EstadoAlarmas alarmas,
|
||||
FakePuertoAlarmasAndroid android,
|
||||
})
|
||||
>
|
||||
crearPar() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
'alarmas_musicales_v1',
|
||||
jsonEncode({
|
||||
'alarmas': [jsonAlarma(alarmaVieja)],
|
||||
'vacaciones': [],
|
||||
'excepciones': [],
|
||||
}),
|
||||
);
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final alarmas = EstadoAlarmas(
|
||||
esPremium: () => true,
|
||||
servicio: ServicioAlarmas(prefs: prefs),
|
||||
android: android,
|
||||
prefs: prefs,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
// Loads + native-syncs the pre-import alarm WITHOUT arming
|
||||
// `inicializar()`'s periodic timers (irrelevant to this bugfix and a
|
||||
// needless liability for a `flutter test` run).
|
||||
await alarmas.refrescarProgramacion();
|
||||
|
||||
final radio = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: archivoCustomVacio,
|
||||
prefs: prefs,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
|
||||
return (radio: radio, alarmas: alarmas, android: android);
|
||||
}
|
||||
|
||||
Map<String, dynamic> backupCon({
|
||||
required List<AlarmaMusical> alarmas,
|
||||
List<Map<String, dynamic>> vacaciones = const [],
|
||||
List<Map<String, dynamic>> excepciones = const [],
|
||||
String ordenListas = 'nombre',
|
||||
}) => {
|
||||
'version': 2,
|
||||
'gruposFavoritos': [],
|
||||
'favoritos': [],
|
||||
'emisorasCustom': [],
|
||||
'presetsEcualizador': {},
|
||||
'alarmas': {
|
||||
'alarmas': alarmas.map(jsonAlarma).toList(),
|
||||
'vacaciones': vacaciones,
|
||||
'excepciones': excepciones,
|
||||
},
|
||||
'emisoraPreferidaUuid': null,
|
||||
'ordenListas': ordenListas,
|
||||
'timerSuenoPresetsSegundos': <int>[300, 600],
|
||||
};
|
||||
|
||||
test('after import, EstadoAlarmas reflects the imported alarms, not the '
|
||||
'pre-import ones', () async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
|
||||
|
||||
await aplicarImportacionConfig(
|
||||
par.radio,
|
||||
par.alarmas,
|
||||
backupCon(alarmas: [alarmaImportada]),
|
||||
);
|
||||
|
||||
expect(par.alarmas.alarmas.map((a) => a.id), ['importada']);
|
||||
expect(par.alarmas.alarmas.single.nombre, 'Alarma importada');
|
||||
});
|
||||
|
||||
test('native re-scheduling is triggered after an import', () async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
// Sanity: the pre-import alarm was already scheduled.
|
||||
expect(par.android.programadas.map((a) => a.id), contains('vieja'));
|
||||
|
||||
await aplicarImportacionConfig(
|
||||
par.radio,
|
||||
par.alarmas,
|
||||
backupCon(alarmas: [alarmaImportada]),
|
||||
);
|
||||
|
||||
// The imported alarm was handed to the native Android bridge — this is
|
||||
// what makes it actually ring, not just appear in the list.
|
||||
expect(par.android.programadas.map((a) => a.id), contains('importada'));
|
||||
});
|
||||
|
||||
test(
|
||||
'vacation ranges and alarm exceptions in the same block come back too',
|
||||
() async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
expect(par.alarmas.vacaciones, isEmpty);
|
||||
expect(par.alarmas.excepciones, isEmpty);
|
||||
|
||||
await aplicarImportacionConfig(
|
||||
par.radio,
|
||||
par.alarmas,
|
||||
backupCon(
|
||||
alarmas: [alarmaImportada],
|
||||
vacaciones: [
|
||||
{
|
||||
'id': 'vac1',
|
||||
'nombre': 'Verano',
|
||||
'inicio': '2026-07-01T00:00:00.000',
|
||||
'fin': '2026-07-15T00:00:00.000',
|
||||
'activo': true,
|
||||
},
|
||||
],
|
||||
excepciones: [
|
||||
{
|
||||
'alarmaId': 'importada',
|
||||
'ejecucion': '2026-08-30T08:15:00.000',
|
||||
'tipo': 'skipNext',
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']);
|
||||
expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']);
|
||||
},
|
||||
);
|
||||
|
||||
test('a failed import (e.g. malformed/unsupported version) leaves existing '
|
||||
'alarms untouched', () async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
final backupNoSoportado = backupCon(alarmas: [alarmaImportada])
|
||||
..['version'] = 99;
|
||||
|
||||
// Runs the SAME production function the call site uses: a throw from
|
||||
// `importarConfig` must propagate before either reload call runs.
|
||||
await expectLater(
|
||||
aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado),
|
||||
throwsA(anything),
|
||||
);
|
||||
|
||||
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
|
||||
expect(par.android.programadas.map((a) => a.id), ['vieja']);
|
||||
});
|
||||
|
||||
test('a cancelled import (dialog declined, importarConfig never called) '
|
||||
'leaves existing alarms untouched', () async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
// Simulates the user declining the confirm dialog: the call site
|
||||
// returns before `importarConfig` and the two reload calls ever run.
|
||||
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
|
||||
expect(par.android.programadas.map((a) => a.id), ['vieja']);
|
||||
});
|
||||
|
||||
test('regression: importing still restores preferences (ordenListas) '
|
||||
'exactly as before', () async {
|
||||
final par = await crearPar();
|
||||
addTearDown(par.radio.dispose);
|
||||
addTearDown(par.alarmas.dispose);
|
||||
addTearDown(par.android.dispose);
|
||||
|
||||
await aplicarImportacionConfig(
|
||||
par.radio,
|
||||
par.alarmas,
|
||||
backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'),
|
||||
);
|
||||
|
||||
expect(par.radio.ordenListas.name, 'nombre');
|
||||
});
|
||||
}
|
||||
@@ -1569,6 +1569,83 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'EstadoEcualizador — importarConfiguracion(activo:) '
|
||||
'(equalizer on/off export/import gap)',
|
||||
() {
|
||||
test(
|
||||
'activo: false turns the equalizer off — persisted AND pushed to '
|
||||
'the live audio engine (reuses cambiarActivo, not a bare field set)',
|
||||
() async {
|
||||
final servicio = FakeServicioEcualizador(activo: true);
|
||||
final audio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
|
||||
await eq.cargarPersistido();
|
||||
audio.cambiosEcualizadorActivo.clear();
|
||||
|
||||
await eq.importarConfiguracion(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {},
|
||||
activo: false,
|
||||
);
|
||||
|
||||
expect(eq.activo, isFalse);
|
||||
expect(audio.cambiosEcualizadorActivo, contains(false));
|
||||
expect(servicio.config.activo, isFalse);
|
||||
expect(servicio.guardarActivoLlamadas, 1);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'activo: true turns the equalizer on — persisted AND pushed to '
|
||||
'the live audio engine',
|
||||
() async {
|
||||
final servicio = FakeServicioEcualizador(activo: false);
|
||||
final audio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
|
||||
await eq.cargarPersistido();
|
||||
audio.cambiosEcualizadorActivo.clear();
|
||||
|
||||
await eq.importarConfiguracion(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {},
|
||||
activo: true,
|
||||
);
|
||||
|
||||
expect(eq.activo, isTrue);
|
||||
expect(audio.cambiosEcualizadorActivo, contains(true));
|
||||
expect(servicio.config.activo, isTrue);
|
||||
expect(servicio.guardarActivoLlamadas, 1);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'activo: null (old backup, no field) leaves the current toggle '
|
||||
'untouched and does not persist anything for it',
|
||||
() async {
|
||||
final servicio = FakeServicioEcualizador(activo: false);
|
||||
final audio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
|
||||
await eq.cargarPersistido();
|
||||
audio.cambiosEcualizadorActivo.clear();
|
||||
|
||||
await eq.importarConfiguracion(
|
||||
principal: PresetEcualizador.flat,
|
||||
porEmisora: {},
|
||||
// activo omitted — simulates a pre-v4 backup.
|
||||
);
|
||||
|
||||
expect(eq.activo, isFalse);
|
||||
expect(audio.cambiosEcualizadorActivo, isEmpty);
|
||||
expect(servicio.guardarActivoLlamadas, 0);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('EstadoEcualizador — bonded Bluetooth names', () {
|
||||
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
|
||||
|
||||
@@ -1728,6 +1805,198 @@ void main() {
|
||||
eq.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// eq-sync-superficies: car/notification-initiated EQ changes must reach
|
||||
// EstadoEcualizador (and persist through ServicioEcualizador), not just
|
||||
// the audio handler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — resync with handler-initiated EQ changes '
|
||||
'(eq-sync-superficies)', () {
|
||||
test(
|
||||
'a handler-initiated toggle (car/notification) syncs activo and '
|
||||
'notifies listeners',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(activo: true),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
expect(eq.activo, isTrue);
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
// Simulates `accionEqToggle` calling
|
||||
// `PluriWaveAudioHandler.setEcualizadorActivo` directly, bypassing
|
||||
// `ServicioAudio`/`EstadoEcualizador` entirely.
|
||||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(eq.activo, isFalse);
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a handler-initiated preset change (Android Auto) syncs presetActual '
|
||||
'and notifies listeners',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(principal: PresetEcualizador.flat),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
expect(eq.presetActual, equals(PresetEcualizador.flat));
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
// Simulates `seleccionarPresetEqPorMediaId` calling
|
||||
// `PluriWaveAudioHandler.aplicarPreset` directly.
|
||||
fakeAudio.simularCambioEqDesdeHandler(preset: PresetEcualizador.jazz);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(eq.presetActual, equals(PresetEcualizador.jazz));
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a handler-initiated toggle is ADOPTED for display and NOT written '
|
||||
'again from here (eq-estado-unico: the handler owns the write)',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||||
await eq.cargarPersistido();
|
||||
|
||||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(
|
||||
eq.activo,
|
||||
isFalse,
|
||||
reason: 'the phone toggle must show what the engine is really doing',
|
||||
);
|
||||
expect(
|
||||
fakeServicio.guardarActivoLlamadas,
|
||||
equals(0),
|
||||
reason:
|
||||
'persistence moved to PluriWaveAudioHandler itself, because '
|
||||
'this resync only exists while an EstadoEcualizador does — and '
|
||||
'on the headless Android Auto engine that produced the bug, '
|
||||
'none ever does. A second write from here would be a second '
|
||||
'owner of the same fact.',
|
||||
);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'resync does not cause an extra handler write (no feedback loop)',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final eq = EstadoEcualizador(
|
||||
audio: fakeAudio,
|
||||
servicio: FakeServicioEcualizador(activo: true),
|
||||
);
|
||||
await eq.cargarPersistido();
|
||||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||||
fakeAudio.presetsAplicados.clear();
|
||||
|
||||
fakeAudio.simularCambioEqDesdeHandler(activo: false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
// The resync must only read from `audio` and write to `servicio` —
|
||||
// never write BACK into `audio`, or a handler write would trigger
|
||||
// another stream tick, which would resync again, forever.
|
||||
expect(fakeAudio.cambiosEcualizadorActivo, isEmpty);
|
||||
expect(fakeAudio.presetsAplicados, isEmpty);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a UI-initiated toggle still works exactly as before and persists '
|
||||
'exactly once (regression)',
|
||||
() async {
|
||||
final fakeAudio = FakeServicioAudio();
|
||||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||||
await eq.cargarPersistido();
|
||||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||||
|
||||
await eq.cambiarActivo(false);
|
||||
// Give any (harmless, no-op) resync tick a chance to run too.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(eq.activo, isFalse);
|
||||
expect(fakeServicio.config.activo, isFalse);
|
||||
expect(fakeServicio.guardarActivoLlamadas, equals(1));
|
||||
expect(fakeAudio.cambiosEcualizadorActivo, equals([false]));
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The handler REJECTED the toggle (native setEnabled threw): the handler
|
||||
// rolls its own flag back, so this class must not keep — nor persist — a
|
||||
// value the engine refused.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
|
||||
test(
|
||||
'adopta el valor real del handler y NO persiste el valor rechazado',
|
||||
() async {
|
||||
final fakeAudio = _FakeAudioEqRechazaConmutacion();
|
||||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||||
await eq.cargarPersistido();
|
||||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||||
fakeServicio.guardarActivoLlamadas = 0;
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
await eq.cambiarActivo(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
// The native call failed, so the handler kept the equalizer ON.
|
||||
expect(fakeAudio.ecualizadorActivo, isTrue);
|
||||
expect(
|
||||
eq.activo,
|
||||
isTrue,
|
||||
reason: 'the UI must show what the engine really does',
|
||||
);
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
expect(
|
||||
fakeServicio.guardarActivoLlamadas,
|
||||
equals(0),
|
||||
reason: 'a rejected value must never reach disk',
|
||||
);
|
||||
expect(fakeServicio.config.activo, isTrue);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fake handler that REFUSES every on/off change: it records the call (the
|
||||
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
|
||||
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
|
||||
/// rolling its flag back when the native `setEnabled` throws.
|
||||
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
|
||||
@override
|
||||
Future<void> setEcualizadorActivo(bool activo) async {
|
||||
cambiosEcualizadorActivo.add(activo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fake whose [guardarActivo] stays pending until released, and releases the
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart'
|
||||
show registrarInvalidacionArbolAuto;
|
||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
||||
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||
int comprasIntentadas = 0;
|
||||
int restaurosIntentados = 0;
|
||||
int consultasPropiedad = 0;
|
||||
|
||||
/// What the silent ownership query answers. Defaults to [desconocido] so
|
||||
/// every pre-existing test keeps its old behavior (fail-open: no change).
|
||||
ResultadoVerificacionLicencia propiedad =
|
||||
ResultadoVerificacionLicencia.desconocido;
|
||||
|
||||
@override
|
||||
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
restaurosIntentados++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||
consultasPropiedad++;
|
||||
return propiedad;
|
||||
}
|
||||
|
||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
}
|
||||
|
||||
/// Mutable clock for the throttle/spacing rules of the license check.
|
||||
class _Reloj {
|
||||
DateTime ahora = DateTime(2026, 9, 18, 10);
|
||||
|
||||
DateTime call() => ahora;
|
||||
}
|
||||
|
||||
/// Lets every microtask/async continuation of the fire-and-forget license
|
||||
/// check settle (mock prefs + fake port complete immediately).
|
||||
Future<void> _asentar() async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -159,6 +189,34 @@ void main() {
|
||||
expect(estado.compraEnCurso, isFalse);
|
||||
});
|
||||
|
||||
test('tras restaurar() sin compras el paywall NO queda bloqueado: se '
|
||||
'puede volver a comprar', () async {
|
||||
final compras = _PuertoComprasFalso();
|
||||
addTearDown(compras.dispose);
|
||||
final estado = EstadoEntitlement(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
compras: compras,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
unawaited(estado.restaurar());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(estado.compraEnCurso, isTrue);
|
||||
|
||||
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart`
|
||||
// (comprar y restaurar): si se queda pegado en `true`, el usuario ya no
|
||||
// puede pagar nunca más.
|
||||
expect(estado.compraEnCurso, isFalse);
|
||||
|
||||
unawaited(estado.comprar());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(compras.comprasIntentadas, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'un error en el flujo de compra no bloquea al pagador (fail-open)',
|
||||
() async {
|
||||
@@ -299,6 +357,135 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('verificacion silenciosa de licencia (reembolsos)', () {
|
||||
late _PuertoComprasFalso compras;
|
||||
late _Reloj reloj;
|
||||
late int invalidaciones;
|
||||
|
||||
setUp(() {
|
||||
compras = _PuertoComprasFalso();
|
||||
reloj = _Reloj();
|
||||
invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
registrarInvalidacionArbolAuto(() {});
|
||||
await compras.dispose();
|
||||
});
|
||||
|
||||
Future<EstadoEntitlement> crear({required bool premium}) async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': premium});
|
||||
final estado = EstadoEntitlement(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
compras: compras,
|
||||
reloj: reloj.call,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await _asentar();
|
||||
return estado;
|
||||
}
|
||||
|
||||
test('se dispara sola al cargar, sin bloquear la carga', () async {
|
||||
final estado = await crear(premium: true);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(compras.consultasPropiedad, 1);
|
||||
});
|
||||
|
||||
test('desconocido (offline) conserva premium sin notificar nada', () async {
|
||||
final estado = await crear(premium: true);
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
reloj.ahora = reloj.ahora.add(const Duration(days: 2));
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(notificaciones, 0);
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('una sola ausencia no revoca', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: true);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
});
|
||||
|
||||
test('revocacion confirmada: notifica, invalida el arbol de Auto y NUNCA '
|
||||
'toca resultadoUsuario ni compraEnCurso', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// A restore the user started stays in flight, untouched.
|
||||
unawaited(estado.restaurar());
|
||||
await _asentar();
|
||||
expect(estado.compraEnCurso, isTrue);
|
||||
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
reloj.ahora = reloj.ahora.add(const Duration(days: 1));
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isFalse);
|
||||
expect(prefs.getBool('compra_premium_v1'), isFalse);
|
||||
expect(notificaciones, greaterThan(0));
|
||||
expect(invalidaciones, 1);
|
||||
expect(estado.resultadoUsuario, isNull);
|
||||
expect(estado.compraEnCurso, isTrue);
|
||||
});
|
||||
|
||||
test('poseida con la flag en false desbloquea en silencio', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.poseida;
|
||||
final estado = await crear(premium: false);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(invalidaciones, 1);
|
||||
expect(estado.resultadoUsuario, isNull);
|
||||
expect(estado.compraEnCurso, isFalse);
|
||||
});
|
||||
|
||||
test('refrescarLicencia recoge un cambio hecho por otra via (Android '
|
||||
'Auto) en prefs', () async {
|
||||
final estado = await crear(premium: true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setBool('compra_premium_v1', false);
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isFalse);
|
||||
});
|
||||
|
||||
test('una compra real reinicia el contador de ausencias', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: false);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(claveAusenciasLicencia, 1);
|
||||
|
||||
compras.emitir(const EventoCompra(TipoEventoCompra.comprada));
|
||||
await _asentar();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('sin puerto de compras no verifica nada', () async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
final estado = EstadoEntitlement(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
reloj: reloj.call,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await _asentar();
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
||||
test('lee la flag persistida directamente desde prefs', () async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Closes the last remaining export/import gap: `EstadoEcualizador._activo`
|
||||
/// (the equalizer's global ON/OFF toggle) was not part of the backup
|
||||
/// envelope at all, so restoring a backup on another device silently kept
|
||||
/// whatever that device's toggle happened to be. These tests exercise the
|
||||
/// flag end to end through `EstadoRadio.exportarConfig`/`importarConfig`,
|
||||
/// the real call sites `pantalla_ajustes_backup.dart` uses.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// A PRIVATE per-test file, never the shared `test/fixtures/` one — see
|
||||
// `estado_alarmas_import_test.dart` for why: `importarConfig`
|
||||
// unconditionally writes to whatever `resolverArchivoCustom` resolves to.
|
||||
tempDir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave_eq_activo_export_test',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
Future<EstadoRadio> crearRadio({bool ecualizadorActivo = true}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final archivoCustom = File('${tempDir.path}/emisoras_custom.json');
|
||||
if (!archivoCustom.existsSync()) {
|
||||
await archivoCustom.writeAsString('[]');
|
||||
}
|
||||
final radio = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(activo: ecualizadorActivo),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: () async => archivoCustom,
|
||||
prefs: prefs,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await radio.ecualizador.cargarPersistido();
|
||||
return radio;
|
||||
}
|
||||
|
||||
Map<String, dynamic> backupBase({
|
||||
required int version,
|
||||
bool? ecualizadorActivo,
|
||||
}) {
|
||||
final data = <String, dynamic>{
|
||||
'version': version,
|
||||
'gruposFavoritos': [],
|
||||
'favoritos': [],
|
||||
'emisorasCustom': [],
|
||||
'presetsEcualizador': {},
|
||||
'alarmas': null,
|
||||
'emisoraPreferidaUuid': null,
|
||||
'ordenListas': 'nombre',
|
||||
'timerSuenoPresetsSegundos': <int>[300, 600],
|
||||
};
|
||||
if (ecualizadorActivo != null) {
|
||||
data['ecualizadorActivo'] = ecualizadorActivo;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
group('EstadoRadio export/import — equalizer on/off toggle (v4)', () {
|
||||
test('exportarConfig includes the flag when the equalizer is ON', () async {
|
||||
final radio = await crearRadio(ecualizadorActivo: true);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final exportado = await radio.exportarConfig();
|
||||
|
||||
expect(exportado['ecualizadorActivo'], isTrue);
|
||||
expect(exportado['version'], 4);
|
||||
});
|
||||
|
||||
test(
|
||||
'exportarConfig includes the flag when the equalizer is OFF',
|
||||
() async {
|
||||
final radio = await crearRadio(ecualizadorActivo: false);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final exportado = await radio.exportarConfig();
|
||||
|
||||
expect(exportado['ecualizadorActivo'], isFalse);
|
||||
expect(exportado['version'], 4);
|
||||
},
|
||||
);
|
||||
|
||||
test('importarConfig(activo: false) turns the equalizer off — persisted '
|
||||
'and reflected in EstadoEcualizador.activo', () async {
|
||||
final radio = await crearRadio(ecualizadorActivo: true);
|
||||
addTearDown(radio.dispose);
|
||||
expect(radio.ecualizador.activo, isTrue);
|
||||
|
||||
await radio.importarConfig(
|
||||
backupBase(version: 4, ecualizadorActivo: false),
|
||||
);
|
||||
|
||||
expect(radio.ecualizador.activo, isFalse);
|
||||
});
|
||||
|
||||
test('importarConfig(activo: true) turns the equalizer on — persisted '
|
||||
'and reflected in EstadoEcualizador.activo', () async {
|
||||
final radio = await crearRadio(ecualizadorActivo: false);
|
||||
addTearDown(radio.dispose);
|
||||
expect(radio.ecualizador.activo, isFalse);
|
||||
|
||||
await radio.importarConfig(
|
||||
backupBase(version: 4, ecualizadorActivo: true),
|
||||
);
|
||||
|
||||
expect(radio.ecualizador.activo, isTrue);
|
||||
});
|
||||
|
||||
test('importing an OLD backup (no ecualizadorActivo field) does not throw '
|
||||
'and leaves the current toggle untouched', () async {
|
||||
final radio = await crearRadio(ecualizadorActivo: false);
|
||||
addTearDown(radio.dispose);
|
||||
expect(radio.ecualizador.activo, isFalse);
|
||||
|
||||
await radio.importarConfig(backupBase(version: 2));
|
||||
|
||||
expect(radio.ecualizador.activo, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'full round-trip: export -> import restores the equalizer toggle',
|
||||
() async {
|
||||
final origen = await crearRadio(ecualizadorActivo: false);
|
||||
addTearDown(origen.dispose);
|
||||
final exportado = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio(ecualizadorActivo: true);
|
||||
addTearDown(destino.dispose);
|
||||
expect(destino.ecualizador.activo, isTrue);
|
||||
|
||||
await destino.importarConfig(exportado);
|
||||
|
||||
expect(destino.ecualizador.activo, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('regression: other fields still round-trip exactly as before '
|
||||
'(ordenListas, timerSuenoPresetsSegundos)', () async {
|
||||
final origen = await crearRadio();
|
||||
addTearDown(origen.dispose);
|
||||
await origen.guardarTimerSuenoPresetsSegundos([120, 900]);
|
||||
final exportado = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio();
|
||||
addTearDown(destino.dispose);
|
||||
await destino.importarConfig(exportado);
|
||||
|
||||
expect(destino.ordenListas.name, origen.ordenListas.name);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Copia de seguridad — ROUND TRIP de los grupos de favoritos y de la
|
||||
/// asignación emisora -> grupo.
|
||||
///
|
||||
/// Reportado desde el uso real: al restaurar una copia en otro dispositivo
|
||||
/// los grupos volvían VACÍOS y todas las emisoras aparecían en «Sin
|
||||
/// asignar». El sobre exportado siempre llevó ambas cosas (`gruposFavoritos`
|
||||
/// desde v2, y `grupo_id` dentro de cada entrada de `favoritos`, porque es
|
||||
/// una clave intrínseca de `Emisora.toMap()`); lo que fallaba era la
|
||||
/// APLICACIÓN del estado: `importarConfig` reusaba `ServicioFavoritos.agregar`,
|
||||
/// la primitiva de «marcar como favorita», que fuerza `sin_asignar` y un
|
||||
/// `orden` nuevo a propósito.
|
||||
///
|
||||
/// Por eso estos tests prueban el VIAJE COMPLETO (origen -> exportar ->
|
||||
/// destino limpio -> importar), no la forma del sobre: la forma ya estaba
|
||||
/// bien y aun así el usuario perdía sus grupos.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
tempDir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave_export_grupos_test',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
var contadorArchivos = 0;
|
||||
|
||||
Future<EstadoRadio> crearRadio() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Un archivo POR instancia: `importarConfig` escribe siempre en el que
|
||||
// resuelva `resolverArchivoCustom`, y origen y destino no pueden
|
||||
// compartirlo.
|
||||
final archivoCustom = File(
|
||||
'${tempDir.path}/emisoras_custom_${contadorArchivos++}.json',
|
||||
);
|
||||
if (!archivoCustom.existsSync()) {
|
||||
await archivoCustom.writeAsString('[]');
|
||||
}
|
||||
final radio = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: () async => archivoCustom,
|
||||
prefs: prefs,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await radio.ecualizador.cargarPersistido();
|
||||
return radio;
|
||||
}
|
||||
|
||||
Emisora emisora(String uuid, String nombre) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: nombre,
|
||||
url: 'https://example.com/$uuid.mp3',
|
||||
);
|
||||
|
||||
group('EstadoRadio export/import — grupos de favoritos', () {
|
||||
test('round trip: los grupos y la asignación de CADA emisora sobreviven '
|
||||
'al viaje origen -> copia -> destino limpio', () async {
|
||||
final origen = await crearRadio();
|
||||
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
|
||||
await origen.toggleFavorito(emisora('rock-2', 'Rock Dos'));
|
||||
await origen.toggleFavorito(emisora('jazz-1', 'Jazz Uno'));
|
||||
await origen.toggleFavorito(emisora('suelta', 'Sin grupo'));
|
||||
await origen.crearGrupoFavoritos('Rock');
|
||||
await origen.crearGrupoFavoritos('Jazz');
|
||||
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
|
||||
final jazz = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Jazz');
|
||||
await origen.asignarGrupoFavorito('rock-1', rock.id);
|
||||
await origen.asignarGrupoFavorito('rock-2', rock.id);
|
||||
await origen.asignarGrupoFavorito('jazz-1', jazz.id);
|
||||
|
||||
final copia = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio();
|
||||
await destino.importarConfig(copia);
|
||||
|
||||
// Los grupos vuelven, con su nombre y su orden.
|
||||
final gruposDestino = destino.gruposFavoritos;
|
||||
expect(
|
||||
gruposDestino.map((g) => g.id),
|
||||
containsAll(<String>[rock.id, jazz.id]),
|
||||
);
|
||||
expect(gruposDestino.firstWhere((g) => g.id == rock.id).nombre, 'Rock');
|
||||
expect(gruposDestino.firstWhere((g) => g.id == jazz.id).nombre, 'Jazz');
|
||||
|
||||
// Y la asignación de CADA emisora vuelve con ellos.
|
||||
String grupoDe(String uuid) =>
|
||||
destino.listaFavoritos.firstWhere((e) => e.uuid == uuid)
|
||||
.grupoFavoritosId;
|
||||
expect(grupoDe('rock-1'), rock.id);
|
||||
expect(grupoDe('rock-2'), rock.id);
|
||||
expect(grupoDe('jazz-1'), jazz.id);
|
||||
expect(grupoDe('suelta'), GrupoFavoritos.sinAsignarId);
|
||||
});
|
||||
|
||||
test('una copia ANTIGUA sin `gruposFavoritos` importa limpiamente y deja '
|
||||
'intactos los grupos que ya existen en el dispositivo', () async {
|
||||
final destino = await crearRadio();
|
||||
await destino.crearGrupoFavoritos('Mío');
|
||||
final propio = destino.gruposFavoritos.firstWhere(
|
||||
(g) => g.nombre == 'Mío',
|
||||
);
|
||||
await destino.toggleFavorito(emisora('local-1', 'Local Uno'));
|
||||
await destino.asignarGrupoFavorito('local-1', propio.id);
|
||||
|
||||
// v1: ni `gruposFavoritos` ni `alarmas` ni preferencias. La regla del
|
||||
// sobre es que un campo AUSENTE no toca ese estado.
|
||||
await destino.importarConfig(<String, dynamic>{
|
||||
'version': 1,
|
||||
'favoritos': <Map<String, dynamic>>[],
|
||||
'emisorasCustom': <Map<String, dynamic>>[],
|
||||
'presetsEcualizador': <String, dynamic>{},
|
||||
});
|
||||
|
||||
expect(destino.gruposFavoritos.any((g) => g.id == propio.id), isTrue);
|
||||
expect(
|
||||
destino.gruposFavoritos.firstWhere((g) => g.id == propio.id).nombre,
|
||||
'Mío',
|
||||
);
|
||||
expect(
|
||||
destino.listaFavoritos.firstWhere((e) => e.uuid == 'local-1')
|
||||
.grupoFavoritosId,
|
||||
propio.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('los grupos importados quedan visibles SIN reiniciar: importarConfig '
|
||||
'recarga la lista en memoria y notifica', () async {
|
||||
final origen = await crearRadio();
|
||||
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
|
||||
await origen.crearGrupoFavoritos('Rock');
|
||||
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
|
||||
await origen.asignarGrupoFavorito('rock-1', rock.id);
|
||||
final copia = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio();
|
||||
var notificaciones = 0;
|
||||
destino.addListener(() => notificaciones++);
|
||||
|
||||
await destino.importarConfig(copia);
|
||||
|
||||
expect(notificaciones, greaterThan(0));
|
||||
expect(destino.gruposFavoritos.any((g) => g.id == rock.id), isTrue);
|
||||
expect(
|
||||
destino.listaFavoritos.single.grupoFavoritosId,
|
||||
rock.id,
|
||||
reason:
|
||||
'la vista de favoritos agrupa por `grupoFavoritosId`: si la lista '
|
||||
'en memoria no se recarga tras restaurar los grupos, la pantalla '
|
||||
'sigue mostrando todo en «Sin asignar» hasta reiniciar',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -77,7 +78,7 @@ void main() {
|
||||
final porEmisora = {'fav-1': PresetEcualizador.rock};
|
||||
final estado = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(ecualizadorActivo: false),
|
||||
audio: FakeServicioAudio(ecualizadorDisponible: false),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(
|
||||
@@ -792,21 +793,16 @@ void main() {
|
||||
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
|
||||
'reproduce, sólo queda seleccionada', () async {
|
||||
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
|
||||
final estadoUno = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estadoUno.reproducir(emisora);
|
||||
await estadoUno.detenerReproduccion();
|
||||
// Lets the fire-and-forget persistence write settle before
|
||||
// spinning up the "restart" instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// The record is now written by the audio handler's `_cambiarFuente`
|
||||
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
|
||||
// `ultima_emisora_v1` and the only one that also exists on the headless
|
||||
// Android Auto engine — `EstadoRadio` used to write it too and no
|
||||
// longer does. Seeded through that same production function here, so
|
||||
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
|
||||
// a real payload instead of one a fake invented. The write itself is
|
||||
// covered end to end in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
|
||||
await guardarUltimaEmisoraPersistida(emisora);
|
||||
|
||||
final audioDos = FakeServicioAudio();
|
||||
final estadoDos = EstadoRadio(
|
||||
@@ -848,9 +844,18 @@ void main() {
|
||||
});
|
||||
|
||||
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
|
||||
'también se recuerda para la próxima instancia', () async {
|
||||
'deja de estar ensombrecida por la seleccion previa del telefono',
|
||||
() async {
|
||||
// The PERSISTENCE half of this scenario moved to the handler, which is
|
||||
// the only writer that exists on a car-only session — it is covered by
|
||||
// «playFromMediaId desde el coche persiste ESA emisora» in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
|
||||
// `EstadoRadio` still owns here, and what this test now pins, is the
|
||||
// shadowing fix: a car selection bypasses `reproducir()`, so without
|
||||
// the `estadoStream` listener `_emisoraSeleccionada` would keep
|
||||
// shadowing the car's station on the `emisoraActual` getter.
|
||||
final audio = _AudioControlado();
|
||||
final estadoUno = EstadoRadio(
|
||||
final estado = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
@@ -859,7 +864,16 @@ void main() {
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estado.inicializar();
|
||||
final desdeElTelefono = emisoraDemo(
|
||||
uuid: 'phone-picked',
|
||||
nombre: 'Elegida en el telefono',
|
||||
);
|
||||
unawaited(estado.reproducir(desdeElTelefono));
|
||||
audio.completar(desdeElTelefono.uuid);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
|
||||
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-remembered',
|
||||
nombre: 'Recordada desde el auto',
|
||||
@@ -867,18 +881,14 @@ void main() {
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final estadoDos = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
expect(
|
||||
estado.emisoraActual?.uuid,
|
||||
desdeCoche.uuid,
|
||||
reason:
|
||||
'the car changed the station without going through reproducir(); '
|
||||
'the phone UI must follow it instead of keeping the previous '
|
||||
'selection on screen',
|
||||
);
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+91
-6
@@ -15,11 +15,14 @@ import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
|
||||
import 'package:pluriwave/servicios/servicio_radio.dart';
|
||||
|
||||
class FakeServicioAudio extends ServicioAudio {
|
||||
FakeServicioAudio({this.ecualizadorActivo = true}) {
|
||||
FakeServicioAudio({this.ecualizadorDisponible = true}) {
|
||||
_estadoController.add(EstadoReproduccion.detenido);
|
||||
}
|
||||
|
||||
final bool ecualizadorActivo;
|
||||
/// Whether the native equalizer is available on this device — NOT whether
|
||||
/// it is currently switched on (see [ecualizadorActivo] for that).
|
||||
@override
|
||||
final bool ecualizadorDisponible;
|
||||
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
|
||||
final List<PresetEcualizador> presetsAplicados = [];
|
||||
final List<Emisora> emisorasReproducidas = [];
|
||||
@@ -30,6 +33,35 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
Emisora? _emisoraActual;
|
||||
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
|
||||
|
||||
/// Mirrors `PluriWaveAudioHandler._ecualizadorActivo`/`_presetActual`:
|
||||
/// the handler-side EQ state, settable independently of the
|
||||
/// `ServicioAudio`-forwarded methods below so tests can simulate a
|
||||
/// car/notification-initiated change (eq-sync-superficies).
|
||||
bool _ecualizadorActivoValor = true;
|
||||
PresetEcualizador _presetActualValor = PresetEcualizador.flat;
|
||||
|
||||
@override
|
||||
bool get ecualizadorActivo => _ecualizadorActivoValor;
|
||||
|
||||
@override
|
||||
PresetEcualizador get presetActual => _presetActualValor;
|
||||
|
||||
/// Simulates a car/notification-initiated EQ change: mutates the (fake)
|
||||
/// handler's own state directly, the same way
|
||||
/// `PluriWaveAudioHandler.customAction`/`seleccionarPresetEqPorMediaId`
|
||||
/// call `setEcualizadorActivo`/`aplicarPreset` on the handler WITHOUT
|
||||
/// going through `ServicioAudio` — then re-emits the current playback
|
||||
/// state, mirroring `_actualizarControlesEq()`'s unconditional
|
||||
/// `playbackState.add(...)` republish so a resync listener on
|
||||
/// [estadoStream] picks it up. Deliberately does NOT append to
|
||||
/// [cambiosEcualizadorActivo]/[presetsAplicados]: those track calls that
|
||||
/// arrived through the `ServicioAudio`-forwarded (UI-initiated) path.
|
||||
void simularCambioEqDesdeHandler({bool? activo, PresetEcualizador? preset}) {
|
||||
if (activo != null) _ecualizadorActivoValor = activo;
|
||||
if (preset != null) _presetActualValor = preset;
|
||||
emitirEstado(_estadoActual);
|
||||
}
|
||||
|
||||
@override
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
||||
// No global handler in tests; just record the call.
|
||||
@@ -39,9 +71,6 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Emisora? get emisoraActual => _emisoraActual;
|
||||
|
||||
@override
|
||||
bool get ecualizadorDisponible => ecualizadorActivo;
|
||||
|
||||
@override
|
||||
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
||||
|
||||
@@ -105,6 +134,7 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
||||
presetsAplicados.add(preset);
|
||||
_presetActualValor = preset;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -113,6 +143,7 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Future<void> setEcualizadorActivo(bool activo) async {
|
||||
cambiosEcualizadorActivo.add(activo);
|
||||
_ecualizadorActivoValor = activo;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -140,7 +171,45 @@ class FakeServicioFavoritos extends ServicioFavoritos {
|
||||
@override
|
||||
Future<void> agregar(Emisora emisora) async {
|
||||
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
|
||||
// FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de
|
||||
// «marcar como favorita», y fuerza `sin_asignar` además de un `orden`
|
||||
// nuevo. El doble NO lo hacía, así que cualquier test de import escrito
|
||||
// contra él salía verde mientras el dispositivo real perdía la asignación
|
||||
// de grupo. Para RESTAURAR una copia existe `restaurarFavorito`.
|
||||
_favoritos.add(
|
||||
emisora.copyWith(
|
||||
orden: _favoritos.length,
|
||||
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restaurarFavorito(Emisora emisora) async {
|
||||
// Fiel a producción: preserva `orden` y `grupo_id`, cayendo a
|
||||
// `sin_asignar` cuando el grupo de la copia no existe.
|
||||
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId);
|
||||
_favoritos.add(
|
||||
existe
|
||||
? emisora
|
||||
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
|
||||
// Sin este override la llamada caía en la implementación REAL de sqflite
|
||||
// y explotaba con «databaseFactory not initialized»; solo pasaba
|
||||
// desapercibido porque todos los tests de import existentes mandaban
|
||||
// `gruposFavoritos: []`.
|
||||
if (grupo.esSinAsignar) return;
|
||||
final index = _grupos.indexWhere((g) => g.id == grupo.id);
|
||||
if (index == -1) {
|
||||
_grupos.add(grupo);
|
||||
} else {
|
||||
_grupos[index] = grupo;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -349,6 +418,10 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
ConfiguracionEcualizador _config;
|
||||
ConfiguracionEcualizador get config => _config;
|
||||
|
||||
/// Number of times [guardarActivo] has been called — lets tests assert a
|
||||
/// persistence write happened exactly once (eq-sync-superficies).
|
||||
int guardarActivoLlamadas = 0;
|
||||
|
||||
@override
|
||||
Future<ConfiguracionEcualizador> cargar() async => _config;
|
||||
|
||||
@@ -367,6 +440,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
|
||||
@override
|
||||
Future<void> guardarActivo(bool activo) async {
|
||||
guardarActivoLlamadas++;
|
||||
_config = ConfiguracionEcualizador(
|
||||
principal: _config.principal,
|
||||
porEmisora: _config.porEmisora,
|
||||
@@ -378,6 +452,17 @@ class FakeServicioEcualizador extends ServicioEcualizador {
|
||||
);
|
||||
}
|
||||
|
||||
/// Overridden like every other write below: without this, calls fall
|
||||
/// through to the real `ServicioEcualizador.guardarConfiguracion`, which
|
||||
/// hits real SharedPreferences and needs a Flutter test binding — a
|
||||
/// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion`
|
||||
/// (the backup-import path) through this Fake without also wiring
|
||||
/// `TestWidgetsFlutterBinding`/mock prefs.
|
||||
@override
|
||||
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
|
||||
_config = config;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
|
||||
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Test-isolation seam for [PluriWaveAudioHandler].
|
||||
///
|
||||
/// A handler nobody releases keeps running after the test that built it: its
|
||||
/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16
|
||||
/// s, longer than most of the tests that arm it) and anything still queued on
|
||||
/// its source-change chain. When one of those finally performs a source change
|
||||
/// it calls `_crearPlayer()`, which reads the CURRENT
|
||||
/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds
|
||||
/// a double bound to a LATER test's script and drives it, incrementing that
|
||||
/// test's counters for work it never asked for.
|
||||
///
|
||||
/// That is why `servicio_audio_transporte_test.dart` behaved differently run
|
||||
/// alone and run inside the whole suite. A suite that passes under those
|
||||
/// conditions passes by luck, and luck runs out on a broken build exactly when
|
||||
/// it matters.
|
||||
///
|
||||
/// Usage — call ONCE at the top of `main()` and build every handler through
|
||||
/// the returned function:
|
||||
///
|
||||
/// ```dart
|
||||
/// final crearHandler = registrarHandlersLiberables();
|
||||
/// ...
|
||||
/// final handler = crearHandler();
|
||||
/// ```
|
||||
///
|
||||
/// The `tearDown` it registers covers every group in the file.
|
||||
PluriWaveAudioHandler Function() registrarHandlersLiberables() {
|
||||
final creados = <PluriWaveAudioHandler>[];
|
||||
tearDown(() async {
|
||||
// Released in reverse creation order so a handler built on top of an
|
||||
// earlier one is torn down first. `liberar` is idempotent, so a test that
|
||||
// already released its own handler is fine.
|
||||
for (final handler in creados.reversed) {
|
||||
await handler.liberar();
|
||||
}
|
||||
creados.clear();
|
||||
});
|
||||
return () {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
creados.add(handler);
|
||||
return handler;
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -46,6 +46,9 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
('pt', 'welcomeBullet2Title'),
|
||||
('ru', 'welcomeBullet2Title'),
|
||||
('zh', 'welcomeBullet2Title'),
|
||||
// `premiumBeneficioAndroidAuto` used to live here as a bare "Android
|
||||
// Auto" product name. It is now a real sentence describing what PRO adds
|
||||
// in the car, so every locale translates it and no entry belongs here.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure symbols / placeholders -- no translatable text at all.
|
||||
@@ -286,4 +289,10 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
'restaurarCompras',
|
||||
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
|
||||
// Portuguese store wording and coincides with es word for word.
|
||||
(
|
||||
'pt',
|
||||
'autoCarpetaFavoritos',
|
||||
), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same
|
||||
// word in pt and es, exactly like the already-listed ('pt',
|
||||
// 'favoritesTitle') above, which carries this very value.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'arb_test_helpers.dart';
|
||||
|
||||
/// Play "Deceptive Behavior" guard over the purchase sheet's copy.
|
||||
///
|
||||
/// The free Android Auto root is NOT a paywall: `ConstructorArbolAuto.raiz`
|
||||
/// hands free users a real, browsable `idDestacadas` folder whose rows are
|
||||
/// playable (`hijosDestacadas`), precisely because Google Play cited the old
|
||||
/// "Premium feature" dead-end rows against the Android for Cars App Quality
|
||||
/// Guidelines (see the comment at `navegacion_auto.dart`). What PRO adds in
|
||||
/// the car is the rest of the tree: the full catalogue, favourites, my
|
||||
/// stations and local music.
|
||||
///
|
||||
/// So a bullet reading just "Android Auto" claims the free tier has no
|
||||
/// Android Auto at all, which is false in every locale.
|
||||
void main() {
|
||||
test('premiumBeneficioAndroidAuto no es el nombre pelado del producto en '
|
||||
'ninguna de las 13 locales', () {
|
||||
final ofensores = <String>[];
|
||||
|
||||
for (final locale in supportedArbLocales) {
|
||||
final valor = readArb(locale)['premiumBeneficioAndroidAuto'] as String?;
|
||||
if (valor == null) continue;
|
||||
if (valor.trim() == 'Android Auto') ofensores.add(locale);
|
||||
}
|
||||
|
||||
expect(
|
||||
ofensores,
|
||||
isEmpty,
|
||||
reason:
|
||||
'Free users already get a playable Android Auto folder, so a '
|
||||
'bullet whose whole text is the product name sells them '
|
||||
'something they have. Describe what PRO actually adds in the '
|
||||
'car instead. Offending locales: $ofensores',
|
||||
);
|
||||
});
|
||||
|
||||
test('premiumBeneficioAndroidAuto nombra lo que PRO anade de verdad en el '
|
||||
'coche (plantilla es)', () {
|
||||
final es = readArb('es')['premiumBeneficioAndroidAuto'] as String;
|
||||
final minusculas = es.toLowerCase();
|
||||
|
||||
expect(minusculas, contains('android auto'));
|
||||
expect(
|
||||
minusculas,
|
||||
contains('catálogo'),
|
||||
reason: 'the full catalogue (idTodas) is the headline PRO folder',
|
||||
);
|
||||
expect(minusculas, contains('favoritos'));
|
||||
expect(minusculas, contains('mis emisoras'));
|
||||
expect(minusculas, contains('música local'));
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart'
|
||||
show registrarInvalidacionArbolAuto;
|
||||
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -65,4 +68,81 @@ void main() {
|
||||
|
||||
expect(find.text('No folder selected'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// fix/android-auto-musica-local, item 4 — the browse-tree invalidation
|
||||
/// after a successful folder pick had ZERO coverage and no testability
|
||||
/// excuse: this file already mounts the screen, `registrarInvalidacionArbolAuto`
|
||||
/// already takes a fake hook, and `pickMusicFolder` mocks exactly like
|
||||
/// `hasPersistedPermission` does in `musica_local_auto_test.dart`.
|
||||
///
|
||||
/// It matters because Android Auto CACHES the browse root and never asks
|
||||
/// again on its own: without the call, a driver who picks a folder on the
|
||||
/// phone keeps getting a car with no «Música Local» entry for the rest of
|
||||
/// the session.
|
||||
group('invalidación del árbol de Android Auto tras elegir carpeta', () {
|
||||
const canal = MethodChannel('pluriwave/file_actions');
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
});
|
||||
|
||||
Future<int> pulsarElegirCarpeta(
|
||||
WidgetTester tester, {
|
||||
required String? uriDevuelta,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
var invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
expect(call.method, 'pickMusicFolder');
|
||||
return uriDevuelta;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(buildScreen());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.tap(find.text('Choose folder'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
return invalidaciones;
|
||||
}
|
||||
|
||||
testWidgets('elegir una carpeta invalida el árbol cacheado del coche', (
|
||||
tester,
|
||||
) async {
|
||||
final invalidaciones = await pulsarElegirCarpeta(
|
||||
tester,
|
||||
uriDevuelta: 'content://com.android.externalstorage.documents/tree/'
|
||||
'primary%3AMusic%2FMyFolder',
|
||||
);
|
||||
|
||||
expect(
|
||||
invalidaciones,
|
||||
1,
|
||||
reason:
|
||||
'acaba de aparecer música local donde antes no había, y el head '
|
||||
'unit no vuelve a preguntar por su cuenta',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('cancelar el selector NO invalida nada (el `if (uri == null) '
|
||||
'return` es deliberado)', (tester) async {
|
||||
final invalidaciones = await pulsarElegirCarpeta(
|
||||
tester,
|
||||
uriDevuelta: null,
|
||||
);
|
||||
|
||||
expect(
|
||||
invalidaciones,
|
||||
0,
|
||||
reason:
|
||||
'nada cambió, así que forzar un re-browse del árbol entero sería '
|
||||
'trabajo gratis para el coche',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_visualizador.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Play sensitive-permission contract for the waveform visualizer's
|
||||
/// microphone opt-in.
|
||||
///
|
||||
/// Turning the switch ON is what eventually makes the native side ask for
|
||||
/// `RECORD_AUDIO`, so the explanation must be on screen and accepted BEFORE
|
||||
/// the flag flips — never a system dialog the user meets cold.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<EstadoVisualizador> montar(WidgetTester tester) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final estado = EstadoVisualizador(prefs: prefs);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoVisualizador>.value(
|
||||
value: estado,
|
||||
child: const MaterialApp(
|
||||
locale: Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaAjustesVisualizador(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return estado;
|
||||
}
|
||||
|
||||
AppLocalizations textos(WidgetTester tester) => AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaAjustesVisualizador)),
|
||||
);
|
||||
|
||||
testWidgets('arranca desactivado: la onda real es opt-in, nunca el defecto', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'activar muestra PRIMERO la explicacion y NO cambia el ajuste todavia',
|
||||
(tester) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogo = find.byKey(
|
||||
const ValueKey('visualizador-explicacion-permiso'),
|
||||
);
|
||||
expect(dialogo, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: dialogo,
|
||||
matching: find.text(l10n.visualizerRealWavePermissionExplanation),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
estado.ondaRealHabilitada,
|
||||
isFalse,
|
||||
reason:
|
||||
'the flag must not flip while the explanation is still on '
|
||||
'screen — flipping it is what triggers the permission request',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('cancelar la explicacion deja el ajuste desactivado', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('aceptar la explicacion activa el ajuste y lo persiste', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.visualizerRealWaveEnableAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.ondaRealHabilitada, isTrue);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool(EstadoVisualizador.claveOndaReal), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('desactivar NO pide explicacion: retirar un permiso es libre', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
EstadoVisualizador.claveOndaReal: true,
|
||||
});
|
||||
final estado = await montar(tester);
|
||||
|
||||
expect(estado.ondaRealHabilitada, isTrue);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('visualizador-explicacion-permiso')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
@@ -108,6 +109,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
@@ -54,6 +55,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
@@ -100,7 +104,7 @@ void main() {
|
||||
// completes the remaining 5 sections, so the root is now exactly 4
|
||||
// GrupoAjustes cards, under 400 lines.
|
||||
group('WU3a — AUDIO and EMISORAS groups', () {
|
||||
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
||||
testWidgets('AUDIO group renders exactly 4 nav rows, no inline controls', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
@@ -114,6 +118,10 @@ void main() {
|
||||
expect(find.text('AUDIO'), findsOneWidget);
|
||||
expect(find.text('Equalizer'), findsOneWidget);
|
||||
expect(find.text('Advanced Equalization Options'), findsOneWidget);
|
||||
// The waveform visualizer's microphone opt-in is reachable from the
|
||||
// root: the RECORD_AUDIO request must have a settings home the user
|
||||
// can find, not only the moment they happen to press play.
|
||||
expect(find.text('Real audio waveform'), findsOneWidget);
|
||||
expect(find.text('Sleep timer'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the old always-visible enable switch and
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/formato_fechas.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
@@ -137,16 +138,29 @@ void main() {
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
// The pill only renders for the ACTIVE or NEXT range
|
||||
// (`EstadoAlarmas.vacacionesProximas` keeps `inicioDia.isAfter(hoy)`), so
|
||||
// the range has to be in the future RELATIVE TO THE RUN. Hardcoded
|
||||
// calendar dates silently rot into the past and turn this into a
|
||||
// date-dependent failure; anchor on next month instead, days 4-18 so the
|
||||
// range never straddles a month boundary and the label stays "d–d MON".
|
||||
final ahora = DateTime.now();
|
||||
final mesSiguiente = DateTime(ahora.year, ahora.month + 1);
|
||||
final inicio = DateTime(mesSiguiente.year, mesSiguiente.month, 4);
|
||||
final fin = DateTime(mesSiguiente.year, mesSiguiente.month, 18);
|
||||
await estado.crearRangoVacaciones(
|
||||
estado.servicio.crearRangoVacaciones(
|
||||
inicio: DateTime(2026, 8, 4),
|
||||
fin: DateTime(2026, 8, 18),
|
||||
inicio: inicio,
|
||||
fin: fin,
|
||||
nombre: 'Summer',
|
||||
),
|
||||
);
|
||||
await montar(tester, estadoAlarmas: estado);
|
||||
|
||||
expect(find.text('4–18 AUG'), findsOneWidget);
|
||||
// Assert against the same pure formatter the widget uses, so this stays
|
||||
// a check that the pill IS rendered in the right shape rather than a
|
||||
// duplicate of the formatter's own logic.
|
||||
expect(find.text(rangoFechasCorto('en', inicio, fin)), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,16 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||
/// constrained to exactly Rename/Share/Delete.
|
||||
/// constrained to exactly Rename/Open-in-another-app/Delete.
|
||||
///
|
||||
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||
/// `MethodChannel`s this suite does not mock — the same documented
|
||||
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
||||
/// the real `share_plus` call, since this suite does not mock that channel
|
||||
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
||||
/// constraint).
|
||||
/// Likewise, `abrirEnOtraApp` is always injected with a fake recorder
|
||||
/// instead of the real `pluriwave/file_actions` round trip, since this suite
|
||||
/// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
|
||||
/// for the same constraint).
|
||||
///
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
@@ -72,7 +72,7 @@ void main() {
|
||||
Widget buildScreen({
|
||||
required EstadoGrabacion estado,
|
||||
required ReproductorGrabaciones reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
Future<bool> Function(String ruta)? abrirEnOtraApp,
|
||||
}) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
@@ -82,7 +82,7 @@ void main() {
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaGrabaciones(
|
||||
reproductor: reproductor,
|
||||
compartir: compartir ?? (_) async {},
|
||||
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -117,6 +117,66 @@ void main() {
|
||||
expect(find.text('My recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('aviso de uso privado', () {
|
||||
/// Recording a broadcast is defensible as a private copy, and stops
|
||||
/// being defensible the moment the product reads as a redistribution
|
||||
/// tool. The library screen had no such statement at all, while the
|
||||
/// manifest already exposes the recordings folder to the system file
|
||||
/// manager, so the notice states the intended use in plain words.
|
||||
testWidgets(
|
||||
'la biblioteca muestra el aviso de uso personal con la lista vacia',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el aviso sigue presente con grabaciones en la lista', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'WU15b: tapping the settings icon pushes the folder/size settings '
|
||||
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
|
||||
@@ -379,9 +439,12 @@ void main() {
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
||||
tester,
|
||||
) async {
|
||||
// The middle entry used to be Share, which handed the audio file to the
|
||||
// system share sheet. It is now a LOCAL open: play your own recording in
|
||||
// another app on the same device. The exact-count assertion is the guard
|
||||
// that no off-device action creeps back in beside it.
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Open in another '
|
||||
'app, Delete', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
@@ -405,7 +468,10 @@ void main() {
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||
findsOneWidget,
|
||||
@@ -555,10 +621,15 @@ void main() {
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-C: Share invokes the injected share callback with the file path',
|
||||
(tester) async {
|
||||
final compartidos = <String>[];
|
||||
/// The row menu used to hand the audio file to the system share sheet,
|
||||
/// which is redistribution of someone else's broadcast. What the owner
|
||||
/// actually wanted is to play your own recording in another app on the
|
||||
/// same device, so the action is a local ACTION_VIEW instead.
|
||||
group('15.4-C: abrir la grabacion en otra app del dispositivo', () {
|
||||
testWidgets('invoca el seam de apertura local con la ruta del archivo', (
|
||||
tester,
|
||||
) async {
|
||||
final abiertos = <String>[];
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
@@ -571,21 +642,68 @@ void main() {
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
compartir: (ruta) async {
|
||||
compartidos.add(ruta);
|
||||
abrirEnOtraApp: (ruta) async {
|
||||
abiertos.add(ruta);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
||||
await tester.tap(
|
||||
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(compartidos, [fijaA.ruta]);
|
||||
},
|
||||
);
|
||||
expect(abiertos, [fijaA.ruta]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'si ningun reproductor del dispositivo puede abrirla, lo dice en vez '
|
||||
'de fallar en silencio',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
abrirEnOtraApp: (_) async => false,
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(
|
||||
PopupMenuItem<String>,
|
||||
l10n.recordingActionOpenIn,
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(
|
||||
find.widgetWithText(SnackBar, l10n.recordingOpenNoAppError),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_navegacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||
@@ -804,6 +805,9 @@ Widget _conProviders(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
||||
value: navegacion ?? EstadoNavegacionRaiz(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
@@ -106,6 +107,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
@@ -482,6 +486,32 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
// This tile shares the STATION (its name and its url) — never an audio
|
||||
// file. It used to borrow `recordingActionShare`, the recordings
|
||||
// library's own menu label, so one key stood for two different
|
||||
// actions and the tile could not say which one it performed. Its
|
||||
// label is now its own key, and it names the station.
|
||||
testWidgets('la etiqueta del boton de compartir nombra la emisora, no un '
|
||||
'generico "Compartir" compartido con la biblioteca de grabaciones', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaReproductor)),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('player-tool-share')),
|
||||
matching: find.text(l10n.stationActionShare),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
||||
(tester) async {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
@@ -80,6 +81,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
Provider<ServicioAnuncios>(
|
||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
@@ -78,6 +79,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
Provider<ServicioAnuncios>(
|
||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Requested: the Android Auto playback screen must offer previous/next for
|
||||
@@ -172,4 +173,145 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('contextoParaSaltoEmisora — la MISMA decisión, nombrada para poder '
|
||||
'recordarla entre procesos', () {
|
||||
Emisora favorita(String uuid, String grupo) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
final rock1 = favorita('rock1', 'g-rock');
|
||||
final rock2 = favorita('rock2', 'g-rock');
|
||||
final jazz1 = favorita('jazz1', 'g-jazz');
|
||||
|
||||
test('nombra el grupo cuando el salto se queda dentro del grupo', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: rock1,
|
||||
favoritos: [rock1, jazz1, rock2],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
);
|
||||
});
|
||||
|
||||
test('nombra la lista de cada uno de los otros tres casos', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: jazz1,
|
||||
favoritos: [rock1, jazz1, rock2],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
const ContextoSalto.favoritos(),
|
||||
reason: 'un grupo de un solo miembro cae a todos los favoritos',
|
||||
);
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [c, a],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
const ContextoSalto.misEmisoras(),
|
||||
);
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
const ContextoSalto.todas(),
|
||||
);
|
||||
});
|
||||
|
||||
test('null cuando la emisora no está en ninguna lista', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: emisora('huerfana'),
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b],
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('CONCUERDA con listaParaSaltoEmisora en todos los casos: son la '
|
||||
'misma decisión y no pueden divergir', () {
|
||||
final escenarios = <List<List<Emisora>>>[
|
||||
[
|
||||
[rock1],
|
||||
[rock1, jazz1, rock2],
|
||||
const [],
|
||||
const [],
|
||||
],
|
||||
[
|
||||
[jazz1],
|
||||
[rock1, jazz1, rock2],
|
||||
const [],
|
||||
const [],
|
||||
],
|
||||
[
|
||||
[c],
|
||||
[a, b],
|
||||
[c, a],
|
||||
[a, b, c],
|
||||
],
|
||||
[
|
||||
[c],
|
||||
[a],
|
||||
[b],
|
||||
[a, b, c],
|
||||
],
|
||||
[
|
||||
[emisora('huerfana')],
|
||||
[a],
|
||||
[b],
|
||||
[a, b],
|
||||
],
|
||||
];
|
||||
for (final escenario in escenarios) {
|
||||
final actual = escenario[0].single;
|
||||
final favoritos = escenario[1];
|
||||
final misEmisoras = escenario[2];
|
||||
final todas = escenario[3];
|
||||
final contexto = contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
);
|
||||
final porContexto =
|
||||
contexto == null
|
||||
? const <Emisora>[]
|
||||
: resolverListaContexto(
|
||||
contexto: contexto,
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
destacadas: const [],
|
||||
grupos: const [
|
||||
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
porContexto,
|
||||
listaParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Contexto de reproducción — el «en qué lista estoy» que sobrevive a que el
|
||||
/// proceso muera.
|
||||
///
|
||||
/// Mismo molde headless-safe que `emisoras_destacadas.dart`: solo
|
||||
/// `shared_preferences` y modelos, jamás `EstadoRadio` ni un `ChangeNotifier`,
|
||||
/// porque este módulo tiene que leerse desde el motor sin árbol de widgets que
|
||||
/// levanta Android Auto.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Emisora emisora(String uuid, {String grupo = GrupoFavoritos.sinAsignarId}) =>
|
||||
Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
group('ContextoSalto — serialización', () {
|
||||
test('la clave de persistencia queda fijada literalmente', () {
|
||||
// Un rename silencioso aquí no rompe nada en compilación y deja al
|
||||
// conductor sin contexto tras actualizar: se fija a propósito.
|
||||
expect(claveContextoSalto, 'contexto_salto_v1');
|
||||
});
|
||||
|
||||
test('round trip de los tres tipos que llevan carga útil', () {
|
||||
for (final contexto in [
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
const ContextoSalto.favoritos(),
|
||||
const ContextoSalto.misEmisoras(),
|
||||
const ContextoSalto.todas(),
|
||||
const ContextoSalto.destacadas(['a', 'b', 'c']),
|
||||
]) {
|
||||
expect(ContextoSalto.desdeMapa(contexto.aMapa()), contexto);
|
||||
}
|
||||
});
|
||||
|
||||
test('un payload corrupto o ajeno devuelve null en vez de lanzar', () {
|
||||
expect(ContextoSalto.desdeMapa(const {}), isNull);
|
||||
expect(ContextoSalto.desdeMapa(const {'tipo': 'inventado'}), isNull);
|
||||
expect(
|
||||
ContextoSalto.desdeMapa(const {'tipo': 'grupoFavoritos'}),
|
||||
isNull,
|
||||
reason: 'un contexto de grupo sin id de grupo no resuelve a nada',
|
||||
);
|
||||
expect(
|
||||
ContextoSalto.desdeMapa(const {'tipo': 'destacadas', 'uuids': 7}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('persistencia', () {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('round trip por disco', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await guardarContextoSalto(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
prefs: prefs,
|
||||
);
|
||||
|
||||
expect(
|
||||
await contextoSaltoPersistido(prefs: prefs),
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
);
|
||||
});
|
||||
|
||||
test('sin nada persistido devuelve null', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
|
||||
});
|
||||
|
||||
test('un JSON ilegible degrada a null, nunca lanza: esto se lee desde un '
|
||||
'botón del volante', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveContextoSalto: 'esto no es json',
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
|
||||
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveContextoSalto: jsonEncode({'tipo': 'inventado'}),
|
||||
});
|
||||
expect(
|
||||
await contextoSaltoPersistido(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolverListaContexto — degradación del contexto recordado', () {
|
||||
final rock1 = emisora('rock1', grupo: 'g-rock');
|
||||
final rock2 = emisora('rock2', grupo: 'g-rock');
|
||||
final jazz1 = emisora('jazz1', grupo: 'g-jazz');
|
||||
final suelta = emisora('suelta');
|
||||
final favoritos = [rock1, jazz1, rock2, suelta];
|
||||
const grupos = [
|
||||
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
];
|
||||
|
||||
List<Emisora> resolver(
|
||||
ContextoSalto contexto,
|
||||
Emisora actual, {
|
||||
List<Emisora>? favs,
|
||||
List<GrupoFavoritos>? gruposVivos,
|
||||
List<Emisora> misEmisoras = const [],
|
||||
List<Emisora> todas = const [],
|
||||
List<Emisora> destacadas = const [],
|
||||
}) => resolverListaContexto(
|
||||
contexto: contexto,
|
||||
actual: actual,
|
||||
favoritos: favs ?? favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
destacadas: destacadas,
|
||||
grupos: gruposVivos ?? grupos,
|
||||
);
|
||||
|
||||
test('el grupo recordado se recorre con sus miembros VIVOS, no con el '
|
||||
'snapshot', () {
|
||||
final nuevo = emisora('rock3', grupo: 'g-rock');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
rock1,
|
||||
favs: [rock1, jazz1, rock2, nuevo],
|
||||
),
|
||||
[rock1, rock2, nuevo],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo recordado ya NO existe -> cae a todos los favoritos', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-borrado'),
|
||||
rock1,
|
||||
gruposVivos: const [
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
),
|
||||
favoritos,
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo SIGUE VIVO con un solo miembro -> se honra igual: un grupo '
|
||||
'de una emisora sigue siendo el grupo que eligió el conductor', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-jazz'),
|
||||
jazz1,
|
||||
),
|
||||
[jazz1],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo sigue vivo pero la emisora que suena ya NO pertenece a él '
|
||||
'-> se PERMANECE en el grupo (el llamador coge su primera emisora)',
|
||||
() {
|
||||
expect(
|
||||
resolver(const ContextoSalto.grupo('g-rock'), jazz1),
|
||||
[rock1, rock2],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo sigue vivo pero se quedó VACÍO -> no hay primera emisora '
|
||||
'que coger, así que se ensancha a todos los favoritos', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
jazz1,
|
||||
favs: [jazz1, suelta],
|
||||
),
|
||||
[jazz1, suelta],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo fue borrado y la emisora ya NO es favorita -> aun así se '
|
||||
'cae a los favoritos: el llamador elegirá una de ellas', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-borrado'),
|
||||
emisora('fuera'),
|
||||
gruposVivos: const [],
|
||||
),
|
||||
favoritos,
|
||||
);
|
||||
});
|
||||
|
||||
test('no quedan favoritos -> lista vacía: el comportamiento de siempre '
|
||||
'cuando no hay emisoras agregadas', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
emisora('fuera'),
|
||||
favs: const [],
|
||||
gruposVivos: const [],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('la emisora salió de favoritos por completo -> el contexto de '
|
||||
'FAVORITOS se descarta', () {
|
||||
expect(
|
||||
resolver(const ContextoSalto.favoritos(), emisora('fuera')),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('favoritos / misEmisoras / todas se resuelven contra su lista viva', () {
|
||||
expect(resolver(const ContextoSalto.favoritos(), rock1), favoritos);
|
||||
final propia = emisora('propia');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.misEmisoras(),
|
||||
propia,
|
||||
misEmisoras: [propia, suelta],
|
||||
),
|
||||
[propia, suelta],
|
||||
);
|
||||
final catalogo = emisora('catalogo');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.todas(),
|
||||
catalogo,
|
||||
todas: [catalogo, rock1],
|
||||
),
|
||||
[catalogo, rock1],
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas respeta el ORDEN CONGELADO, que es la razón de existir '
|
||||
'del snapshot: la lista viva se reordena sola en cada lectura', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
final ajena = emisora('ajena');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['ajena', 'fip', 'soma']),
|
||||
ajena,
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
[ajena, fip, soma],
|
||||
reason:
|
||||
'la emisora que suena entra en la lista aunque no esté en el set '
|
||||
'curado; si no, ambos botones morirían',
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas: un uuid del snapshot que ya no resuelve se descarta', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['fip', 'retirada', 'soma']),
|
||||
fip,
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
[fip, soma],
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas: si la emisora que suena no está en el snapshot el '
|
||||
'contexto se descarta', () {
|
||||
final fip = emisora('fip');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['fip']),
|
||||
emisora('otra'),
|
||||
destacadas: [fip],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('uuidsCongeladosDestacadas', () {
|
||||
test('respeta el orden curado y antepone la emisora que suena cuando no '
|
||||
'pertenece al set', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
expect(
|
||||
uuidsCongeladosDestacadas(actual: fip, destacadas: [fip, soma]),
|
||||
['fip', 'soma'],
|
||||
);
|
||||
expect(
|
||||
uuidsCongeladosDestacadas(
|
||||
actual: emisora('ajena'),
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
['ajena', 'fip', 'soma'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Free-tier featured set (fix/auto-quality-guidelines, item 6).
|
||||
///
|
||||
/// The whole compliance story rests on this: a Play reviewer on a fresh
|
||||
/// install is ALWAYS free tier, has no network catalogue snapshot, no
|
||||
/// favourites, no custom stations and no `ultima_emisora_v1` — so the free
|
||||
/// root's single folder MUST still resolve to real, playable stations from
|
||||
/// nothing but the binary itself.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const ultima = Emisora(
|
||||
uuid: 'uuid-ultima',
|
||||
nombre: 'Ultima escuchada',
|
||||
url: 'https://ultima.example/stream',
|
||||
);
|
||||
|
||||
group('resolverEmisorasDestacadas', () {
|
||||
test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve '
|
||||
'>= 3 emisoras reales', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
destacadas.every((e) => e.uuid.isNotEmpty),
|
||||
isTrue,
|
||||
reason: 'un uuid vacío no se puede resolver desde emisora:<uuid>',
|
||||
);
|
||||
expect(
|
||||
destacadas.every(
|
||||
(e) => e.url.startsWith('http://') || e.url.startsWith('https://'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
destacadas.map((e) => e.uuid).toSet().length,
|
||||
destacadas.length,
|
||||
reason: 'uuids duplicados romperían porUuid',
|
||||
);
|
||||
});
|
||||
|
||||
test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(ultima.toMap()),
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.first.uuid, ultima.uuid);
|
||||
expect(
|
||||
destacadas.where((e) => e.uuid == ultima.uuid).length,
|
||||
1,
|
||||
reason: 'la última escuchada no puede aparecer dos veces',
|
||||
);
|
||||
expect(destacadas.length, emisorasDestacadas.length + 1);
|
||||
});
|
||||
|
||||
test('la última escuchada YA curada no añade una segunda fila', () async {
|
||||
final yaCurada = emisorasDestacadas.first;
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(yaCurada.toMap()),
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.first.uuid, yaCurada.uuid);
|
||||
expect(destacadas.length, emisorasDestacadas.length);
|
||||
});
|
||||
|
||||
test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: 'no-es-json{{',
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.length, emisorasDestacadas.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('esEmisoraGratuitaPorUuid', () {
|
||||
test('un uuid curado es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(
|
||||
await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('la última escuchada es gratuita aunque no esté curada', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(ultima.toMap()),
|
||||
});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue);
|
||||
});
|
||||
|
||||
test('un uuid del catálogo Radio Browser NO es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse);
|
||||
});
|
||||
|
||||
test('uuid vacío nunca es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid(''), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () {
|
||||
expect(claveUltimaEmisora, 'ultima_emisora_v1');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
|
||||
/// `FuenteMusicaLocalAuto.estadoCarpeta` promises, in its own interface doc,
|
||||
/// «Never throws: cualquier fallo degrada a un valor de
|
||||
/// [EstadoCarpetaLocal], nunca a una excepción». The refactor to the
|
||||
/// three-valued enum moved `await _uriPersistida()` OUTSIDE the try/catch,
|
||||
/// so a SharedPreferences failure escaped again — and the only caller,
|
||||
/// `PluriWaveAudioHandler.getChildren`'s root branch, awaits it inline, so
|
||||
/// the throw takes the whole browse root down and empties the car.
|
||||
///
|
||||
/// DELIBERATE FILE SEPARATION: nothing here may call
|
||||
/// `SharedPreferences.setMockInitialValues`. That call swaps
|
||||
/// `SharedPreferencesStorePlatform.instance` for an in-memory store for the
|
||||
/// rest of the isolate, and an in-memory store cannot fail. Left alone, the
|
||||
/// default store answers a real `getAll` platform call that nothing handles
|
||||
/// under `flutter test`, which is exactly the failure being exercised —
|
||||
/// hence its own file, not a group inside `musica_local_auto_test.dart`.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FuenteMusicaLocalAutoImpl.estadoCarpeta — fallo de prefs', () {
|
||||
test('un fallo leyendo SharedPreferences degrada a noConfigurada en vez '
|
||||
'de propagar y vaciar la raíz del coche', () async {
|
||||
// Sin prefs inyectadas: `_resolverPrefs` cae en
|
||||
// `SharedPreferences.getInstance()`, que aquí lanza.
|
||||
final fuente = FuenteMusicaLocalAutoImpl();
|
||||
|
||||
await expectLater(
|
||||
fuente.estadoCarpeta(),
|
||||
completion(EstadoCarpetaLocal.noConfigurada),
|
||||
);
|
||||
});
|
||||
|
||||
test('ese fallo NO se reporta como canalNoDisponible, que es la única '
|
||||
'respuesta que significa «hay carpeta pero no puedo comprobar el '
|
||||
'permiso»', () async {
|
||||
final fuente = FuenteMusicaLocalAutoImpl();
|
||||
|
||||
final estado = await fuente.estadoCarpeta();
|
||||
|
||||
expect(
|
||||
estado,
|
||||
isNot(EstadoCarpetaLocal.canalNoDisponible),
|
||||
reason:
|
||||
'el fallo de prefs es una MissingPluginException igual que la '
|
||||
'del canal `pluriwave/file_actions`, así que un único try que '
|
||||
'las capturase juntas borraría la distinción de 3 valores: el '
|
||||
'árbol mostraría «Música Local» con un subárbol que explica un '
|
||||
'problema de canal inexistente',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -163,6 +163,132 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
/// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no
|
||||
/// aparece la opción de reproducir música local, no aparece ni el menú».
|
||||
///
|
||||
/// El usuario TIENE la compra PRO, así que no es un problema de
|
||||
/// entitlement. La causa real: `hasPersistedPermission` viaja por
|
||||
/// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de
|
||||
/// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android
|
||||
/// Auto levanta el MediaBrowserService sin que la app se haya abierto,
|
||||
/// `audio_service` construye un FlutterEngine SIN Activity, ese método
|
||||
/// nunca corre, el canal se queda sin handler y `invokeMethod` lanza
|
||||
/// `MissingPluginException` — indistinguible hasta ahora de «permiso
|
||||
/// revocado».
|
||||
///
|
||||
/// Estos tests fijan la distinción: «el canal no está disponible» NO es
|
||||
/// «no hay carpeta».
|
||||
group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () {
|
||||
const canal = MethodChannel('pluriwave/file_actions');
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
});
|
||||
|
||||
test(
|
||||
'con URI persistida y SIN handler nativo (motor headless de Android '
|
||||
'Auto) reporta canalNoDisponible, no noConfigurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
// Sin handler: `invokeMethod` lanza MissingPluginException, que es
|
||||
// exactamente lo que pasa en el motor sin Activity.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(
|
||||
await fuente.estadoCarpeta(),
|
||||
EstadoCarpetaLocal.canalNoDisponible,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con URI persistida y handler nativo que responde false (permiso '
|
||||
'revocado de verdad) reporta noConfigurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
expect(call.method, 'hasPersistedPermission');
|
||||
return false;
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con URI persistida y handler nativo que responde true reporta '
|
||||
'configurada',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async => true);
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sin URI persistida reporta noConfigurada sin invocar el canal',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
var llamadas = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
llamadas++;
|
||||
return true;
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un PlatformException del canal (el handler SÍ existe, la llamada '
|
||||
'falla) reporta noConfigurada, no canalNoDisponible',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'musica_local_uri': 'content://tree/x',
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
throw PlatformException(code: 'ERROR');
|
||||
});
|
||||
|
||||
final fuente = FuenteMusicaLocalAutoImpl(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
|
||||
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('esArchivoAudio', () {
|
||||
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
|
||||
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8
|
||||
/// and 10).
|
||||
///
|
||||
/// Google Play returned "Approved with Issues" against the Android for Cars
|
||||
/// App Quality Guidelines on version code 157. The free root advertised four
|
||||
/// folders that each dead-ended on a single non-playable "Función Premium"
|
||||
/// row, and on a cold headless bind every one of the underlying lists is
|
||||
/// empty anyway. This suite pins the replacement: ONE browsable folder that
|
||||
/// resolves to real, playable stations.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FuenteEmisorasAutoLocal.porUuid — item 7', () {
|
||||
// Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no
|
||||
// favourites (sqflite is not initialised under `flutter test`, so the
|
||||
// read throws and degrades to `[]`), and a custom-stations path that
|
||||
// does not exist.
|
||||
FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal(
|
||||
resolverRutaCustom: () async => 'no/existe/emisoras_custom.json',
|
||||
);
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('en frío resuelve un uuid destacado (antes devolvía null y la fila '
|
||||
'no hacía nada al tocarla)', () async {
|
||||
final fuente = fuenteFria();
|
||||
|
||||
final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid);
|
||||
|
||||
expect(resuelta, isNotNull);
|
||||
expect(resuelta!.url, emisorasDestacadas.first.url);
|
||||
});
|
||||
|
||||
test('en frío resuelve la última escuchada persistida', () async {
|
||||
const ultima = Emisora(
|
||||
uuid: 'uuid-ultima',
|
||||
nombre: 'Ultima',
|
||||
url: 'https://ultima.example/stream',
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
||||
'"url":"https://ultima.example/stream"}',
|
||||
});
|
||||
|
||||
final resuelta = await fuenteFria().porUuid(ultima.uuid);
|
||||
|
||||
expect(resuelta?.url, ultima.url);
|
||||
});
|
||||
|
||||
test('un uuid desconocido sigue devolviendo null', () async {
|
||||
expect(await fuenteFria().porUuid('uuid-inexistente'), isNull);
|
||||
});
|
||||
|
||||
test('el snapshot vivo gana al set destacado para el MISMO uuid',
|
||||
() async {
|
||||
final fuente = fuenteFria();
|
||||
final delCatalogo = Emisora(
|
||||
uuid: emisorasDestacadas.first.uuid,
|
||||
nombre: 'Version viva',
|
||||
url: 'https://viva.example/stream',
|
||||
);
|
||||
fuente.actualizarSnapshot(todas: [delCatalogo]);
|
||||
|
||||
final resuelta = await fuente.porUuid(delCatalogo.uuid);
|
||||
|
||||
expect(resuelta?.url, 'https://viva.example/stream');
|
||||
});
|
||||
});
|
||||
|
||||
group('raiz(premium:) — item 8', () {
|
||||
test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro '
|
||||
'que morían en la fila premium', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
// `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta
|
||||
// local configurada puede el tier gratuito ver ese nodo.
|
||||
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
||||
|
||||
expect(libre, hasLength(1));
|
||||
expect(libre.single.id, ConstructorArbolAuto.idDestacadas);
|
||||
expect(libre.single.playable, isFalse);
|
||||
expect(libre.single.title, isNotEmpty);
|
||||
expect(
|
||||
libre.map((m) => m.id),
|
||||
isNot(
|
||||
anyOf(
|
||||
contains(ConstructorArbolAuto.idFavoritos),
|
||||
contains(ConstructorArbolAuto.idTodas),
|
||||
contains(ConstructorArbolAuto.idMisEmisoras),
|
||||
contains(ConstructorArbolAuto.idMusicaLocal),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: true, premium: true)
|
||||
.map((m) => m.id),
|
||||
[
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: false, premium: true)
|
||||
.map((m) => m.id),
|
||||
[
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: true, premium: true)
|
||||
.every((m) => m.playable == false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: '
|
||||
'audio_service 0.18.18 descarta los rootHints, así que un root de '
|
||||
'un solo item PLAYABLE se renderiza vacío en una unidad que solo '
|
||||
'acepta FLAG_BROWSABLE', () {
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: false,
|
||||
);
|
||||
|
||||
expect(libre.any((m) => m.playable == false), isTrue);
|
||||
});
|
||||
|
||||
test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no '
|
||||
'una constante castellana de este archivo (hallazgo 4)', () {
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: false,
|
||||
tituloDestacadas: 'Listen',
|
||||
);
|
||||
|
||||
expect(
|
||||
libre.single.title,
|
||||
'Listen',
|
||||
reason:
|
||||
'this one label is 100% of the browse tree a free-tier (i.e. '
|
||||
'every Play reviewer) driver ever sees; the pure builder stays '
|
||||
'AppLocalizations-free, so the handler has to hand it the string',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('hijosDestacadas — item 8/9', () {
|
||||
test('mapea a items PLAYABLE con id emisora:<uuid>', () {
|
||||
final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas);
|
||||
|
||||
expect(items, hasLength(emisorasDestacadas.length));
|
||||
expect(items.every((m) => m.playable == true), isTrue);
|
||||
expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}');
|
||||
expect(items.every((m) => m.artUri != null), isTrue);
|
||||
});
|
||||
|
||||
test('lista vacía devuelve lista vacía, nunca lanza', () {
|
||||
expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement — item 10', () {
|
||||
List<MediaItem>? gate(String id, {required bool premium}) =>
|
||||
respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: premium,
|
||||
destacadas: emisorasDestacadas,
|
||||
);
|
||||
|
||||
test('premium: nada se bloquea', () {
|
||||
for (final id in [
|
||||
AudioService.browsableRootId,
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
'emisora:uuid-del-catalogo',
|
||||
]) {
|
||||
expect(gate(id, premium: true), isNull, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: la raíz y el contenido gratuito PASAN', () {
|
||||
expect(gate(AudioService.browsableRootId, premium: false), isNull);
|
||||
expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull);
|
||||
for (final e in emisorasDestacadas) {
|
||||
expect(gate('emisora:${e.uuid}', premium: false), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: el catálogo premium se bloquea', () {
|
||||
for (final id in [
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
'grupo:algo',
|
||||
'emisora:uuid-del-catalogo',
|
||||
'pista:doc-id',
|
||||
]) {
|
||||
expect(gate(id, premium: false), isNotNull, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila '
|
||||
'no reproducible: eso es exactamente lo que Play citó', () {
|
||||
final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false);
|
||||
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada, isNotEmpty);
|
||||
expect(
|
||||
bloqueada!.every((m) => m.playable == true),
|
||||
isTrue,
|
||||
reason: 'una fila no reproducible en el árbol es la cita de Play',
|
||||
);
|
||||
expect(bloqueada.map((m) => m.id), [
|
||||
for (final e in emisorasDestacadas) 'emisora:${e.uuid}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sin destacadas resolubles la rama bloqueada sigue sin inventar una '
|
||||
'fila muerta', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idTodas,
|
||||
premium: false,
|
||||
destacadas: const [],
|
||||
);
|
||||
|
||||
expect(bloqueada, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () {
|
||||
// Guardia estructural: si alguien los reintroduce, este archivo deja de
|
||||
// compilar por el `expect` de abajo, no por un comentario. La única
|
||||
// prueba real es que `ConstructorArbolAuto` no expone ningún item no
|
||||
// reproducible fuera de las carpetas.
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: false,
|
||||
);
|
||||
|
||||
expect(libre.every((m) => m.id != 'premium:info'), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -1,106 +1,142 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
|
||||
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
|
||||
/// design.md ADR-4). All pure — no handler instantiation needed
|
||||
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
|
||||
/// Android Auto entitlement gating — the id-shape matrix.
|
||||
///
|
||||
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
|
||||
/// assert the opposite design: that every non-root id, for a free-tier user,
|
||||
/// collapsed to a single non-playable `premium:info` row. Google Play cited
|
||||
/// that browse tree against the Android for Cars App Quality Guidelines, so
|
||||
/// the contract is now content-scoping — the free tier sees LESS, never a
|
||||
/// row that does nothing.
|
||||
///
|
||||
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
|
||||
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
|
||||
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
|
||||
/// including the stale/deep-linked ones a head unit's cached tree replays.
|
||||
void main() {
|
||||
group('raiz(premium:) — root keeps its labels for every tier', () {
|
||||
test('premium: identical to today\'s tree (regression guard)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
const gratuitas = [
|
||||
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
|
||||
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
|
||||
];
|
||||
|
||||
final premiumConLocal = constructor.raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: true,
|
||||
group('idPermitidoEnFree', () {
|
||||
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
AudioService.browsableRootId,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final premiumSinLocal = constructor.raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: true,
|
||||
);
|
||||
|
||||
expect(premiumConLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
|
||||
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
|
||||
expect(premiumSinLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
]);
|
||||
});
|
||||
|
||||
test('free: same folder ids/titles, non-blank, never playable', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
||||
|
||||
expect(libre, isNotEmpty);
|
||||
expect(libre.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(libre.every((m) => m.playable == false), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
|
||||
() {
|
||||
final item = ConstructorArbolAuto().itemPremiumBloqueado();
|
||||
|
||||
expect(item.id, 'premium:info');
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
|
||||
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: AudioService.browsableRootId,
|
||||
premium: false,
|
||||
test('la carpeta gratuita pasa', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
|
||||
test('un emisora:<uuid> del set gratuito pasa', () {
|
||||
for (final e in gratuitas) {
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
|
||||
isTrue,
|
||||
reason: e.uuid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('las carpetas premium NO pasan', () {
|
||||
for (final id in [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
// Stale/deep-linked id from before a downgrade — the backstop must
|
||||
// not special-case known ids (Spec "Stale folder id bypass
|
||||
// attempt").
|
||||
'emisora:algun-uuid-viejo',
|
||||
'grupo:algo',
|
||||
]) {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: false,
|
||||
);
|
||||
expect(respuesta, hasLength(1));
|
||||
expect(respuesta!.single.id, 'premium:info');
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en premium, no es bloqueada', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idFavoritos,
|
||||
premium: true,
|
||||
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
|
||||
'ésa es la propiedad de seguridad que el rediseño tenía que '
|
||||
'conservar', () {
|
||||
for (final id in [
|
||||
'emisora:uuid-del-catalogo',
|
||||
'grupo:algun-grupo',
|
||||
'pista:doc-id',
|
||||
'carpeta_local:doc-id',
|
||||
'carpeta_local_reproducir:doc-id',
|
||||
'carpeta_local_aleatorio:doc-id',
|
||||
'eq_preset:Rock',
|
||||
'premium:info', // la fila muerta que ya no existe
|
||||
'',
|
||||
]) {
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
|
||||
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
|
||||
});
|
||||
|
||||
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
|
||||
expect(
|
||||
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: const [],
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement', () {
|
||||
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
|
||||
for (final id in [
|
||||
AudioService.browsableRootId,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
'emisora:cualquier-cosa',
|
||||
'basura',
|
||||
]) {
|
||||
expect(
|
||||
respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: true,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isNull,
|
||||
reason: id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
|
||||
premium: false,
|
||||
destacadas: gratuitas,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada!.every((m) => m.playable == true), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pista_local.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Every user-readable label of the Android Auto browse tree is translated.
|
||||
///
|
||||
/// The owner's rule, after Play saw a Spanish-only car tree on a head unit
|
||||
/// in any of the 13 shipped locales: anything a user can read gets
|
||||
/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`)
|
||||
/// only ever see strings that already entered the ARB system, so neither
|
||||
/// could catch a label hardcoded in `navegacion_auto.dart` that never
|
||||
/// became a key. This file closes that hole from the CONSUMPTION side
|
||||
/// (the tree really renders the injected locale);
|
||||
/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE
|
||||
/// side (no new hardcoded label can be added at all).
|
||||
Future<Map<String, MetadatosPista>> _sinMetadatos(List<String> ids) async =>
|
||||
const {};
|
||||
|
||||
List<NodoLocal> _pistas(int cuantas, {String prefijo = 'cancion'}) =>
|
||||
List.generate(
|
||||
cuantas,
|
||||
(i) => NodoLocal(
|
||||
documentId: 'doc-$prefijo-$i',
|
||||
nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final ingles = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
group('EtiquetasArbolAuto desde AppLocalizations', () {
|
||||
test('mapea cada etiqueta del árbol a su clave ARB del locale', () {
|
||||
final etiquetas = etiquetasArbolAutoDesde(ingles);
|
||||
|
||||
expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar);
|
||||
expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos);
|
||||
expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas);
|
||||
expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras);
|
||||
expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal);
|
||||
expect(
|
||||
etiquetas.musicaLocalNoDisponible,
|
||||
ingles.autoMusicaLocalNoDisponible,
|
||||
);
|
||||
expect(etiquetas.cargarMas, ingles.autoCargarMas);
|
||||
expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad);
|
||||
expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta);
|
||||
expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio);
|
||||
expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre);
|
||||
});
|
||||
|
||||
test('ninguna etiqueta inglesa cae en el castellano de respaldo', () {
|
||||
final etiquetas = etiquetasArbolAutoDesde(ingles);
|
||||
const respaldo = EtiquetasArbolAuto.respaldo;
|
||||
|
||||
expect(etiquetas.favoritos, isNot(respaldo.favoritos));
|
||||
expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras));
|
||||
expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras));
|
||||
expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal));
|
||||
expect(
|
||||
etiquetas.musicaLocalNoDisponible,
|
||||
isNot(respaldo.musicaLocalNoDisponible),
|
||||
);
|
||||
expect(etiquetas.cargarMas, isNot(respaldo.cargarMas));
|
||||
expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad));
|
||||
expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta));
|
||||
expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio));
|
||||
expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre));
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () {
|
||||
final constructor = ConstructorArbolAuto(
|
||||
etiquetas: etiquetasArbolAutoDesde(ingles),
|
||||
);
|
||||
|
||||
test('la raíz premium rotula sus cuatro carpetas en el locale', () {
|
||||
final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true);
|
||||
|
||||
expect(raiz.map((i) => i.title).toList(), [
|
||||
ingles.autoCarpetaFavoritos,
|
||||
ingles.autoCarpetaTodas,
|
||||
ingles.autoCarpetaMisEmisoras,
|
||||
ingles.autoCarpetaMusicaLocal,
|
||||
]);
|
||||
});
|
||||
|
||||
test('la raíz gratuita sigue rotulando Escuchar en el locale', () {
|
||||
final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false);
|
||||
|
||||
expect(raiz.single.title, ingles.autoCarpetaEscuchar);
|
||||
});
|
||||
|
||||
test('el item de música local no disponible va en el locale', () {
|
||||
expect(
|
||||
constructor.itemLocalNoDisponible().title,
|
||||
ingles.autoMusicaLocalNoDisponible,
|
||||
);
|
||||
});
|
||||
|
||||
test('las acciones de carpeta y la entrada de orden van en el '
|
||||
'locale', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
_pistas(3),
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
final titulos = items.map((i) => i.title).toList();
|
||||
expect(titulos, contains(ingles.autoReproducirCarpeta));
|
||||
expect(titulos, contains(ingles.autoReproducirAleatorio));
|
||||
expect(titulos, contains(ingles.autoOrdenarPorCalidad));
|
||||
});
|
||||
|
||||
test('el item "cargar más" de las tres vistas paginadas va en el '
|
||||
'locale', () async {
|
||||
final nodos = _pistas(60);
|
||||
|
||||
final porNombre = await constructor.itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
final porCalidad = await constructor.itemsLocalesOrdenCalidad(
|
||||
nodos,
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
final porBucket = await constructor.itemsLocalesBucket(
|
||||
_pistas(60, prefijo: 'apple'),
|
||||
documentIdPadre: 'padre',
|
||||
idxBucket: 0,
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
expect(porNombre.last.title, ingles.autoCargarMas);
|
||||
expect(porCalidad.last.title, ingles.autoCargarMas);
|
||||
expect(porBucket.last.title, ingles.autoCargarMas);
|
||||
});
|
||||
|
||||
test('un nombre de fichero en blanco cae en la pista sin nombre del '
|
||||
'locale', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
const [
|
||||
NodoLocal(
|
||||
documentId: 'doc-vacio',
|
||||
nombre: ' ',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
final pista = items.singleWhere((i) => i.id.startsWith('pista:'));
|
||||
expect(pista.title, ingles.autoPistaSinNombre);
|
||||
});
|
||||
|
||||
test('los rangos alfabéticos NO se traducen: son rangos de letras '
|
||||
'latinas, no prosa', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
_pistas(60),
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
expect(items.map((i) => i.title), containsAll(['A-F', 'G-M']));
|
||||
});
|
||||
});
|
||||
|
||||
group('hijosMusicaLocal propaga las etiquetas', () {
|
||||
test('la carpeta raíz local rotula sus acciones en el locale', () async {
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: _FuenteLocalFalsa(),
|
||||
etiquetas: etiquetasArbolAutoDesde(ingles),
|
||||
);
|
||||
|
||||
expect(
|
||||
items!.map((i) => i.title),
|
||||
containsAll([
|
||||
ingles.autoReproducirCarpeta,
|
||||
ingles.autoReproducirAleatorio,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the
|
||||
/// tree root, no metadata, native channel available.
|
||||
class _FuenteLocalFalsa implements FuenteMusicaLocalAuto {
|
||||
@override
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta() async =>
|
||||
EstadoCarpetaLocal.configurada;
|
||||
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async =>
|
||||
documentId.isEmpty
|
||||
? const [
|
||||
NodoLocal(
|
||||
documentId: 'doc-0',
|
||||
nombre: 'cancion.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
]
|
||||
: const [];
|
||||
|
||||
@override
|
||||
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds) =>
|
||||
_sinMetadatos(documentIds);
|
||||
|
||||
@override
|
||||
Future<String?> uriContenidoDePista(String documentId) async =>
|
||||
'content://fake/$documentId';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user