Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a739fb4162 | ||
|
|
4a83019f40 |
@@ -1,5 +0,0 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,336 +0,0 @@
|
||||
name: Build & Deploy PluriWave
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, PRO]
|
||||
|
||||
env:
|
||||
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
||||
KEYSTORE_PATH: /Users/freetlab/.openclaw/workspace/.secure/pluriwave/pluriwave-upload.jks
|
||||
KEYSTORE_ALIAS: pluriwave-upload
|
||||
PLAY_PACKAGE_NAME: es.freetimelab.pluriwave
|
||||
CURRENT_REF: ${{ gitea.ref }}
|
||||
|
||||
jobs:
|
||||
analizar:
|
||||
name: Análisis de código
|
||||
runs-on: [self-hosted, macos, arm64, flutter]
|
||||
steps:
|
||||
- name: Clonar rama actual
|
||||
run: |
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
|
||||
git fetch origin "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
|
||||
- name: Obtener dependencias
|
||||
run: flutter pub get
|
||||
|
||||
- name: Verificar integridad de literales i18n
|
||||
run: python3 tool/check_arb_placeholder_corruption.py
|
||||
|
||||
- name: Analizar código
|
||||
run: flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
|
||||
- name: Ejecutar tests criticos
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
flutter test test/servicios/servicio_programacion_alarmas_test.dart test/estado/estado_alarmas_test.dart --concurrency=1 --timeout=60s
|
||||
|
||||
- name: Limpiar procesos Flutter de tests
|
||||
if: always()
|
||||
run: pkill -f 'flutter_tester|flutter_tools.snapshot|dartaotruntime' 2>/dev/null || true
|
||||
|
||||
build:
|
||||
name: Build APK + AAB release
|
||||
runs-on: [self-hosted, macos, arm64, flutter]
|
||||
needs: analizar
|
||||
steps:
|
||||
- name: Clonar rama actual
|
||||
run: |
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
|
||||
git fetch origin "$BRANCH"
|
||||
git checkout "$BRANCH"
|
||||
|
||||
- name: Configurar keystore de firma
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
if [ ! -f "$KEYSTORE_PATH" ]; then
|
||||
echo "ERROR: Keystore no encontrado en $KEYSTORE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
echo "storeFile=$KEYSTORE_PATH" > android/key.properties
|
||||
echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
||||
echo "keyAlias=$KEYSTORE_ALIAS" >> android/key.properties
|
||||
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
||||
echo "✅ Keystore configurado"
|
||||
|
||||
- name: Bump versión patch + commit
|
||||
run: |
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
git config user.name "ShanaiaBot"
|
||||
git config user.email "shanaia@freetimelab.es"
|
||||
CURRENT=$(grep '^version:' pubspec.yaml | awk '{print $2}')
|
||||
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
|
||||
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
|
||||
else
|
||||
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
|
||||
MINOR=$(echo "$SEMVER" | cut -d. -f2)
|
||||
PATCH=$(echo "$SEMVER" | cut -d. -f3)
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
|
||||
fi
|
||||
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
|
||||
git add pubspec.yaml
|
||||
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
|
||||
git push origin "HEAD:${BRANCH}"
|
||||
|
||||
- name: Extraer versión
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
|
||||
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Obtener dependencias
|
||||
run: flutter pub get
|
||||
|
||||
# OBLIGATORIO en este runner autoalojado, no es higiene opcional.
|
||||
#
|
||||
# El directorio build/ sobrevive entre ejecuciones y el merge
|
||||
# incremental de recursos de Gradle se queda rancio: los drawables
|
||||
# ic_auto_eq_on/ic_auto_eq_off (anadidos el 31-07 en 2540556) NUNCA
|
||||
# llegaron a entrar en el APK, mientras que ic_stat_pluriwave -- misma
|
||||
# carpeta, anadido el 02-07 -- si estaba. Verificado extrayendo el
|
||||
# base.apk instalado en el dispositivo: los ficheros no existen ni como
|
||||
# entrada del zip ni en resources.arsc.
|
||||
#
|
||||
# El coste fue semanas de diagnostico equivocado. Cada setState
|
||||
# publicaba una CustomAction cuyo icono resolvia a 0, y
|
||||
# PlaybackStateCompat.CustomAction.Builder lanza en ese caso, abortando
|
||||
# setState antes de activar la sesion de medios: Android Auto se
|
||||
# quedaba con la sesion congelada e inactiva. El codigo Dart siempre
|
||||
# llegaba porque se recompila; el recurso Android no.
|
||||
- name: Limpiar artefactos de compilacion
|
||||
run: flutter clean
|
||||
|
||||
- name: Reinstalar dependencias tras limpiar
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build APK release
|
||||
run: flutter build apk --release
|
||||
|
||||
# Guardian de recursos: el APK debe contener los drawables que el codigo
|
||||
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
|
||||
#
|
||||
# Un nombre que no resuelve devuelve id 0, y eso no falla la
|
||||
# compilacion: falla en el coche. Concretamente
|
||||
# PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw
|
||||
# aborta AudioService.setState antes de activar la sesion de medios, y
|
||||
# Android Auto se queda con la interfaz congelada. Paso exactamente eso
|
||||
# entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara.
|
||||
#
|
||||
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
|
||||
# Guardian de recursos: el APK debe contener los drawables que el codigo
|
||||
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
|
||||
#
|
||||
# Un nombre que no resuelve devuelve id 0, y eso no falla la
|
||||
# compilacion: falla en el coche. PlaybackStateCompat.CustomAction
|
||||
# .Builder lanza con icono 0, ese throw aborta AudioService.setState
|
||||
# antes de activar la sesion de medios, y Android Auto se queda con la
|
||||
# interfaz congelada. Paso exactamente eso desde el 31-07 (commit
|
||||
# 2540556) sin que nada lo detectara.
|
||||
#
|
||||
# La primera version de este paso daba FALSOS POSITIVOS: no comprobaba
|
||||
# que el APK existiera ni que unzip estuviera disponible, asi que
|
||||
# cualquier fallo de la tuberia se reportaba como "faltan todos los
|
||||
# recursos". Un guardian que miente es peor que no tener guardian:
|
||||
# manda a buscar fantasmas. De ahi que ahora verifique primero sus
|
||||
# propias herramientas y vuelque el inventario real antes de juzgar.
|
||||
#
|
||||
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
|
||||
# Guardian de recursos: el APK debe contener los drawables que el codigo
|
||||
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
|
||||
#
|
||||
# Un nombre que no resuelve devuelve id 0. Eso no falla la compilacion:
|
||||
# falla en el coche. PlaybackStateCompat.CustomAction.Builder lanza con
|
||||
# icono 0, ese throw aborta AudioService.setState antes de activar la
|
||||
# sesion de medios, y Android Auto se queda con la interfaz congelada.
|
||||
# Paso exactamente eso desde el 31-07 (commit 2540556) sin deteccion.
|
||||
#
|
||||
# Se inspecciona resources.arsc, NO las rutas del zip: el APK release
|
||||
# acorta/renombra las rutas de recursos (una version anterior de este
|
||||
# paso listo "ningun drawable" en un APK de 105MB, que es imposible).
|
||||
# Los NOMBRES de recurso siguen en la tabla pase lo que pase.
|
||||
#
|
||||
# El centinela existe porque este guardian ya mintio una vez: al no
|
||||
# validar su propio metodo, reporto como ausente hasta un recurso que
|
||||
# estaba verificado presente. Si el centinela no aparece, la inspeccion
|
||||
# no es fiable y NO tenemos derecho a declarar nada ausente.
|
||||
#
|
||||
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
|
||||
- name: Verificar recursos criticos en el APK
|
||||
run: |
|
||||
set -u
|
||||
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||
CENTINELA=station_art_nova
|
||||
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "El APK no esta donde se esperaba: $APK"
|
||||
find build/app/outputs -name '*.apk' 2>/dev/null || echo " (nada)"
|
||||
exit 1
|
||||
fi
|
||||
echo "APK: $APK ($(wc -c < "$APK") bytes)"
|
||||
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo "unzip no esta disponible: no se puede inspeccionar el APK."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARSC=$(mktemp)
|
||||
unzip -p "$APK" resources.arsc > "$ARSC" 2>/dev/null || true
|
||||
if [ ! -s "$ARSC" ]; then
|
||||
echo "No se pudo extraer resources.arsc del APK."
|
||||
exit 1
|
||||
fi
|
||||
echo "resources.arsc: $(wc -c < "$ARSC") bytes"
|
||||
|
||||
if ! grep -a -q "$CENTINELA" "$ARSC"; then
|
||||
echo "El centinela '$CENTINELA' no aparece en la tabla de recursos."
|
||||
echo "La inspeccion no es fiable; no se declara nada ausente."
|
||||
exit 1
|
||||
fi
|
||||
echo "Centinela '$CENTINELA' localizado: la inspeccion es fiable."
|
||||
|
||||
FALTAN=0
|
||||
for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do
|
||||
if grep -a -q "$RECURSO" "$ARSC"; then
|
||||
echo "OK $RECURSO"
|
||||
else
|
||||
echo "FALTA $RECURSO"
|
||||
FALTAN=$((FALTAN + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FALTAN" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "$FALTAN drawable(s) resueltos por nombre NO estan en el APK."
|
||||
echo "En runtime resolveran a id 0 y tumbaran la sesion de medios."
|
||||
exit 1
|
||||
fi
|
||||
echo "Todos los recursos criticos viajan en el APK."
|
||||
|
||||
- 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 }}"
|
||||
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"
|
||||
DESTINO="/opt/ftl-builds/builds/pluriwave/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}"
|
||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
||||
build/app/outputs/flutter-apk/app-release.apk \
|
||||
"ShanaiaBot@192.168.0.33:${DESTINO}/${APK_NOMBRE}"
|
||||
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} → ${APK_NOMBRE}"
|
||||
echo "✅ AAB: builds.freetimelab.es → pluriwave → 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 "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' && 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' && 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
|
||||
PLAY_TRACK: internal
|
||||
PLAY_RELEASE_STATUS: completed
|
||||
run: fastlane android upload_internal
|
||||
|
||||
- name: Notificar Telegram
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
COMMIT="${{ steps.version.outputs.commit }}"
|
||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||
BOT_TOKEN=$(plutil -extract 'EnvironmentVariables:TELEGRAM_BOT_TOKEN' raw /Users/freetlab/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || echo "")
|
||||
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
||||
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
|
||||
# se omite si falta el secreto, y un aviso que dice "publicado"
|
||||
# cuando no se publico es peor que no avisar.
|
||||
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
|
||||
else
|
||||
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
|
||||
fi
|
||||
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
|
||||
@@ -11,10 +11,7 @@ on:
|
||||
jobs:
|
||||
flutter-ci:
|
||||
name: Test + Build
|
||||
#runs-on: macos-14
|
||||
runs-on: [self-hosted, macos, arm64, flutter]
|
||||
env:
|
||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
||||
runs-on: macmini-flutter
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -32,12 +32,6 @@ migrate_working_dir/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
.atl/
|
||||
|
||||
# Test-run scratch files (created and best-effort cleaned up by
|
||||
# pantalla_ajustes_emisoras_personalizadas_test.dart; ignored as a backstop
|
||||
# in case a run is interrupted before its own cleanup runs)
|
||||
test/fixtures/.tmp_*
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Changelog — PluriWave
|
||||
|
||||
## [0.5.0] — 2026-04-04
|
||||
|
||||
### Añadido
|
||||
- **VisualizadorAudio** — visualizador de barras animadas en `PantallaReproductor`. 24 barras verticales con movimiento orgánico pseudo-aleatorio (combinación de ondas seno con fases distintas). Se activa al reproducir y decae suavemente al parar. Sin FFT real ni permisos de micrófono — animación simulada visualmente equivalente a las apps de streaming.
|
||||
- **IndicadorReproduccion** — versión compacta de 3 barras para el `MiniReproductor`. Reemplaza el icono estático de radio y pulsa mientras hay audio activo.
|
||||
|
||||
## [0.4.0] — 2026-04-04
|
||||
|
||||
### Añadido
|
||||
- **PantallaReproductor** — pantalla completa del reproductor. Accesible tocando MiniReproductor o cualquier emisora. Incluye: artwork/logo grande con sombra animada al reproducir, nombre + chips info (país, idioma), codec/bitrate, controles play/pause/stop con indicador "en vivo", botón favorito (toggle), widget de timer (iniciar/cancelar desde la pantalla), animación de entrada slide-up. Transición pageRoute desde cualquier pantalla.
|
||||
- **PantallaAjustes** — pantalla de ajustes básica (tab nuevo en NavigationBar). Muestra estado del sistema (filtro emisoras, audio background), conteo de favoritos, preview de features próximas (Export/Import, radio personalizada, ecualizador).
|
||||
- **MiniReproductor** — ahora es tappable: toca la barra para abrir PantallaReproductor.
|
||||
- **NavigationBar** — añadido tab "Ajustes" (4 destinos: Inicio/Buscar/Favoritos/Ajustes).
|
||||
|
||||
## [0.3.0] — 2026-04-04
|
||||
|
||||
### Fixes (prioridad alta — petición WhikY)
|
||||
|
||||
- **Audio en background** — `ServicioAudio` refactorizado para delegar toda la reproducción a `PluriWaveAudioHandler` (audio_service). La notificación foreground de Android mantiene el audio vivo al apagar pantalla. Handler inicializado en `main.dart` con `AudioService.init()` y registrado globalmente. `onTaskRemoved` libera recursos al cerrar la app. `mediaItem` propagado con nombre, artista y artwork de la emisora.
|
||||
- **Filtrar emisoras rotas** — `ServicioRadio` añade `lastcheckok=1` en todas las peticiones a la API. Solo se devuelven emisoras verificadas como funcionales por Radio Browser.
|
||||
- **Errores como SnackBar** — `EstadoRadio` emite errores de reproducción y búsqueda por `errorStream` (StreamController broadcast). `_PaginaPrincipalState.didChangeDependencies` suscribe al stream y muestra `SnackBar` flotante de 3 segundos. Los errores de carga de lista siguen como banner inline (no bloquean la UI).
|
||||
- **Icono de app** — Generado con Stable Diffusion XL: diseño morado, ondas de radio blancas, estilo Material You. Todos los tamaños Android generados (mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi, 48-192px). `ic_launcher_round` añadido. `android:roundIcon` en AndroidManifest.
|
||||
|
||||
### Ficheros modificados
|
||||
| Fichero | Cambio |
|
||||
|---|---|
|
||||
| `lib/main.dart` | `AudioService.init()` + `registrarHandler()` |
|
||||
| `lib/servicios/servicio_audio.dart` | Arquitectura background completa |
|
||||
| `lib/servicios/servicio_radio.dart` | `lastcheckok=1` en todas las peticiones |
|
||||
| `lib/estado/estado_radio.dart` | `errorStream` en lugar de campo `_error` |
|
||||
| `lib/app.dart` | Listener `errorStream` → SnackBar + theme SnackBar |
|
||||
| `android/app/src/main/AndroidManifest.xml` | `roundIcon` |
|
||||
| `android/app/src/main/res/mipmap-*/` | Iconos generados (5 densidades) |
|
||||
|
||||
## [0.2.0] — 2026-04-04
|
||||
|
||||
### Añadido
|
||||
- **CI/CD Gitea Actions** — workflow `.gitea/workflows/ci.yml` para el runner `macmini-flutter`. Jobs en secuencia: `flutter pub get` → `flutter test` → `flutter build apk --release` → `flutter build appbundle --release`. APK y AAB subidos como artifacts con el SHA del commit en el nombre (`pluriwave-apk-<sha>`, `pluriwave-aab-<sha>`). Notificación Telegram al finalizar: ✅ éxito con commit y rama, ❌ fallo con enlace al log. Activado en push a `main` y PRs contra `main`.
|
||||
- **`ARQ-REVISION-F1.md`** — revisión de arquitectura del stack Flutter. Veredicto: aprobado. Sin conflictos de dependencias (`audio_session` compartido entre `just_audio` y `audio_service` sin colisión; `rxdart` sin conflicto). Todas las licencias OSI-approved (MIT, Apache-2.0, BSD). Ajustes pendientes: actualizar `just_audio` a ^0.10.0 con Flutter ≥3.27.0, signing real para Play Store, `google_mobile_ads` comentado hasta tener Ad Unit IDs.
|
||||
|
||||
### Notas técnicas
|
||||
- **Signing**: `build.gradle.kts` usa clave debug para release (TODO preexistente). Válido para CI interno y testing. Play Store requiere keystore como secret en Gitea.
|
||||
- **Secrets necesarios**: `TELEGRAM_BOT_TOKEN` y `TELEGRAM_CHAT_ID` (Settings → Secrets del repo en Gitea).
|
||||
|
||||
### Ficheros añadidos
|
||||
| Fichero | Descripción |
|
||||
|---|---|
|
||||
| `.gitea/workflows/ci.yml` | Workflow CI/CD Flutter completo (+66 líneas) |
|
||||
| `ARQ-REVISION-F1.md` | Revisión arquitectura F1 — stack, licencias, ajustes (+143 líneas) |
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# PluriWave
|
||||
# 📻 PluriWave
|
||||
|
||||
Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI premium.
|
||||
|
||||
## Features
|
||||
|
||||
- **+53.000 emisoras** de 238 países (Radio Browser API)
|
||||
- **Ecualizador por emisora** — guarda tu preset favorito para cada radio
|
||||
- **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
|
||||
- **Timer de auto-apagado** — perfecto para dormir
|
||||
- **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
|
||||
- **Favoritos** — acceso rápido a emisoras preferidas
|
||||
- **Compartir** — envía emisoras a tus amigos
|
||||
- **UI premium** — Material You, visualizador de audio, animaciones fluidas
|
||||
- 🌍 **+53.000 emisoras** de 238 países (Radio Browser API)
|
||||
- 🎛️ **Ecualizador por emisora** — guarda tu preset favorito para cada radio
|
||||
- 🎵 **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
|
||||
- ⏰ **Timer de auto-apagado** — perfecto para dormir
|
||||
- 🔊 **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
|
||||
- ⭐ **Favoritos** — accede rápido a tus emisoras preferidas
|
||||
- 📤 **Compartir** — envía emisoras a tus amigos
|
||||
- 🎨 **UI premium** — Material You, visualizador de audio, animaciones fluidas
|
||||
|
||||
## Monetización
|
||||
|
||||
@@ -22,31 +22,13 @@ Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI p
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Flutter (Android + iOS)
|
||||
- **Radio API**: Radio Browser (gratis, +53K emisoras)
|
||||
- **Radio API**: [Radio Browser](https://api.radio-browser.info/) (gratis, +53K emisoras)
|
||||
- **Audio**: just_audio + audio_service
|
||||
- **Ecualizador**: just_audio equalizer (Android nativo)
|
||||
- **Reconocimiento**: AudD API (1000 req/mes free)
|
||||
- **Ads**: Google AdMob
|
||||
- **Compras**: in_app_purchase
|
||||
|
||||
## CI/CD
|
||||
|
||||
Workflow Gitea Actions en `.gitea/workflows/ci.yml`, runner `macmini-flutter`.
|
||||
|
||||
**Jobs:** `flutter pub get` → `flutter test` → `build apk --release` → `build appbundle --release`
|
||||
|
||||
**Artifacts:** APK y AAB guardados en Gitea con nombre `pluriwave-apk-<sha>` / `pluriwave-aab-<sha>`.
|
||||
|
||||
**Notificaciones:** Telegram al completar (éxito ✅ / fallo ❌).
|
||||
|
||||
**Secrets necesarios en el repo:**
|
||||
| Secret | Uso |
|
||||
|---|---|
|
||||
| `TELEGRAM_BOT_TOKEN` | Notificaciones CI |
|
||||
| `TELEGRAM_CHAT_ID` | Canal de destino |
|
||||
|
||||
> **Signing**: build de release usa clave debug (válido para CI interno). Para Play Store se requiere keystore como secret adicional.
|
||||
|
||||
## Desarrollador
|
||||
|
||||
FreeTimeLab — [freetimelab.es](https://freetimelab.es)
|
||||
@@ -54,4 +36,3 @@ FreeTimeLab — [freetimelab.es](https://freetimelab.es)
|
||||
## Licencia
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Internacionalización AAA
|
||||
|
||||
- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma.
|
||||
- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema.
|
||||
- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma.
|
||||
- [x] Añadir soporte inicial para un conjunto amplio de idiomas muy hablados: inglés, español, chino, hindi, árabe, portugués, francés, ruso, alemán, japonés, indonesio, bengalí e italiano.
|
||||
- [x] Ejecutar escaneo UTF-8 sobre ARB/código tocado y corregir corrupciones visibles en los textos migrados.
|
||||
- [ ] Validar no solo el guardado UTF-8 en código, sino también el render real en la aplicación para acentos, ñ, signos, alfabetos no latinos y direcciones RTL.
|
||||
- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones.
|
||||
- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
|
||||
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`.
|
||||
- [ ] Revisar profesionalmente todas las traducciones nuevas con hablantes nativos o servicio especializado antes de considerarlas definitivas.
|
||||
- [ ] Preparar traducciones adicionales si se decide ampliar más allá del conjunto inicial.
|
||||
- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
|
||||
- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones.
|
||||
|
||||
## UX y accesibilidad visual
|
||||
|
||||
- [x] Revisar los paneles informativos superiores de cada pantalla: recuperar márgenes internos elegantes para que el texto no quede pegado a los bordes.
|
||||
- [x] Añadir comportamiento adaptativo en el header premium para escalas de texto grandes y pantallas estrechas.
|
||||
- [ ] Probar la aplicación con escalas de texto grandes/muy grandes del sistema en dispositivo real o golden tests.
|
||||
- [ ] Diseñar una solución elegante para textos largos en todos los paneles secundarios: reflow, límites razonables, scroll, wraps controlados y jerarquías que mantengan la estética AAA.
|
||||
|
||||
## Grabaciones
|
||||
|
||||
- [x] Añadir en Ajustes un acceso elegante para abrir la carpeta de grabaciones con el gestor de ficheros del sistema mediante intent.
|
||||
- [x] Añadir configuración de tamaño máximo de fichero de grabación; valor por defecto: 500 MB.
|
||||
- [x] Detener automáticamente la grabación si se para o pausa la reproducción.
|
||||
- [x] Detener automáticamente la grabación si se cambia de emisora.
|
||||
- [ ] Probar en Android real que el intent de carpeta funciona con rutas internas y rutas escogidas por el usuario.
|
||||
|
||||
## Búsqueda de emisoras
|
||||
|
||||
- [x] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
|
||||
|
||||
## Favoritos
|
||||
|
||||
- [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
|
||||
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
|
||||
|
||||
## Agrupaciones de favoritos
|
||||
|
||||
- [x] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
|
||||
- [x] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
|
||||
- [x] Gestionar desde la vista Favoritos qué emisoras pertenecen a cada agrupación/lista.
|
||||
- [x] Diseñar migración SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
|
||||
- [x] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
|
||||
- [x] Completar UI en Favoritos para mover emisoras entre listas.
|
||||
@@ -23,11 +23,6 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
unawaited_futures: true
|
||||
prefer_final_locals: true
|
||||
avoid_dynamic_calls: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
import java.util.Properties
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
fun secret(name: String, propertyName: String): String? =
|
||||
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
|
||||
?: System.getenv(name)?.takeIf { it.isNotBlank() }
|
||||
|
||||
android {
|
||||
namespace = "es.freetimelab.pluriwave"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -31,38 +20,21 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "es.freetimelab.pluriwave"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
|
||||
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
|
||||
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
|
||||
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
|
||||
|
||||
if (!storeFilePath.isNullOrBlank()) {
|
||||
storeFile = file(storeFilePath)
|
||||
}
|
||||
if (!storePasswordValue.isNullOrBlank()) {
|
||||
storePassword = storePasswordValue
|
||||
}
|
||||
if (!keyAliasValue.isNullOrBlank()) {
|
||||
keyAlias = keyAliasValue
|
||||
}
|
||||
if (!keyPasswordValue.isNullOrBlank()) {
|
||||
keyPassword = keyPasswordValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,21 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Permisos requeridos para streaming de audio -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<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"/>
|
||||
<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
|
||||
and by this legacy permission below it. Normal permission: granted at
|
||||
install, no runtime prompt.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30"/>
|
||||
|
||||
<application
|
||||
android:label="PluriWave"
|
||||
android:label="pluriwave"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
@@ -50,103 +25,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Servicio de audio en background (audio_service) -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".PluriWaveAlarmService"
|
||||
android:foregroundServiceType="mediaPlayback|systemExempted"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".PluriWaveAlarmReceiver"
|
||||
android:exported="false"
|
||||
android:directBootAware="true">
|
||||
<intent-filter>
|
||||
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
|
||||
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
|
||||
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
|
||||
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".PluriWaveBootReceiver"
|
||||
android:exported="true"
|
||||
android:directBootAware="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.USER_UNLOCKED"/>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||
<action android:name="android.intent.action.TIME_SET"/>
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
|
||||
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/pluriwave_file_paths" />
|
||||
</provider>
|
||||
|
||||
<!--
|
||||
Publishes the app-private recordings folder as a browsable storage
|
||||
root for the system file manager. MANAGE_DOCUMENTS restricts direct
|
||||
access to the document framework (DocumentsUI); grantUriPermissions
|
||||
lets it hand single-file access to whatever app the user picks.
|
||||
-->
|
||||
<provider
|
||||
android:name=".RecordingsDocumentsProvider"
|
||||
android:authorities="${applicationId}.recordings"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Android Auto discovery (android-auto-media) -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- AdMob application id (iap-freemium-unlock). Real id, provisioned
|
||||
in the AdMob console. Safe to use in all build modes — this id
|
||||
only initializes the SDK; it never serves an ad by itself, so it
|
||||
carries none of the "don't tap your own ads" risk that ad unit
|
||||
ids do. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-6038935671414339~4085536467" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Localized strings for native alarm notifications, channels and choosers.
|
||||
*
|
||||
* Flutter is the single source of truth for i18n: it pushes the current-locale
|
||||
* strings via the `setNotificationStrings` MethodChannel whenever the app locale
|
||||
* is (re)configured. They are persisted in device-protected storage so the
|
||||
* native side can read them when building a notification or channel even while
|
||||
* the Flutter engine is dead (alarm fired from a killed app, after reboot, in
|
||||
* direct-boot). Every getter falls back to English when a value is unset.
|
||||
*/
|
||||
object AlarmNotificationStrings {
|
||||
private const val PREFS = "pluriwave_alarm_strings"
|
||||
|
||||
const val KEY_RING_TITLE = "ringTitle"
|
||||
const val KEY_SNOOZE = "snoozeLabel"
|
||||
const val KEY_STOP = "stopLabel"
|
||||
const val KEY_SKIP = "skipLabel"
|
||||
const val KEY_SNOOZE_AGAIN = "snoozeAgainLabel"
|
||||
const val KEY_FIRE_CHANNEL_NAME = "fireChannelName"
|
||||
const val KEY_FIRE_CHANNEL_DESC = "fireChannelDescription"
|
||||
const val KEY_PRE_NOTICE_CHANNEL_NAME = "preNoticeChannelName"
|
||||
const val KEY_PRE_NOTICE_CHANNEL_DESC = "preNoticeChannelDescription"
|
||||
const val KEY_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
|
||||
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
|
||||
const val KEY_OPEN_FOLDER = "openFolderTitle"
|
||||
const val KEY_OPEN_RECORDING = "openRecordingTitle"
|
||||
const val KEY_RECORDINGS_ROOT_TITLE = "recordingsRootTitle"
|
||||
const val KEY_MISSED_TITLE = "missedTitle"
|
||||
const val KEY_MISSED_TEMPLATE = "missedTemplate"
|
||||
|
||||
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
|
||||
fun save(context: Context, values: Map<String, Any?>) {
|
||||
val editor = prefs(context).edit()
|
||||
for ((key, value) in values) {
|
||||
val str = value as? String
|
||||
if (str.isNullOrBlank()) editor.remove(key) else editor.putString(key, str)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
fun ringTitle(context: Context) = get(context, KEY_RING_TITLE, "PluriWave alarm")
|
||||
fun snoozeLabel(context: Context) = get(context, KEY_SNOOZE, "Snooze")
|
||||
fun stopLabel(context: Context) = get(context, KEY_STOP, "Stop")
|
||||
fun skipLabel(context: Context) = get(context, KEY_SKIP, "Skip this time")
|
||||
fun snoozeAgainLabel(context: Context) = get(context, KEY_SNOOZE_AGAIN, "Snooze again")
|
||||
fun fireChannelName(context: Context) = get(context, KEY_FIRE_CHANNEL_NAME, "Ringing alarms")
|
||||
fun fireChannelDescription(context: Context) =
|
||||
get(context, KEY_FIRE_CHANNEL_DESC, "Urgent sound and screen when a music alarm must ring")
|
||||
fun preNoticeChannelName(context: Context) =
|
||||
get(context, KEY_PRE_NOTICE_CHANNEL_NAME, "Alarm reminders")
|
||||
fun preNoticeChannelDescription(context: Context) =
|
||||
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
|
||||
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
|
||||
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
|
||||
|
||||
/** Title of the storage root published by [RecordingsDocumentsProvider]. */
|
||||
fun recordingsRootTitle(context: Context) =
|
||||
get(context, KEY_RECORDINGS_ROOT_TITLE, "PluriWave recordings")
|
||||
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
|
||||
fun missedText(context: Context, name: String): String =
|
||||
format(
|
||||
// "10 minutes" mirrors AlarmScheduler.AUTO_SILENCE_MILLIS
|
||||
// (READ-3/READ-4) -- keep both, and the alarmMissedNotificationText
|
||||
// entry of ALL 13 lib/l10n/app_*.arb files, in sync.
|
||||
get(context, KEY_MISSED_TEMPLATE, "{name} was silenced automatically after 10 minutes."),
|
||||
name
|
||||
)
|
||||
|
||||
fun preNoticeText(context: Context, minutes: Long): String =
|
||||
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
|
||||
|
||||
fun snoozeCountdownText(context: Context, minutes: Long): String =
|
||||
format(get(context, KEY_SNOOZE_COUNTDOWN_TEMPLATE, "Rings in {minutes} min"), minutes)
|
||||
|
||||
private fun format(template: String, minutes: Long): String =
|
||||
template.replace("{minutes}", minutes.toString())
|
||||
|
||||
private fun format(template: String, name: String): String =
|
||||
template.replace("{name}", name)
|
||||
|
||||
private fun get(context: Context, key: String, fallback: String): String =
|
||||
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
/**
|
||||
* Shared brand color for native notification icons.
|
||||
*
|
||||
* Single source of truth for the cyan tint applied via `NotificationCompat.Builder.setColor()`
|
||||
* across all PluriWave alarm and audio notifications, mirroring the [AlarmNotificationStrings]
|
||||
* shared-constants precedent.
|
||||
*/
|
||||
object NotificationBrand {
|
||||
@ColorInt const val CYAN: Int = 0xFF21D4D9.toInt()
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: run {
|
||||
Log.w(TAG, "alarm.receiver missing alarmId action=${intent.action}")
|
||||
return
|
||||
}
|
||||
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
|
||||
val snoozeMinutes = sanitizeSnoozeMinutes(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5))
|
||||
Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_FIRE -> {
|
||||
AlarmScheduler(context).onAlarmFired(alarmId)
|
||||
PluriWaveAlarmService.start(context, intent)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE)
|
||||
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
|
||||
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
|
||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
}
|
||||
// The service's startForeground notification (single FSI owner) is
|
||||
// posted by PluriWaveAlarmService.start above; the receiver must NOT
|
||||
// post a duplicate fire notification.
|
||||
try {
|
||||
context.startActivity(launch)
|
||||
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.receiver fire startActivity ERROR id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
ACTION_PRE_NOTICE -> {
|
||||
showPreNoticeNotification(
|
||||
context,
|
||||
alarmId,
|
||||
title,
|
||||
snoozeMinutes,
|
||||
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
|
||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
||||
)
|
||||
}
|
||||
ACTION_POSTPONE_NEXT -> {
|
||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
|
||||
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
|
||||
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
putExtra(EXTRA_ALARM_ACTION, ACTION_POSTPONE_NEXT)
|
||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAt)
|
||||
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
|
||||
}
|
||||
try {
|
||||
context.startActivity(launch)
|
||||
Log.d(TAG, "alarm.receiver postponeNext startActivity OK id=$alarmId")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.receiver postponeNext startActivity ERROR id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
ACTION_SKIP_NEXT -> {
|
||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
|
||||
AlarmScheduler(context).skipNext(alarmId)
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
|
||||
}
|
||||
try {
|
||||
context.startActivity(launch)
|
||||
Log.d(TAG, "alarm.receiver skipNext startActivity OK id=$alarmId")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
|
||||
}
|
||||
}
|
||||
ACTION_SNOOZE_COUNTDOWN -> {
|
||||
AlarmScheduler(context).handleSnoozeCountdownTick(alarmId)
|
||||
}
|
||||
ACTION_MISSED -> {
|
||||
AlarmScheduler(context).onAlarmMissed(alarmId)
|
||||
}
|
||||
ACTION_SNOOZE_AGAIN -> {
|
||||
val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes)
|
||||
if (snoozed != null) {
|
||||
// Reuses the existing native-snooze event so Flutter records
|
||||
// the new snooze (live) and the cold-start sync imports it.
|
||||
MainActivity.notifyAlarmEvent(
|
||||
mapOf(
|
||||
"alarmId" to alarmId,
|
||||
"alarmTitle" to snoozed.title,
|
||||
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
|
||||
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
|
||||
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
|
||||
"snoozeMinutes" to snoozeMinutes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
ACTION_CANCEL_SNOOZE -> {
|
||||
val occurrence = AlarmScheduler(context).cancelSnooze(alarmId)
|
||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
||||
if (occurrence != null) {
|
||||
MainActivity.notifyAlarmEvent(
|
||||
mapOf(
|
||||
"alarmId" to alarmId,
|
||||
"alarmTitle" to title,
|
||||
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZE_CANCELLED,
|
||||
"occurrenceAtMillis" to occurrence
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPreNoticeNotification(
|
||||
context: Context,
|
||||
alarmId: String,
|
||||
title: String,
|
||||
snoozeMinutes: Int,
|
||||
triggerAtMillis: Long,
|
||||
occurrenceAtMillis: Long
|
||||
) {
|
||||
ensureChannel(context)
|
||||
|
||||
val remaining = computeRemainingMinutes(triggerAtMillis)
|
||||
val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
|
||||
|
||||
val openAppIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode(alarmId, 1),
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val skipNextIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode(alarmId, 2),
|
||||
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
||||
action = ACTION_SKIP_NEXT
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val postponeNextIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode(alarmId, 3),
|
||||
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
||||
action = ACTION_POSTPONE_NEXT
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_ALARM_TITLE, title)
|
||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
putExtra(EXTRA_TRIGGER_AT, triggerAtMillis)
|
||||
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_pluriwave)
|
||||
.setColor(NotificationBrand.CYAN)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openAppIntent)
|
||||
.addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
|
||||
.addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
|
||||
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId remaining=$remaining")
|
||||
} catch (error: SecurityException) {
|
||||
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
|
||||
}
|
||||
|
||||
// Re-arm the next minute tick so the countdown keeps live-updating
|
||||
// until the real alarm fires. Reuses the SAME [remaining] computed
|
||||
// above for the notification text to avoid a second clock read that
|
||||
// could drift and cause an off-by-one between displayed text and the
|
||||
// next-boundary math.
|
||||
AlarmScheduler(context).armNextPreNoticeCountdownTick(
|
||||
id = alarmId,
|
||||
title = title,
|
||||
snoozeMinutes = snoozeMinutes,
|
||||
triggerAtMillis = triggerAtMillis,
|
||||
occurrenceAtMillis = occurrenceAtMillis,
|
||||
remaining = remaining
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the number of minutes remaining until [triggerAtMillis] using
|
||||
* ceiling rounding (consistent with [AlarmScheduler]'s snooze-countdown
|
||||
* ceilMinutes), clamped to a minimum of 1. Handles Doze-delayed wakeups
|
||||
* and clock drift.
|
||||
*/
|
||||
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
|
||||
maxOf(1L, (triggerAtMillis - System.currentTimeMillis() + 59_999L) / 60_000L)
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
// Re-create each time so the localized name/description refresh after a
|
||||
// locale change (Android updates them on an existing channel).
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
AlarmNotificationStrings.preNoticeChannelName(context),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = AlarmNotificationStrings.preNoticeChannelDescription(context)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
||||
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
||||
|
||||
companion object {
|
||||
const val TAG = "PluriWave"
|
||||
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
|
||||
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
|
||||
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
|
||||
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
|
||||
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
|
||||
const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN"
|
||||
const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN"
|
||||
const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE"
|
||||
const val ACTION_MISSED = "es.freetimelab.pluriwave.alarm.MISSED"
|
||||
const val EXTRA_ALARM_ID = "alarmId"
|
||||
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
||||
const val EXTRA_STATION_NAME = "stationName"
|
||||
const val EXTRA_STATION_URL = "stationUrl"
|
||||
const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName"
|
||||
const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl"
|
||||
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
|
||||
const val EXTRA_VOLUME = "volume"
|
||||
const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos"
|
||||
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
|
||||
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
|
||||
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
||||
|
||||
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
|
||||
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
|
||||
|
||||
/**
|
||||
* Shared PendingIntent requestCode formula (READ-3/READ-4): kept in
|
||||
* ONE place so instance call sites (showPreNoticeNotification, which
|
||||
* resolve this unqualified via companion-member lookup) and
|
||||
* companion-object call sites ([pendingMissedIntent]) can never
|
||||
* diverge into two different formulas for the same alarm id.
|
||||
*/
|
||||
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
|
||||
|
||||
/** Shared PendingIntent factory for the MISSED transition alarm (Decision 3). */
|
||||
fun pendingMissedIntent(context: Context, alarmId: String, flags: Int): PendingIntent? =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode(alarmId, 4),
|
||||
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
||||
action = ACTION_MISSED
|
||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
flags or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,907 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Foreground service that owns native alarm audio and the single ringing
|
||||
* notification (NOTIFICATION_ID, full-screen intent).
|
||||
*
|
||||
* Sole ring-audio ownership: this service is the ONLY audio source for the
|
||||
* whole ring, from start to dismiss/snooze/timeout, on STREAM_ALARM via its
|
||||
* own MediaPlayer (station stream, fallback station, or bundled WAV). The
|
||||
* Flutter ringing screen is display-only: it never starts a player and
|
||||
* never touches system volume, only EstadoAlarmas.finalizarEjecucion /
|
||||
* posponerAlarma from Stop/Snooze/back.
|
||||
*/
|
||||
class PluriWaveAlarmService : Service() {
|
||||
private var player: MediaPlayer? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var activeAlarmId: String? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var stationFallbackRunnable: Runnable? = null
|
||||
private var fadeLoopRunnable: Runnable? = null
|
||||
private var fadeAnchorElapsedMs: Long = 0L
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val noopAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* Paired-write helper (feedback item, READ-6): the instance-scoped
|
||||
* [activeAlarmId] and the same-process companion [activeRingingId] must
|
||||
* always move together -- setting one without the other would let
|
||||
* [stopActiveVerified] read a stale/wrong ring state. Used at every write
|
||||
* site instead of assigning each field separately.
|
||||
*/
|
||||
private fun setActiveIds(id: String?) {
|
||||
activeAlarmId = id
|
||||
activeRingingId = id
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2):
|
||||
// lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead
|
||||
// of trusting an async startService dispatch to have completed.
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action
|
||||
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
|
||||
|
||||
when (action) {
|
||||
ACTION_STOP -> {
|
||||
stopAlarm(requestedId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_STOP_ACTIVE -> {
|
||||
// Id-agnostic fail-safe stop (Decision 1): silences whatever is
|
||||
// ringing regardless of the id the caller passed (or omitted).
|
||||
// Used by the ringing UI and the notification Stop action so a
|
||||
// stop request can never silently no-op a live ring.
|
||||
stopEverything()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SNOOZE -> {
|
||||
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
|
||||
if (requestedId != null) {
|
||||
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
|
||||
if (snoozed != null) {
|
||||
// D1 fix (Decision 2.1): report the native snooze back to
|
||||
// Flutter so the canonical config records it. If the engine
|
||||
// is dead this is a no-op and the cold-start sync
|
||||
// (getNativeSnoozeState) reconciles on next launch.
|
||||
MainActivity.notifyAlarmEvent(
|
||||
mapOf(
|
||||
"alarmId" to requestedId,
|
||||
"alarmTitle" to snoozed.title,
|
||||
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
|
||||
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
|
||||
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
|
||||
"snoozeMinutes" to minutes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
stopAlarm(requestedId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
|
||||
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startAlarm(intent: Intent?) {
|
||||
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
|
||||
if (activeAlarmId != null) {
|
||||
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
|
||||
// Orphaned firing record fix (RES-2): the newcomer's own firing
|
||||
// record + auto-silence were already armed by onAlarmFired before
|
||||
// this refusal, so they must be cleared here or a false MISSED
|
||||
// fires 10 minutes later for an alarm that never actually rang.
|
||||
val scheduler = AlarmScheduler(this)
|
||||
scheduler.clearFiringRecord(alarmId)
|
||||
scheduler.cancelAutoSilence(alarmId)
|
||||
return
|
||||
}
|
||||
// onStartCommand re-validation (Decision 4): a redelivered/resurrected
|
||||
// start for a firing record older than the auto-silence bound must
|
||||
// never resume audio -- treat it as an already-missed ring instead.
|
||||
val scheduler = AlarmScheduler(this)
|
||||
val firingAge = scheduler.firingRecordAgeMillis(alarmId)
|
||||
if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) {
|
||||
Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed")
|
||||
scheduler.onAlarmMissed(alarmId)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
// Durable firing record (Decision 4): written before MediaPlayer.start()
|
||||
// (via startAudio below) so a process death mid-ring leaves proof the
|
||||
// ring was in flight for the re-validation above / boot cleanup.
|
||||
scheduler.recordFiring(alarmId)
|
||||
setActiveIds(alarmId)
|
||||
// Anchor the fade curve at RING start, not audio start (design D2):
|
||||
// every source in the 3-stage fallback chain shares this ONE clock,
|
||||
// so a source that begins mid-fade (e.g. after a station timeout)
|
||||
// joins at the already-elapsed gain instead of restarting from
|
||||
// silence (Requirement: Exponential dB fade-in ceiling).
|
||||
fadeAnchorElapsedMs = SystemClock.elapsedRealtime()
|
||||
|
||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
||||
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
|
||||
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
|
||||
val fallbackStationName =
|
||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
|
||||
val fallbackStationUrl =
|
||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
|
||||
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
||||
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
||||
val fadeInSegundos =
|
||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
|
||||
val snoozeMinutes = sanitizeSnoozeMinutes(
|
||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
|
||||
)
|
||||
|
||||
acquireWakeLock()
|
||||
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
|
||||
// slow); startForeground runs first so the ringing surface never lags audio.
|
||||
try {
|
||||
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: same user-visible symptom as a refused
|
||||
// startForegroundService (the ring never actually starts) --
|
||||
// recorded under the SAME tipo so the alarms list surfaces it
|
||||
// regardless of which of the two calls the OS refused.
|
||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
||||
NativeSchedulingFailures.record(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
releaseWakeLock()
|
||||
// Second documented clear site (feedback item, READ-5): this
|
||||
// branch never reaches stopEverything(), so without the same
|
||||
// cleanup below the receiver-armed auto-silence timer + durable
|
||||
// firing record for alarmId would survive and fire a ghost
|
||||
// MISSED notification ~10 minutes later for a ring that never
|
||||
// actually started.
|
||||
scheduler.clearFiringRecord(alarmId)
|
||||
scheduler.cancelAutoSilence(alarmId)
|
||||
setActiveIds(null)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
NativeSchedulingFailures.clear(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
startAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
stationUrl,
|
||||
fallbackStationName,
|
||||
fallbackStationUrl,
|
||||
fallbackSound,
|
||||
volume,
|
||||
fadeInSegundos
|
||||
)
|
||||
}
|
||||
|
||||
private fun startAudio(
|
||||
alarmId: String,
|
||||
stationName: String?,
|
||||
stationUrl: String?,
|
||||
fallbackStationName: String?,
|
||||
fallbackStationUrl: String?,
|
||||
fallbackSound: String?,
|
||||
volume: Float,
|
||||
fadeInSegundos: Int
|
||||
) {
|
||||
player?.release()
|
||||
player = null
|
||||
requestAlarmAudioFocus()
|
||||
startFadeLoop(alarmId, volume, fadeInSegundos)
|
||||
|
||||
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
|
||||
// Each stage owns its own 15s timeout window via scheduleStationFallback.
|
||||
val startBundled: (String) -> Unit = { reason ->
|
||||
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
|
||||
}
|
||||
val startFallbackStation: (String) -> Unit = { reason ->
|
||||
if (fallbackStationUrl.isNullOrBlank()) {
|
||||
startBundled(reason)
|
||||
} else {
|
||||
startStationAudio(
|
||||
alarmId,
|
||||
fallbackStationName,
|
||||
fallbackStationUrl.trim(),
|
||||
volume,
|
||||
fadeInSegundos,
|
||||
"fallback-station",
|
||||
startBundled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (stationUrl.isNullOrBlank()) {
|
||||
startFallbackStation("station url missing")
|
||||
return
|
||||
}
|
||||
startStationAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
stationUrl.trim(),
|
||||
volume,
|
||||
fadeInSegundos,
|
||||
"station",
|
||||
startFallbackStation
|
||||
)
|
||||
}
|
||||
|
||||
private fun startStationAudio(
|
||||
alarmId: String,
|
||||
stationName: String?,
|
||||
stationUrl: String,
|
||||
volume: Float,
|
||||
fadeInSegundos: Int,
|
||||
stage: String,
|
||||
onStageFailed: (String) -> Unit
|
||||
) {
|
||||
player?.release()
|
||||
player = null
|
||||
scheduleStationFallback(alarmId, stage, onStageFailed)
|
||||
val startVolume = computeFadeVolume(
|
||||
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
||||
fadeInSegundos * 1000L,
|
||||
volume
|
||||
)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(alarmAudioAttributes())
|
||||
isLooping = false
|
||||
setVolume(startVolume, startVolume)
|
||||
setDataSource(
|
||||
this@PluriWaveAlarmService,
|
||||
Uri.parse(stationUrl),
|
||||
mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)")
|
||||
)
|
||||
setOnPreparedListener {
|
||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
||||
cancelStationFallback()
|
||||
// Recompute at prepare-time (not the stale value captured
|
||||
// before prepareAsync): buffering can take seconds, during
|
||||
// which the fade clock keeps advancing. Setting volume
|
||||
// BEFORE start() avoids an audible pop (Requirement:
|
||||
// No-fade path starts pop-free; same principle applies
|
||||
// mid-fade).
|
||||
val current = computeFadeVolume(
|
||||
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
||||
fadeInSegundos * 1000L,
|
||||
volume
|
||||
)
|
||||
it.setVolume(current, current)
|
||||
it.start()
|
||||
Log.d(
|
||||
TAG,
|
||||
"alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl"
|
||||
)
|
||||
}
|
||||
setOnCompletionListener {
|
||||
if (activeAlarmId != alarmId) return@setOnCompletionListener
|
||||
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
|
||||
onStageFailed("$stage completed")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
Log.e(
|
||||
TAG,
|
||||
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
|
||||
)
|
||||
runCatching { mp.reset() }
|
||||
if (activeAlarmId == alarmId) {
|
||||
onStageFailed("$stage error")
|
||||
}
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
|
||||
onStageFailed("$stage prepare failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFallbackAudio(
|
||||
alarmId: String,
|
||||
fallbackSound: String?,
|
||||
volume: Float,
|
||||
fadeInSegundos: Int,
|
||||
reason: String
|
||||
) {
|
||||
cancelStationFallback()
|
||||
player?.release()
|
||||
player = null
|
||||
|
||||
val source = fallbackAssetPath(fallbackSound)
|
||||
val startVolume = computeFadeVolume(
|
||||
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
||||
fadeInSegundos * 1000L,
|
||||
volume
|
||||
)
|
||||
try {
|
||||
player = MediaPlayer().apply {
|
||||
setAudioAttributes(alarmAudioAttributes())
|
||||
isLooping = true
|
||||
setVolume(startVolume, startVolume)
|
||||
setFallbackAssetDataSource(this, fallbackSound)
|
||||
setOnPreparedListener {
|
||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
||||
// Recompute at prepare-time; see the matching comment in
|
||||
// startStationAudio's setOnPreparedListener.
|
||||
val current = computeFadeVolume(
|
||||
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
||||
fadeInSegundos * 1000L,
|
||||
volume
|
||||
)
|
||||
it.setVolume(current, current)
|
||||
it.start()
|
||||
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
|
||||
}
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
|
||||
mp.reset()
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleStationFallback(
|
||||
alarmId: String,
|
||||
stage: String,
|
||||
onStageFailed: (String) -> Unit
|
||||
) {
|
||||
cancelStationFallback()
|
||||
val runnable = Runnable {
|
||||
if (activeAlarmId == alarmId) {
|
||||
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
|
||||
onStageFailed("$stage timeout")
|
||||
}
|
||||
}
|
||||
stationFallbackRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single ring-anchored fade loop (Requirement: Exponential dB fade-in
|
||||
* ceiling; design D1). Ticks every [FADE_TICK_MILLIS] and reads [player]
|
||||
* FRESH on each tick -- not a captured MediaPlayer reference -- so the
|
||||
* SAME loop survives the 3-stage source swap (station -> fallback
|
||||
* station -> bundled WAV) instead of needing a fresh ramp per source.
|
||||
* Guarded by [activeAlarmId] so a stale loop from a superseded ring can
|
||||
* never touch a new one's player. Stops rescheduling once elapsed
|
||||
* reaches the fade window; further ticks would be redundant since
|
||||
* [computeFadeVolume] already clamps to the ceiling past that point.
|
||||
*/
|
||||
private fun startFadeLoop(alarmId: String, ceiling: Float, fadeInSegundos: Int) {
|
||||
cancelFadeLoop()
|
||||
if (fadeInSegundos <= 0) return
|
||||
val fadeMs = fadeInSegundos * 1000L
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (activeAlarmId != alarmId) return
|
||||
val elapsed = SystemClock.elapsedRealtime() - fadeAnchorElapsedMs
|
||||
val current = computeFadeVolume(elapsed, fadeMs, ceiling)
|
||||
runCatching { player?.setVolume(current, current) }
|
||||
if (elapsed < fadeMs) {
|
||||
mainHandler.postDelayed(this, FADE_TICK_MILLIS)
|
||||
}
|
||||
}
|
||||
}
|
||||
fadeLoopRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, FADE_TICK_MILLIS)
|
||||
Log.d(TAG, "alarm.service fade loop started id=$alarmId seconds=$fadeInSegundos")
|
||||
}
|
||||
|
||||
private fun cancelFadeLoop() {
|
||||
fadeLoopRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
fadeLoopRunnable = null
|
||||
}
|
||||
|
||||
private fun cancelStationFallback() {
|
||||
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
stationFallbackRunnable = null
|
||||
}
|
||||
|
||||
private fun alarmAudioAttributes(): AudioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
|
||||
private fun stopAlarm(alarmId: String?) {
|
||||
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
||||
// Scope the teardown to the alarm that is actually ringing: a stop
|
||||
// request for a DIFFERENT id (e.g. a second alarm firing while this
|
||||
// one rings — Dart hides the newcomer's notification, which routes
|
||||
// through ACTION_STOP with the newcomer's id) must not kill the
|
||||
// active ring, release its wake lock, or prematurely restore the
|
||||
// device volume. Only the id-specific notification cancel below is
|
||||
// honored for the mismatched id. A null alarmId (internal callers,
|
||||
// onDestroy) keeps full-teardown semantics.
|
||||
if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"alarm.service stop ignored for id=$alarmId (active=$activeAlarmId)"
|
||||
)
|
||||
NotificationManagerCompat.from(this).cancel(
|
||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
||||
)
|
||||
// Orphaned firing record fix (RES-2): this mismatched id is not
|
||||
// being torn down by stopEverything() below (that only tears down
|
||||
// activeAlarmId), so its own firing record + auto-silence must be
|
||||
// cleared here to avoid a false MISSED 10 minutes later.
|
||||
val scheduler = AlarmScheduler(this)
|
||||
scheduler.clearFiringRecord(alarmId)
|
||||
scheduler.cancelAutoSilence(alarmId)
|
||||
return
|
||||
}
|
||||
stopEverything()
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop
|
||||
* entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE
|
||||
* via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE
|
||||
* method so no path can perform a partial teardown. Id-agnostic by design:
|
||||
* it always tears down whatever [activeAlarmId] currently is.
|
||||
*/
|
||||
private fun stopEverything() {
|
||||
val stoppingId = activeAlarmId
|
||||
cancelStationFallback()
|
||||
cancelFadeLoop()
|
||||
try {
|
||||
player?.stop()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service stop player failed", error)
|
||||
}
|
||||
try {
|
||||
player?.release()
|
||||
} catch (error: Throwable) {
|
||||
// Non-atomic release fix (RES-4): a throw here must not abort the
|
||||
// rest of the teardown below (state reset, wakelock, firing-record
|
||||
// clear, stopForeground, stopSelf all still need to run).
|
||||
Log.w(TAG, "alarm.service release player failed", error)
|
||||
}
|
||||
player = null
|
||||
setActiveIds(null)
|
||||
releaseWakeLock()
|
||||
abandonAlarmAudioFocus()
|
||||
if (stoppingId != null) {
|
||||
NotificationManagerCompat.from(this).cancel(
|
||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId)
|
||||
)
|
||||
val scheduler = AlarmScheduler(this)
|
||||
scheduler.clearFiringRecord(stoppingId)
|
||||
scheduler.cancelAutoSilence(stoppingId)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun buildNotification(
|
||||
alarmId: String,
|
||||
title: String,
|
||||
stationName: String?,
|
||||
snoozeMinutes: Int
|
||||
) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_pluriwave)
|
||||
.setColor(NotificationBrand.CYAN)
|
||||
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
|
||||
.setContentText(
|
||||
if (stationName.isNullOrBlank()) title else "$title - $stationName"
|
||||
)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
|
||||
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
|
||||
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
|
||||
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
|
||||
.build()
|
||||
|
||||
private fun openAlarmPendingIntent(
|
||||
alarmId: String,
|
||||
title: String,
|
||||
snoozeMinutes: Int
|
||||
): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
requestCode(alarmId, 20),
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun stopPendingIntent(alarmId: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
requestCode(alarmId, 21),
|
||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
||||
// Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification
|
||||
// Stop action must route through the id-agnostic stop so it can
|
||||
// never no-op a live ring; the extra id is kept only for logs.
|
||||
action = ACTION_STOP_ACTIVE
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
requestCode(alarmId, 30 + minutes),
|
||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_SNOOZE
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"PluriWave:AlarmWakeLock"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(10 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try {
|
||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service wakeLock release failed", error)
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests transient alarm-scoped audio focus (Requirement: Manual
|
||||
* transient focus; no system volume writes; design D3). Manual instead
|
||||
* of relying on MediaPlayer's implicit focus handling so the service
|
||||
* keeps STREAM_ALARM audible without ever writing another app's stream
|
||||
* volume. AUDIOFOCUS_GAIN_TRANSIENT signals "temporary, give it back
|
||||
* when I'm done" -- the OS pauses/ducks other playback for the ring and
|
||||
* resumes it automatically once focus is abandoned. No-op listener:
|
||||
* this service never reacts to focus loss (an alarm should keep
|
||||
* ringing regardless of what else wants focus).
|
||||
*/
|
||||
private fun requestAlarmAudioFocus() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
.setAudioAttributes(alarmAudioAttributes())
|
||||
.setOnAudioFocusChangeListener(noopAudioFocusChangeListener)
|
||||
.build()
|
||||
audioFocusRequest = request
|
||||
audioManager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
noopAudioFocusChangeListener,
|
||||
AudioManager.STREAM_ALARM,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Abandons the focus request from [requestAlarmAudioFocus]; a safe no-op if none is held. */
|
||||
private fun abandonAlarmAudioFocus() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
audioFocusRequest = null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(noopAudioFocusChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
|
||||
val path = fallbackAssetPath(sound)
|
||||
try {
|
||||
val descriptor = assets.openFd(path)
|
||||
mediaPlayer.setDataSource(
|
||||
descriptor.fileDescriptor,
|
||||
descriptor.startOffset,
|
||||
descriptor.length
|
||||
)
|
||||
descriptor.close()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
|
||||
val cached = File(cacheDir, path.substringAfterLast('/'))
|
||||
assets.open(path).use { input ->
|
||||
cached.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
mediaPlayer.setDataSource(cached.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fallbackAssetPath(sound: String?): String {
|
||||
val fileName = when (sound) {
|
||||
"campanaSuave" -> "alarm_campana_suave.wav"
|
||||
"pulsoDigital" -> "alarm_pulso_digital.wav"
|
||||
else -> "alarm_amanecer.wav"
|
||||
}
|
||||
return "flutter_assets/assets/audio/$fileName"
|
||||
}
|
||||
|
||||
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
||||
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
||||
|
||||
override fun onDestroy() {
|
||||
stopAlarm(activeAlarmId)
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
private const val CHANNEL_ID = "pluriwave_alarm_fire_v3"
|
||||
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
|
||||
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
|
||||
private const val LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"
|
||||
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
|
||||
private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"
|
||||
private const val NOTIFICATION_ID = 92841
|
||||
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
|
||||
const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE"
|
||||
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
|
||||
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
||||
|
||||
/**
|
||||
* Same-process companion snapshot (Decision 1): `MainActivity` reads
|
||||
* this synchronously (no service round-trip) to build a verified stop
|
||||
* result. Always written together with the instance-scoped
|
||||
* [activeAlarmId] through the paired [setActiveIds] helper (feedback
|
||||
* item, READ-6) -- always the id ACTUALLY ringing, never a
|
||||
* caller-supplied one. Set in [startAlarm]; cleared in TWO documented
|
||||
* sites -- [stopEverything] (confirmed stop/teardown) AND
|
||||
* [startAlarm]'s own startForeground-failure catch (feedback item,
|
||||
* READ-5), which never reaches [stopEverything] but must still clear
|
||||
* the ids for the ring that never actually started.
|
||||
*/
|
||||
@Volatile
|
||||
var activeRingingId: String? = null
|
||||
|
||||
/**
|
||||
* Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2):
|
||||
* set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified]
|
||||
* call [stopEverything] synchronously instead of trusting an async
|
||||
* startService dispatch to have completed before reporting a result.
|
||||
*/
|
||||
@Volatile
|
||||
private var instance: PluriWaveAlarmService? = null
|
||||
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
|
||||
private const val FADE_TICK_MILLIS = 50L
|
||||
private const val FADE_RANGE_DB = 40.0f
|
||||
|
||||
/**
|
||||
* DeskClock-style exponential fade curve (AOSP AsyncRingtonePlayer /
|
||||
* VolumeShaper reference shape -- reimplemented here on a plain
|
||||
* Handler tick since MediaPlayer.setVolume takes a linear [0,1] gain
|
||||
* and this service targets API levels below VolumeShaper's API 26
|
||||
* floor). Volume rises from near-silence to [ceiling] over [fadeMs]
|
||||
* along a DECIBEL ramp, not a linear amplitude ramp, so the rise
|
||||
* SOUNDS smooth: human loudness perception is logarithmic, and a
|
||||
* linear amplitude ramp sounds like it "arrives late" and jumps at
|
||||
* the end. At elapsedMs<=0 the gain is -40dB (~1% of ceiling); at
|
||||
* elapsedMs>=fadeMs the gain is 0dB (exactly ceiling). Pure
|
||||
* function -- no side effects -- so it is safe to call from a timer
|
||||
* tick, a prepare-time recompute, or a construction-time seed alike.
|
||||
*/
|
||||
private fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
|
||||
if (fadeMs <= 0) return ceiling.coerceIn(0f, 1f)
|
||||
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
|
||||
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB
|
||||
val curve = Math.pow(10.0, (gainDb / 20.0).toDouble()).toFloat()
|
||||
return (ceiling * curve).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtras(source)
|
||||
}
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Log.d(TAG, "alarm.service start requested")
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.clear(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: a fire-and-forget call from the
|
||||
// receiver's ACTION_FIRE branch -- if the OS refuses the
|
||||
// foreground-service start (background-restricted app), the
|
||||
// ring never happens and nothing surfaced it anywhere but
|
||||
// logcat, "as if there were no alarm at all".
|
||||
Log.e(TAG, "alarm.service start failed", error)
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.record(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context, alarmId: String) {
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
Log.d(TAG, "alarm.service stop action requested id=$alarmId")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
|
||||
try {
|
||||
context.stopService(intent)
|
||||
} catch (fallbackError: Throwable) {
|
||||
Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */
|
||||
fun stopActive(context: Context) {
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = ACTION_STOP_ACTIVE
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
Log.d(TAG, "alarm.service stopActive action requested")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "alarm.service stopActive request failed", error)
|
||||
try {
|
||||
context.stopService(intent)
|
||||
} catch (fallbackError: Throwable) {
|
||||
Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2):
|
||||
* fixes the hollow verification where [stopActive]'s async
|
||||
* startService dispatch made the result a literal `true` decided
|
||||
* before teardown ran. When a live [instance] exists, invokes
|
||||
* [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and
|
||||
* this service both run on the main thread of the SAME process, so
|
||||
* no round trip is needed) and returns whether teardown actually
|
||||
* cleared [activeRingingId]. Falls back to the async [stopActive]
|
||||
* dispatch only when no instance is alive -- nothing can be ringing
|
||||
* without a live instance, so [activeRingingId] is already null and
|
||||
* the fallback trivially succeeds.
|
||||
*/
|
||||
fun stopActiveVerified(context: Context): Boolean {
|
||||
val current = instance
|
||||
if (current != null) {
|
||||
current.stopEverything()
|
||||
return activeRingingId == null
|
||||
}
|
||||
stopActive(context)
|
||||
return activeRingingId == null
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
migrateLegacyChannels(context, manager)
|
||||
// Re-create each time (not early-returning when present) so the
|
||||
// localized name/description refresh after a locale change. Android
|
||||
// updates name + description on an existing channel; importance and
|
||||
// sound stay fixed from first creation. setSound(null, null) is
|
||||
// REQUIRED for silence: omitting the call leaves the platform
|
||||
// DEFAULT notification sound on the channel (same reason the
|
||||
// pre-notice channel calls it explicitly). This channel must be
|
||||
// silent (Requirement: Fire notification posts with no sound) --
|
||||
// the native MediaPlayer on STREAM_ALARM is the only audible
|
||||
// source, so a channel sound would double it.
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
AlarmNotificationStrings.fireChannelName(context),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = AlarmNotificationStrings.fireChannelDescription(context)
|
||||
setSound(null, null)
|
||||
enableVibration(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
// Android locks channel sound/importance at creation time, so the
|
||||
// only way to apply a changed shape (USAGE_ALARM in v2, silent in v3)
|
||||
// on existing installs is deleting the legacy channels and recreating
|
||||
// under a new versioned id. Runs once, guarded by a flag;
|
||||
// deleteNotificationChannel is a safe no-op for an id that was never
|
||||
// created (fresh installs) or already deleted (re-runs).
|
||||
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
|
||||
val prefs = context.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V3, false)) return
|
||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
|
||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
|
||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }
|
||||
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V3, true).apply()
|
||||
Log.d(TAG, "alarm.service legacy notification channels migrated to v3")
|
||||
}
|
||||
|
||||
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
|
||||
class PluriWaveBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_USER_UNLOCKED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" -> {
|
||||
Log.d(TAG, "alarm.bootReceiver action=${intent.action}")
|
||||
AlarmScheduler(context).reschedulePersistedAlarms()
|
||||
}
|
||||
else -> Log.w(TAG, "alarm.bootReceiver unknown action=${intent.action}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsProvider
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Publishes the radio-recordings folder as a storage root the system file
|
||||
* manager can browse, WITHOUT moving a single file out of app-private storage.
|
||||
*
|
||||
* Why this exists: the recordings live under
|
||||
* `getApplicationDocumentsDirectory()/grabaciones`
|
||||
* (`/data/user/0/es.freetimelab.pluriwave/app_flutter/grabaciones`). The Android
|
||||
* sandbox forbids any other app -- including the system Files app -- from
|
||||
* reading that path, so no `ACTION_VIEW` on a `file://` or `FileProvider` URI
|
||||
* can ever open it. A `DocumentsProvider` is the only supported way to expose
|
||||
* private files to the document framework: we stay the owner of the bytes and
|
||||
* the system asks US for them, one document at a time.
|
||||
*
|
||||
* The root is browsable, readable, writable, renameable and deletable so the
|
||||
* user can do whatever they want with their recordings (copy out, share, delete,
|
||||
* open in another player) straight from the file manager.
|
||||
*
|
||||
* Static-review-only component: it runs in the app process but is driven
|
||||
* entirely by the platform's document framework, so it has no Dart unit tests.
|
||||
* See MainActivity.viewDirectory for the intents that open it.
|
||||
*/
|
||||
class RecordingsDocumentsProvider : DocumentsProvider() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PluriWave"
|
||||
|
||||
/** Root id and document id of the exposed folder itself. */
|
||||
const val ROOT_ID = "recordings"
|
||||
|
||||
/**
|
||||
* Remembers the folder Flutter is actually recording into. Written on
|
||||
* every open-folder request so a user-configured path is honoured, and
|
||||
* read back by [rootDirectory] when the platform enumerates roots (which
|
||||
* can happen with no Activity alive).
|
||||
*/
|
||||
private const val PREFS = "pluriwave_recordings_root"
|
||||
private const val KEY_PATH = "path"
|
||||
|
||||
/**
|
||||
* Mirrors path_provider's `getApplicationDocumentsDirectory()` on
|
||||
* Android (`context.getDir("flutter", MODE_PRIVATE)`) plus the
|
||||
* `grabaciones` subfolder appended by
|
||||
* `ServicioGrabacionRadio.directorioEfectivo()`. Used until Flutter has
|
||||
* reported the effective path at least once.
|
||||
*/
|
||||
private fun defaultDirectory(context: Context): File =
|
||||
File(context.getDir("flutter", Context.MODE_PRIVATE), "grabaciones")
|
||||
|
||||
fun authority(context: Context): String = "${context.packageName}.recordings"
|
||||
|
||||
/** `ACTION_VIEW` target that opens the file manager at this root. */
|
||||
fun rootUri(context: Context): Uri =
|
||||
DocumentsContract.buildRootUri(authority(context), ROOT_ID)
|
||||
|
||||
/** `ACTION_VIEW` target for the root folder as a document. */
|
||||
fun rootDocumentUri(context: Context): Uri =
|
||||
DocumentsContract.buildDocumentUri(authority(context), ROOT_ID)
|
||||
|
||||
/** `EXTRA_INITIAL_URI` target for the `ACTION_OPEN_DOCUMENT_TREE` fallback. */
|
||||
fun rootTreeUri(context: Context): Uri =
|
||||
DocumentsContract.buildTreeDocumentUri(authority(context), ROOT_ID)
|
||||
|
||||
/**
|
||||
* Points the published root at [path] and tells the framework to
|
||||
* refresh, so a folder change in Settings is reflected in the file
|
||||
* manager. No-op when the path is unchanged.
|
||||
*/
|
||||
fun rememberRoot(context: Context, path: String) {
|
||||
val app = context.applicationContext
|
||||
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_PATH, null) == path) return
|
||||
prefs.edit().putString(KEY_PATH, path).apply()
|
||||
try {
|
||||
app.contentResolver.notifyChange(
|
||||
DocumentsContract.buildRootsUri(authority(app)),
|
||||
null
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "recordings_provider notifyChange failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** The directory currently published as [ROOT_ID], created if missing. */
|
||||
fun rootDirectory(context: Context): File {
|
||||
val app = context.applicationContext
|
||||
val stored = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PATH, null)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val directory = if (stored != null) File(stored) else defaultDirectory(app)
|
||||
if (!directory.exists()) directory.mkdirs()
|
||||
return directory
|
||||
}
|
||||
|
||||
private val ROOT_COLUMNS = arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_SUMMARY,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_ICON,
|
||||
)
|
||||
|
||||
private val DOCUMENT_COLUMNS = arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_FLAGS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [DocumentsProvider.getContext] is nullable only before `onCreate`.
|
||||
* Not named requireContext: ContentProvider.requireContext() is API 30 and
|
||||
* minSdk is 24.
|
||||
*/
|
||||
private fun resolveContext(): Context =
|
||||
requireNotNull(context) { "provider context unavailable" }
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: ROOT_COLUMNS)
|
||||
val context = resolveContext()
|
||||
// Ensure the folder exists before the file manager lists an empty root.
|
||||
rootDirectory(context)
|
||||
cursor.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||
add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
|
||||
// The file manager renders title as the primary label and summary
|
||||
// below it, so the brand identifies the source and the localized
|
||||
// folder name says what it holds.
|
||||
add(Root.COLUMN_TITLE, appLabel(context))
|
||||
add(Root.COLUMN_SUMMARY, AlarmNotificationStrings.recordingsRootTitle(context))
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.FLAG_LOCAL_ONLY or
|
||||
Root.FLAG_SUPPORTS_CREATE or
|
||||
Root.FLAG_SUPPORTS_IS_CHILD
|
||||
)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
|
||||
addRow(cursor, resolve(documentId), documentId)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
|
||||
val parent = resolve(parentDocumentId)
|
||||
// Newest recording first: it is the one the user just made.
|
||||
val children = parent.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
|
||||
for (child in children) {
|
||||
addRow(cursor, child, documentIdFor(child))
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
|
||||
documentId != parentDocumentId &&
|
||||
documentId.startsWith(
|
||||
if (parentDocumentId == ROOT_ID) "$ROOT_ID/" else "$parentDocumentId/"
|
||||
)
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor {
|
||||
val file = resolve(documentId)
|
||||
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
|
||||
}
|
||||
|
||||
override fun createDocument(
|
||||
parentDocumentId: String,
|
||||
mimeType: String,
|
||||
displayName: String,
|
||||
): String {
|
||||
val parent = resolve(parentDocumentId)
|
||||
val target = uniqueChild(parent, displayName)
|
||||
val created =
|
||||
if (Document.MIME_TYPE_DIR == mimeType) target.mkdir() else target.createNewFile()
|
||||
if (!created) {
|
||||
throw FileNotFoundException("could not create $displayName in $parentDocumentId")
|
||||
}
|
||||
notifyParent(parentDocumentId)
|
||||
return documentIdFor(target)
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
val file = resolve(documentId)
|
||||
if (!file.deleteRecursively()) {
|
||||
throw FileNotFoundException("could not delete $documentId")
|
||||
}
|
||||
notifyParent(parentDocumentIdOf(documentId))
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String {
|
||||
val file = resolve(documentId)
|
||||
val target = File(file.parentFile, displayName)
|
||||
if (target.exists() || !file.renameTo(target)) {
|
||||
throw FileNotFoundException("could not rename $documentId to $displayName")
|
||||
}
|
||||
notifyParent(parentDocumentIdOf(documentId))
|
||||
return documentIdFor(target)
|
||||
}
|
||||
|
||||
override fun getDocumentType(documentId: String): String = mimeTypeOf(resolve(documentId))
|
||||
|
||||
private fun appLabel(context: Context): String =
|
||||
context.applicationInfo.loadLabel(context.packageManager).toString()
|
||||
|
||||
private fun addRow(cursor: MatrixCursor, file: File, documentId: String) {
|
||||
val isDirectory = file.isDirectory
|
||||
var flags =
|
||||
if (isDirectory) Document.FLAG_DIR_SUPPORTS_CREATE else Document.FLAG_SUPPORTS_WRITE
|
||||
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME
|
||||
cursor.newRow().apply {
|
||||
add(Document.COLUMN_DOCUMENT_ID, documentId)
|
||||
add(
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
if (documentId == ROOT_ID) {
|
||||
AlarmNotificationStrings.recordingsRootTitle(resolveContext())
|
||||
} else {
|
||||
file.name
|
||||
}
|
||||
)
|
||||
add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file))
|
||||
add(Document.COLUMN_SIZE, file.length())
|
||||
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
|
||||
add(Document.COLUMN_FLAGS, flags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a document id back to a file, refusing anything that escapes the
|
||||
* published root -- a caller-supplied id must never reach a sibling of the
|
||||
* recordings folder via `..` segments.
|
||||
*/
|
||||
private fun resolve(documentId: String): File {
|
||||
val root = rootDirectory(resolveContext())
|
||||
if (documentId == ROOT_ID) return root
|
||||
if (!documentId.startsWith("$ROOT_ID/")) {
|
||||
throw FileNotFoundException("unknown document id $documentId")
|
||||
}
|
||||
val relative = documentId.removePrefix("$ROOT_ID/")
|
||||
val target = File(root, relative).canonicalFile
|
||||
val rootPath = root.canonicalPath
|
||||
if (target.path != rootPath && !target.path.startsWith("$rootPath${File.separator}")) {
|
||||
throw FileNotFoundException("document id escapes the root: $documentId")
|
||||
}
|
||||
if (!target.exists()) throw FileNotFoundException("missing document $documentId")
|
||||
return target
|
||||
}
|
||||
|
||||
private fun documentIdFor(file: File): String {
|
||||
val rootPath = rootDirectory(resolveContext()).canonicalPath
|
||||
val filePath = file.canonicalPath
|
||||
if (filePath == rootPath) return ROOT_ID
|
||||
return "$ROOT_ID/${filePath.removePrefix("$rootPath${File.separator}").replace(File.separatorChar, '/')}"
|
||||
}
|
||||
|
||||
private fun parentDocumentIdOf(documentId: String): String =
|
||||
documentId.substringBeforeLast('/', ROOT_ID).takeIf { it.isNotBlank() } ?: ROOT_ID
|
||||
|
||||
private fun notifyParent(parentDocumentId: String) {
|
||||
try {
|
||||
val ctx = resolveContext()
|
||||
ctx.contentResolver.notifyChange(
|
||||
DocumentsContract.buildChildDocumentsUri(authority(ctx), parentDocumentId),
|
||||
null
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "recordings_provider notifyChange failed parent=$parentDocumentId", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends ` (n)` before the extension until the name is free. */
|
||||
private fun uniqueChild(parent: File, displayName: String): File {
|
||||
var candidate = File(parent, displayName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dot = displayName.lastIndexOf('.')
|
||||
val base = if (dot > 0) displayName.substring(0, dot) else displayName
|
||||
val extension = if (dot > 0) displayName.substring(dot) else ""
|
||||
var index = 1
|
||||
while (candidate.exists()) {
|
||||
candidate = File(parent, "$base ($index)$extension")
|
||||
index++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun mimeTypeOf(file: File): String {
|
||||
if (file.isDirectory) return Document.MIME_TYPE_DIR
|
||||
val extension = file.extension.lowercase()
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
?: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package es.freetimelab.pluriwave
|
||||
|
||||
/**
|
||||
* Anchors the drawables that only Dart names, so the Android build cannot
|
||||
* decide they are unused.
|
||||
*
|
||||
* These icons are handed to `audio_service` as plain strings
|
||||
* (`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`) and resolved
|
||||
* at runtime through `getResources().getIdentifier(...)`. Nothing on the
|
||||
* Android side of the build ever mentions them, so as far as the resource
|
||||
* pipeline is concerned they are dead weight — and they were dropped from
|
||||
* every release APK.
|
||||
*
|
||||
* The damage was not a missing icon. `getResourceId` returns 0 for a name it
|
||||
* cannot find, `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon,
|
||||
* and that throw aborts `AudioService.setState` before the media session is
|
||||
* ever activated. Android Auto was left holding a frozen, inactive session:
|
||||
* dead playback screen, a play button that never became pause, the app losing
|
||||
* its pane to whichever app did have a live session, and audio that played
|
||||
* "as if it were not the app". One absent file, four symptoms, from 31 July
|
||||
* (commit 2540556) until this.
|
||||
*
|
||||
* Verified rather than assumed. Pulling the installed APK off the device and
|
||||
* reading its resource table showed `ic_stat_pluriwave` present and both
|
||||
* equalizer icons absent — and `ic_stat_pluriwave` is the one drawable of the
|
||||
* three that Kotlin references directly (`R.drawable.ic_stat_pluriwave`, four
|
||||
* call sites across the alarm notifications). That contrast is the whole
|
||||
* diagnosis: a real `R.drawable` reference survives, a name that exists only
|
||||
* inside a Dart string does not.
|
||||
*
|
||||
* So this object is not defensive tidiness — it is the reference that was
|
||||
* missing. Any future drawable that Dart resolves by name must be added here
|
||||
* AND to the resource guard in `.gitea/workflows/build.yml`, which reads the
|
||||
* built APK's resource table and fails the build if one of them is gone.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
internal object RecursosResueltosPorNombre {
|
||||
val anclados: IntArray =
|
||||
intArrayOf(
|
||||
R.drawable.ic_auto_eq_on,
|
||||
R.drawable.ic_auto_eq_off,
|
||||
R.drawable.ic_stat_pluriwave,
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z M2,20L4,22L22,4L20,2Z" />
|
||||
</vector>
|
||||
@@ -1,3 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z" />
|
||||
</vector>
|
||||
@@ -1,3 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M7,18h2L9,6L7,6v12zM3,14h2v-4L3,10v4zM11,20h2L13,4h-2v16zM19,10v4h2v-4h-2zM15,18h2L17,6h-2v12z" />
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 84 KiB |
@@ -1,33 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Protects the drawables that only Dart names from the resource shrinker.
|
||||
|
||||
Flutter's own Gradle plugin turns shrinking on for every release build
|
||||
(FlutterPlugin.kt: `releaseBuildType.isMinifyEnabled = true` and
|
||||
`isShrinkResources = true`), regardless of what app/build.gradle.kts says.
|
||||
The shrinker keeps what it can see referenced — and it cannot see
|
||||
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`, because that
|
||||
is a string inside Dart, resolved at runtime via
|
||||
`getResources().getIdentifier(...)`. So it removed both equalizer icons
|
||||
from every release APK.
|
||||
|
||||
The consequence was not a blank button. `getResourceId` returns 0 for a
|
||||
name it cannot resolve, `PlaybackStateCompat.CustomAction.Builder` throws
|
||||
on a 0 icon, and that throw aborts `AudioService.setState` before the media
|
||||
session is activated — leaving Android Auto with a frozen, inactive
|
||||
session. Dead playback screen, play that never became pause, the app losing
|
||||
its pane to any app with a live session, and audio playing "as if it were
|
||||
not the app". One shrunk file, four symptoms, from 31 July (commit 2540556).
|
||||
|
||||
Proven, not assumed: the installed APK was pulled off the device and its
|
||||
resource table read. `ic_stat_pluriwave` was present, both equalizer icons
|
||||
were not — and `ic_stat_pluriwave` is the only one of the three that Kotlin
|
||||
references as a real `R.drawable`, from the alarm notifications. A genuine
|
||||
reference survives shrinking; a name living in a Dart string does not.
|
||||
|
||||
ANY new drawable that Dart resolves by name must be listed here, and in the
|
||||
resource guard in .gitea/workflows/build.yml, which reads the built APK's
|
||||
resource table and fails the build if one of them went missing.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/ic_auto_eq_on,@drawable/ic_auto_eq_off,@drawable/ic_stat_pluriwave,@drawable/station_art_*" />
|
||||
@@ -1,3 +0,0 @@
|
||||
<automotiveApp>
|
||||
<uses name="media"/>
|
||||
</automotiveApp>
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
network_security_config.xml
|
||||
Permite tráfico HTTP cleartext para streams de radio que no soporten HTTPS.
|
||||
Fix para: "Cleartext HTTP traffic to [host] not permitted" en ExoPlayer.
|
||||
-->
|
||||
<network-security-config>
|
||||
<!-- Permitir HTTP cleartext para streams de radio -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<!-- Certificados del sistema (CA reconocidas) -->
|
||||
<certificates src="system"/>
|
||||
<!-- Certificados de usuario (para desarrollo) -->
|
||||
<certificates src="user"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path
|
||||
name="files"
|
||||
path="." />
|
||||
<!--
|
||||
path_provider's getApplicationDocumentsDirectory() maps to
|
||||
context.getDir("flutter") -> <data>/app_flutter, a sibling of files/ that
|
||||
no FileProvider tag covers directly. Without this root,
|
||||
getUriForFile() throws for every radio recording and "open last
|
||||
recording" fails. FileProvider canonicalizes roots, so the ../ hop
|
||||
resolves to <data>/app_flutter.
|
||||
-->
|
||||
<files-path
|
||||
name="app_flutter"
|
||||
path="../app_flutter/" />
|
||||
<cache-path
|
||||
name="cache"
|
||||
path="." />
|
||||
<external-files-path
|
||||
name="external_files"
|
||||
path="." />
|
||||
<external-cache-path
|
||||
name="external_cache"
|
||||
path="." />
|
||||
</paths>
|
||||
@@ -1,6 +1,2 @@
|
||||
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
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# أهلاً بك في PluriWave
|
||||
|
||||
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
|
||||
|
||||
## راديو مباشر
|
||||
|
||||
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
|
||||
- استكشف المحطات القريبة واكتشف محطات جديدة.
|
||||
- رتّب القوائم حسب الاسم أو الجودة.
|
||||
|
||||
## موسيقى بطريقتك
|
||||
|
||||
- احفظ المفضلات ونظّمها في مجموعات.
|
||||
- اضبط المعادل العام أو إعدادات كل محطة.
|
||||
- استخدم مؤقّت النوم بمدد مخصّصة.
|
||||
|
||||
## التسجيلات
|
||||
|
||||
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
|
||||
- حدّد الحجم الأقصى للملف لتبقى بأمان.
|
||||
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
|
||||
|
||||
## منبّهات موسيقية
|
||||
|
||||
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
|
||||
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
|
||||
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
|
||||
@@ -1,27 +0,0 @@
|
||||
# PluriWave-এ স্বাগতম
|
||||
|
||||
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
|
||||
|
||||
## লাইভ রেডিও
|
||||
|
||||
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
|
||||
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
|
||||
- তালিকা নাম বা মান অনুযায়ী সাজান।
|
||||
|
||||
## আপনার মতো করে সঙ্গীত
|
||||
|
||||
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
|
||||
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
|
||||
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
|
||||
|
||||
## রেকর্ডিং
|
||||
|
||||
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
|
||||
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
|
||||
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
|
||||
|
||||
## মিউজিক অ্যালার্ম
|
||||
|
||||
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
|
||||
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
|
||||
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
|
||||
@@ -1,27 +0,0 @@
|
||||
# Willkommen bei PluriWave
|
||||
|
||||
PluriWave ist Ihr Premium-Weltradio: Live-Sender, organisierte Favoriten, Aufnahmen, Equalizer und Musikalarme in einer sorgfältig gestalteten Erfahrung.
|
||||
|
||||
## Live-Radio
|
||||
|
||||
- Suche nach Sendern nach Name, Land, Sprache und Qualität.
|
||||
- Entdecke Sender in der Nähe und finde neue Radios.
|
||||
- Sortiere Listen nach Name oder Qualität.
|
||||
|
||||
## Musik auf deine Art
|
||||
|
||||
- Speichere Favoriten und organisiere sie in Gruppen.
|
||||
- Stelle den globalen Equalizer oder Sender-Presets ein.
|
||||
- Nutze den Sleep-Timer mit eigenen Laufzeiten.
|
||||
|
||||
## Aufnahmen
|
||||
|
||||
- Nimm Radio auf, ohne den Original-Stream neu zu komprimieren.
|
||||
- Begrenze die maximale Dateigröße für mehr Sicherheit.
|
||||
- Öffne den Aufnahmeordner zum Teilen, Verschieben oder Bearbeiten von Dateien.
|
||||
|
||||
## Musikalarme
|
||||
|
||||
- Erstelle einmalige, tägliche oder Wochentags-Alarme.
|
||||
- Wähle einen Lieblingssender und einen sicheren internen Ton.
|
||||
- Nutze Feiertage, "nächste Ausführung überspringen" und Snooze.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Welcome to PluriWave
|
||||
|
||||
PluriWave is your premium world radio: live stations, organized favorites, recordings, equalizer and musical alarms in a carefully crafted experience.
|
||||
|
||||
## Live radio
|
||||
|
||||
- Search stations by name, country, language and quality.
|
||||
- Explore nearby stations and discover new radio.
|
||||
- Sort lists by name or quality.
|
||||
|
||||
## Music your way
|
||||
|
||||
- Save favorites and organize them into groups.
|
||||
- Tune the global equalizer or per-station presets.
|
||||
- Use the sleep timer with custom durations.
|
||||
|
||||
## Recordings
|
||||
|
||||
- Record radio without recompressing the original stream.
|
||||
- Limit maximum file size to stay safe.
|
||||
- Open the recordings folder to share, move or edit files.
|
||||
|
||||
## Musical alarms
|
||||
|
||||
- Create one-time, daily or weekday alarms.
|
||||
- Choose a favorite station and a safe internal sound.
|
||||
- Use holidays, skip-next execution and snooze.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Bienvenido a PluriWave
|
||||
|
||||
PluriWave es tu radio mundial premium: emisoras en directo, favoritos organizados, grabaciones, ecualizador y alarmas musicales en una experiencia cuidada.
|
||||
|
||||
## Radio en vivo
|
||||
|
||||
- Buscá emisoras por nombre, país, idioma y calidad.
|
||||
- Explorá emisoras cercanas y descubrí radios nuevas.
|
||||
- Ordená listas por nombre o calidad.
|
||||
|
||||
## Música a tu manera
|
||||
|
||||
- Guardá favoritos y organizalos en grupos.
|
||||
- Ajustá el ecualizador global o los presets por emisora.
|
||||
- Usá el temporizador de sueño con duraciones personalizadas.
|
||||
|
||||
## Grabaciones
|
||||
|
||||
- Grabá radio sin recomprimir el stream original.
|
||||
- Limitá el tamaño máximo del archivo para evitar sustos.
|
||||
- Abrí la carpeta de grabaciones para compartir, mover o editar archivos.
|
||||
|
||||
## Alarmas musicales
|
||||
|
||||
- Creá alarmas únicas, diarias o por días de semana.
|
||||
- Elegí una emisora favorita y un sonido interno seguro.
|
||||
- Usá vacaciones, omitir la próxima ejecución y posponer.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Bienvenue dans PluriWave
|
||||
|
||||
PluriWave est votre radio mondiale premium : stations en direct, favoris organisés, enregistrements, égaliseur et alarmes musicales dans une expérience soignée.
|
||||
|
||||
## Radio en direct
|
||||
|
||||
- Recherchez des stations par nom, pays, langue et qualité.
|
||||
- Explorez les stations proches et découvrez de nouvelles radios.
|
||||
- Triez les listes par nom ou qualité.
|
||||
|
||||
## Votre musique, votre style
|
||||
|
||||
- Enregistrez vos favoris et organisez-les en groupes.
|
||||
- Réglez l'égaliseur global ou des préréglages par station.
|
||||
- Utilisez le minuteur de sommeil avec des durées personnalisées.
|
||||
|
||||
## Enregistrements
|
||||
|
||||
- Enregistrez la radio sans recompresser le flux d'origine.
|
||||
- Limitez la taille maximale des fichiers pour rester serein.
|
||||
- Ouvrez le dossier des enregistrements pour partager, déplacer ou modifier des fichiers.
|
||||
|
||||
## Alarmes musicales
|
||||
|
||||
- Créez des alarmes uniques, quotidiennes ou en semaine.
|
||||
- Choisissez une station favorite et un son interne sûr.
|
||||
- Utilisez les vacances, le saut de la prochaine exécution et le snooze.
|
||||
@@ -1,27 +0,0 @@
|
||||
# PluriWave में आपका स्वागत है
|
||||
|
||||
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
|
||||
|
||||
## लाइव रेडियो
|
||||
|
||||
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
|
||||
- पास के स्टेशन देखें और नए रेडियो खोजें।
|
||||
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
|
||||
|
||||
## संगीत आपके तरीके से
|
||||
|
||||
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
|
||||
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
|
||||
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
|
||||
|
||||
## रिकॉर्डिंग
|
||||
|
||||
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
|
||||
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
|
||||
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
|
||||
|
||||
## संगीत अलार्म
|
||||
|
||||
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
|
||||
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
|
||||
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
|
||||
@@ -1,27 +0,0 @@
|
||||
# Selamat datang di PluriWave
|
||||
|
||||
PluriWave adalah radio dunia premium Anda: stasiun langsung, favorit terorganisir, rekaman, equalizer, dan alarm musik dalam pengalaman yang dirancang rapi.
|
||||
|
||||
## Radio langsung
|
||||
|
||||
- Cari stasiun berdasarkan nama, negara, bahasa, dan kualitas.
|
||||
- Jelajahi stasiun terdekat dan temukan radio baru.
|
||||
- Urutkan daftar berdasarkan nama atau kualitas.
|
||||
|
||||
## Musik sesuai cara Anda
|
||||
|
||||
- Simpan favorit dan atur ke dalam grup.
|
||||
- Atur equalizer global atau preset per stasiun.
|
||||
- Gunakan sleep timer dengan durasi kustom.
|
||||
|
||||
## Rekaman
|
||||
|
||||
- Rekam radio tanpa mengompresi ulang stream asli.
|
||||
- Batasi ukuran file maksimum agar tetap aman.
|
||||
- Buka folder rekaman untuk berbagi, memindahkan, atau mengedit file.
|
||||
|
||||
## Alarm musik
|
||||
|
||||
- Buat alarm sekali, harian, atau hari kerja.
|
||||
- Pilih stasiun favorit dan suara internal yang aman.
|
||||
- Gunakan hari libur, lewati eksekusi berikutnya, dan snooze.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Benvenuto in PluriWave
|
||||
|
||||
PluriWave è la tua radio mondiale premium: stazioni live, preferiti organizzati, registrazioni, equalizzatore e sveglie musicali in un'esperienza curata.
|
||||
|
||||
## Radio live
|
||||
|
||||
- Cerca stazioni per nome, paese, lingua e qualità.
|
||||
- Esplora le stazioni vicine e scopri nuove radio.
|
||||
- Ordina le liste per nome o qualità.
|
||||
|
||||
## Musica a modo tuo
|
||||
|
||||
- Salva i preferiti e organizzali in gruppi.
|
||||
- Regola l'equalizzatore globale o i preset per stazione.
|
||||
- Usa il timer di spegnimento con durate personalizzate.
|
||||
|
||||
## Registrazioni
|
||||
|
||||
- Registra la radio senza ricomprimere il flusso originale.
|
||||
- Limita la dimensione massima dei file per stare tranquillo.
|
||||
- Apri la cartella registrazioni per condividere, spostare o modificare i file.
|
||||
|
||||
## Sveglie musicali
|
||||
|
||||
- Crea sveglie singole, giornaliere o nei giorni feriali.
|
||||
- Scegli una stazione preferita e un suono interno sicuro.
|
||||
- Usa ferie, salto della prossima esecuzione e snooze.
|
||||
@@ -1,27 +0,0 @@
|
||||
# PluriWave へようこそ
|
||||
|
||||
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
|
||||
|
||||
## ライブラジオ
|
||||
|
||||
- 名前、国、言語、音質で局を検索できます。
|
||||
- 近くの局を探して新しいラジオを見つけられます。
|
||||
- リストを名前または音質で並べ替えできます。
|
||||
|
||||
## あなた好みの音楽体験
|
||||
|
||||
- お気に入りを保存してグループで整理できます。
|
||||
- 全体イコライザーや局ごとのプリセットを調整できます。
|
||||
- 時間を指定できるスリープタイマーを使えます。
|
||||
|
||||
## 録音
|
||||
|
||||
- 元のストリームを再圧縮せずに録音できます。
|
||||
- 最大ファイルサイズを制限して安全に使えます。
|
||||
- 録音フォルダーを開いて共有・移動・編集できます。
|
||||
|
||||
## 音楽アラーム
|
||||
|
||||
- 1回のみ、毎日、平日のアラームを作成できます。
|
||||
- お気に入り局と安全な内蔵サウンドを選べます。
|
||||
- 休日設定、次回スキップ、スヌーズに対応しています。
|
||||
@@ -1,27 +0,0 @@
|
||||
# Bem-vindo ao PluriWave
|
||||
|
||||
PluriWave é seu rádio mundial premium: estações ao vivo, favoritos organizados, gravações, equalizador e alarmes musicais em uma experiência caprichada.
|
||||
|
||||
## Rádio ao vivo
|
||||
|
||||
- Procure estações por nome, país, idioma e qualidade.
|
||||
- Explore estações próximas e descubra novas rádios.
|
||||
- Ordene listas por nome ou qualidade.
|
||||
|
||||
## Música do seu jeito
|
||||
|
||||
- Salve favoritos e organize em grupos.
|
||||
- Ajuste o equalizador global ou presets por estação.
|
||||
- Use o timer de sono com durações personalizadas.
|
||||
|
||||
## Gravações
|
||||
|
||||
- Grave rádio sem recomprimir o stream original.
|
||||
- Limite o tamanho máximo dos arquivos para evitar problemas.
|
||||
- Abra a pasta de gravações para compartilhar, mover ou editar arquivos.
|
||||
|
||||
## Alarmes musicais
|
||||
|
||||
- Crie alarmes únicos, diários ou de dias úteis.
|
||||
- Escolha uma estação favorita e um som interno seguro.
|
||||
- Use feriados, pular próxima execução e soneca.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Добро пожаловать в PluriWave
|
||||
|
||||
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
|
||||
|
||||
## Прямое радио
|
||||
|
||||
- Ищите станции по названию, стране, языку и качеству.
|
||||
- Изучайте ближайшие станции и открывайте новое радио.
|
||||
- Сортируйте списки по названию или качеству.
|
||||
|
||||
## Музыка по-вашему
|
||||
|
||||
- Сохраняйте избранное и организуйте его по группам.
|
||||
- Настраивайте глобальный эквалайзер или пресеты для станций.
|
||||
- Используйте таймер сна с нужной длительностью.
|
||||
|
||||
## Записи
|
||||
|
||||
- Записывайте радио без повторного сжатия исходного потока.
|
||||
- Ограничивайте максимальный размер файла для безопасности.
|
||||
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
|
||||
|
||||
## Музыкальные будильники
|
||||
|
||||
- Создавайте разовые, ежедневные или будничные будильники.
|
||||
- Выбирайте любимую станцию и безопасный встроенный звук.
|
||||
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 欢迎使用 PluriWave
|
||||
|
||||
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
|
||||
|
||||
## 直播电台
|
||||
|
||||
- 按名称、国家、语言和音质搜索电台。
|
||||
- 探索附近电台,发现新的广播内容。
|
||||
- 按名称或音质排序列表。
|
||||
|
||||
## 按你的方式听音乐
|
||||
|
||||
- 保存收藏并按分组整理。
|
||||
- 调整全局均衡器或单电台预设。
|
||||
- 使用可自定义时长的睡眠定时器。
|
||||
|
||||
## 录音
|
||||
|
||||
- 录制电台时不重新压缩原始流。
|
||||
- 限制最大文件大小,更安全省心。
|
||||
- 打开录音文件夹以分享、移动或编辑文件。
|
||||
|
||||
## 音乐闹钟
|
||||
|
||||
- 创建一次性、每日或工作日闹钟。
|
||||
- 选择喜爱的电台和安全的内置提示音。
|
||||
- 支持假期、跳过下次执行和贪睡。
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · منبّهات وملفات أكثر موثوقية
|
||||
|
||||
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
|
||||
|
||||
## التحسينات
|
||||
|
||||
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
|
||||
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
|
||||
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
|
||||
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
|
||||
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
|
||||
|
||||
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
|
||||
|
||||
## উন্নতি
|
||||
|
||||
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
|
||||
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
|
||||
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
|
||||
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
|
||||
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Zuverlässigere Alarme und Dateien
|
||||
|
||||
Zusammenfassung: Wir haben die Android-Alarmbasis verstärkt und das Öffnen eines Ordners klar vom Ändern seines Pfads getrennt.
|
||||
|
||||
## Verbesserungen
|
||||
|
||||
- Neue native Grundlage für Alarme mit sicherem internem Ton.
|
||||
- Bessere Diagnose der Android-Berechtigung für exakte Alarme.
|
||||
- Alarme, die in derselben Minute erstellt werden, werden wegen Sekunden nicht mehr verworfen.
|
||||
- Das Alarmpanel unterscheidet aktive Alarme von Alarmen ohne gültige nächste Ausführung.
|
||||
- Ordner öffnen versucht jetzt den gespeicherten Pfad zu öffnen; Pfad ändern ist separat.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · More reliable alarms and files
|
||||
|
||||
Summary: we reinforced the Android alarm foundation and clearly separated opening a folder from changing its path.
|
||||
|
||||
## Improvements
|
||||
|
||||
- New native foundation for alarms with a safe internal sound.
|
||||
- Better Android exact-alarm permission diagnostics.
|
||||
- Alarms created in the same minute are no longer discarded because of seconds.
|
||||
- The alarms panel distinguishes active alarms from alarms without a valid next execution.
|
||||
- Open folder now tries to open the saved path; change path is separate.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Alarmas y archivos más fiables
|
||||
|
||||
Resumen: reforzamos la base de alarmas Android y separamos claramente abrir carpeta de cambiar ruta.
|
||||
|
||||
## Mejoras
|
||||
|
||||
- Nueva base nativa para alarmas con sonido interno seguro.
|
||||
- Mejor diagnóstico de permisos Android para alarmas exactas.
|
||||
- Las alarmas creadas en el mismo minuto ya no se descartan por segundos.
|
||||
- El panel de alarmas distingue entre alarmas activas y alarmas sin próxima ejecución válida.
|
||||
- Abrir carpeta ahora intenta abrir la ruta guardada; cambiar ruta queda separado.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Alarmes et fichiers plus fiables
|
||||
|
||||
Résumé : nous avons renforcé la base des alarmes Android et séparé clairement l'ouverture d'un dossier du changement de chemin.
|
||||
|
||||
## Améliorations
|
||||
|
||||
- Nouvelle base native pour les alarmes avec un son interne sûr.
|
||||
- Meilleur diagnostic des permissions Android pour les alarmes exactes.
|
||||
- Les alarmes créées dans la même minute ne sont plus ignorées à cause des secondes.
|
||||
- Le panneau d'alarmes distingue les alarmes actives de celles sans prochaine exécution valide.
|
||||
- Ouvrir le dossier tente désormais d'ouvrir le chemin enregistré ; changer le chemin est séparé.
|
||||
@@ -1,12 +0,0 @@
|
||||
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
|
||||
|
||||
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
|
||||
|
||||
## सुधार
|
||||
|
||||
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
|
||||
- Android exact-alarm अनुमति के बेहतर निदान।
|
||||
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
|
||||
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
|
||||
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Alarm dan file lebih andal
|
||||
|
||||
Ringkasan: kami memperkuat fondasi alarm Android dan memisahkan dengan jelas antara membuka folder dan mengubah jalurnya.
|
||||
|
||||
## Peningkatan
|
||||
|
||||
- Fondasi native baru untuk alarm dengan suara internal yang aman.
|
||||
- Diagnostik izin exact-alarm Android yang lebih baik.
|
||||
- Alarm yang dibuat pada menit yang sama tidak lagi dibuang karena detik.
|
||||
- Panel alarm membedakan alarm aktif dari alarm tanpa eksekusi berikutnya yang valid.
|
||||
- Buka folder sekarang mencoba membuka jalur tersimpan; ubah jalur dipisahkan.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Allarmi e file più affidabili
|
||||
|
||||
Riepilogo: abbiamo rafforzato la base degli allarmi Android e separato chiaramente l'apertura di una cartella dalla modifica del suo percorso.
|
||||
|
||||
## Miglioramenti
|
||||
|
||||
- Nuova base nativa per gli allarmi con suono interno sicuro.
|
||||
- Diagnostica migliore dei permessi Android per gli allarmi esatti.
|
||||
- Gli allarmi creati nello stesso minuto non vengono più scartati a causa dei secondi.
|
||||
- Il pannello allarmi distingue gli allarmi attivi da quelli senza prossima esecuzione valida.
|
||||
- Apri cartella ora prova ad aprire il percorso salvato; cambia percorso è separato.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · より信頼できるアラームとファイル
|
||||
|
||||
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
|
||||
|
||||
## 改善点
|
||||
|
||||
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
|
||||
- Android の正確なアラーム権限診断を改善。
|
||||
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
|
||||
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
|
||||
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Alarmes e arquivos mais confiáveis
|
||||
|
||||
Resumo: reforçamos a base de alarmes do Android e separamos claramente abrir pasta de mudar caminho.
|
||||
|
||||
## Melhorias
|
||||
|
||||
- Nova base nativa para alarmes com som interno seguro.
|
||||
- Melhor diagnóstico de permissões Android para alarmes exatos.
|
||||
- Alarmes criados no mesmo minuto não são mais descartados por causa dos segundos.
|
||||
- O painel de alarmes distingue alarmes ativos de alarmes sem próxima execução válida.
|
||||
- Abrir pasta agora tenta abrir o caminho salvo; mudar caminho fica separado.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · Более надежные будильники и файлы
|
||||
|
||||
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
|
||||
|
||||
## Улучшения
|
||||
|
||||
- Новая нативная основа будильников с безопасным встроенным звуком.
|
||||
- Улучшена диагностика разрешений Android для точных будильников.
|
||||
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
|
||||
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
|
||||
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
|
||||
@@ -1,11 +0,0 @@
|
||||
# v0.1.47 · 更可靠的闹钟与文件
|
||||
|
||||
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
|
||||
|
||||
## 改进
|
||||
|
||||
- 闹钟采用新的原生基础,配有安全的内置提示音。
|
||||
- 改进 Android 精确闹钟权限诊断。
|
||||
- 同一分钟创建的闹钟不再因秒数被丢弃。
|
||||
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
|
||||
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
|
||||
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -1,5 +0,0 @@
|
||||
# PluriWave Night Ocean asset sheet prompt
|
||||
|
||||
Generated with built-in image_gen for the Night Ocean Broadcast redesign.
|
||||
|
||||
Contents: app mark, station fallback artworks, aurora/waveform banner, and navigation glyph assets using teal/amber/cream over navy with no purple/magenta dominance.
|
||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 519 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |