Author SHA1 Message Date
agent-arq a739fb4162 docs(arq): revisión arquitectura Fase 1 — stack aprobado, ajustes menores
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
2026-04-04 16:39:37 +02:00
agent-arq 4a83019f40 feat(ci): workflow Gitea Actions Flutter — test + build APK/AAB + Telegram 2026-04-04 16:35:40 +02:00
614 changed files with 174 additions and 139417 deletions
-5
View File
@@ -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
-181
View File
@@ -1,181 +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
- name: Build APK release
run: flutter build apk --release
- name: Build AAB release
run: flutter build appbundle --release
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
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}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
- name: Preparar credenciales de Google Play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
exit 1
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }}
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' }}
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"
if [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
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: jobs:
flutter-ci: flutter-ci:
name: Test + Build name: Test + Build
#runs-on: macos-14 runs-on: macmini-flutter
runs-on: [self-hosted, macos, arm64, flutter]
env:
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
steps: steps:
- name: Checkout - name: Checkout
-6
View File
@@ -32,12 +32,6 @@ migrate_working_dir/
.pub/ .pub/
/build/ /build/
/coverage/ /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 # Symbolication related
app.*.symbols app.*.symbols
-52
View File
@@ -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) |
+10 -29
View File
@@ -1,17 +1,17 @@
# PluriWave # 📻 PluriWave
Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI premium. Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI premium.
## Features ## Features
- **+53.000 emisoras** de 238 países (Radio Browser API) - 🌍 **+53.000 emisoras** de 238 países (Radio Browser API)
- **Ecualizador por emisora** — guarda tu preset favorito para cada radio - 🎛️ **Ecualizador por emisora** — guarda tu preset favorito para cada radio
- **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app - 🎵 **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
- **Timer de auto-apagado** — perfecto para dormir - **Timer de auto-apagado** — perfecto para dormir
- **Reproducción en segundo plano** — sigue sonando con la pantalla apagada - 🔊 **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
- **Favoritos** — acceso rápido a emisoras preferidas - **Favoritos** — accede rápido a tus emisoras preferidas
- **Compartir** — envía emisoras a tus amigos - 📤 **Compartir** — envía emisoras a tus amigos
- **UI premium** — Material You, visualizador de audio, animaciones fluidas - 🎨 **UI premium** — Material You, visualizador de audio, animaciones fluidas
## Monetización ## Monetización
@@ -22,31 +22,13 @@ Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI p
## Stack ## Stack
- **Frontend**: Flutter (Android + iOS) - **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 - **Audio**: just_audio + audio_service
- **Ecualizador**: just_audio equalizer (Android nativo) - **Ecualizador**: just_audio equalizer (Android nativo)
- **Reconocimiento**: AudD API (1000 req/mes free) - **Reconocimiento**: AudD API (1000 req/mes free)
- **Ads**: Google AdMob - **Ads**: Google AdMob
- **Compras**: in_app_purchase - **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 ## Desarrollador
FreeTimeLab — [freetimelab.es](https://freetimelab.es) FreeTimeLab — [freetimelab.es](https://freetimelab.es)
@@ -54,4 +36,3 @@ FreeTimeLab — [freetimelab.es](https://freetimelab.es)
## Licencia ## Licencia
MIT MIT
-50
View File
@@ -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.
-5
View File
@@ -23,11 +23,6 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` 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 # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
+7 -35
View File
@@ -1,21 +1,10 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") 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 { android {
namespace = "es.freetimelab.pluriwave" namespace = "es.freetimelab.pluriwave"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -31,38 +20,21 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "es.freetimelab.pluriwave" 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 minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName 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 { buildTypes {
release { 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")
} }
} }
} }
+13 -115
View File
@@ -1,46 +1,21 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:label="PluriWave" android:label="pluriwave"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher">
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> 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 <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
@@ -50,94 +25,17 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below.
<!-- Servicio de audio en background (audio_service) --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<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>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> 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" />
</application> </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> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <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)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

@@ -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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
# أهلاً بك في PluriWave
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
## راديو مباشر
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
- استكشف المحطات القريبة واكتشف محطات جديدة.
- رتّب القوائم حسب الاسم أو الجودة.
## موسيقى بطريقتك
- احفظ المفضلات ونظّمها في مجموعات.
- اضبط المعادل العام أو إعدادات كل محطة.
- استخدم مؤقّت النوم بمدد مخصّصة.
## التسجيلات
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
- حدّد الحجم الأقصى للملف لتبقى بأمان.
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
## منبّهات موسيقية
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave-এ স্বাগতম
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
## লাইভ রেডিও
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
- তালিকা নাম বা মান অনুযায়ী সাজান।
## আপনার মতো করে সঙ্গীত
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
## রেকর্ডিং
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
## মিউজিক অ্যালার্ম
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave में आपका स्वागत है
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
## लाइव रेडियो
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
- पास के स्टेशन देखें और नए रेडियो खोजें।
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
## संगीत आपके तरीके से
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
## रिकॉर्डिंग
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
## संगीत अलार्म
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave へようこそ
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
## ライブラジオ
- 名前、国、言語、音質で局を検索できます。
- 近くの局を探して新しいラジオを見つけられます。
- リストを名前または音質で並べ替えできます。
## あなた好みの音楽体験
- お気に入りを保存してグループで整理できます。
- 全体イコライザーや局ごとのプリセットを調整できます。
- 時間を指定できるスリープタイマーを使えます。
## 録音
- 元のストリームを再圧縮せずに録音できます。
- 最大ファイルサイズを制限して安全に使えます。
- 録音フォルダーを開いて共有・移動・編集できます。
## 音楽アラーム
- 1回のみ、毎日、平日のアラームを作成できます。
- お気に入り局と安全な内蔵サウンドを選べます。
- 休日設定、次回スキップ、スヌーズに対応しています。
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# Добро пожаловать в PluriWave
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
## Прямое радио
- Ищите станции по названию, стране, языку и качеству.
- Изучайте ближайшие станции и открывайте новое радио.
- Сортируйте списки по названию или качеству.
## Музыка по-вашему
- Сохраняйте избранное и организуйте его по группам.
- Настраивайте глобальный эквалайзер или пресеты для станций.
- Используйте таймер сна с нужной длительностью.
## Записи
- Записывайте радио без повторного сжатия исходного потока.
- Ограничивайте максимальный размер файла для безопасности.
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
## Музыкальные будильники
- Создавайте разовые, ежедневные или будничные будильники.
- Выбирайте любимую станцию и безопасный встроенный звук.
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
-27
View File
@@ -1,27 +0,0 @@
# 欢迎使用 PluriWave
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
## 直播电台
- 按名称、国家、语言和音质搜索电台。
- 探索附近电台,发现新的广播内容。
- 按名称或音质排序列表。
## 按你的方式听音乐
- 保存收藏并按分组整理。
- 调整全局均衡器或单电台预设。
- 使用可自定义时长的睡眠定时器。
## 录音
- 录制电台时不重新压缩原始流。
- 限制最大文件大小,更安全省心。
- 打开录音文件夹以分享、移动或编辑文件。
## 音乐闹钟
- 创建一次性、每日或工作日闹钟。
- 选择喜爱的电台和安全的内置提示音。
- 支持假期、跳过下次执行和贪睡。
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · منبّهات وملفات أكثر موثوقية
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
## التحسينات
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
## উন্নতি
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -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é.
-12
View File
@@ -1,12 +0,0 @@
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
## सुधार
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
- Android exact-alarm अनुमति के बेहतर निदान।
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · より信頼できるアラームとファイル
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
## 改善点
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
- Android の正確なアラーム権限診断を改善。
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
-11
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · Более надежные будильники и файлы
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
## Улучшения
- Новая нативная основа будильников с безопасным встроенным звуком.
- Улучшена диагностика разрешений Android для точных будильников.
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · 更可靠的闹钟与文件
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
## 改进
- 闹钟采用新的原生基础,配有安全的内置提示音。
- 改进 Android 精确闹钟权限诊断。
- 同一分钟创建的闹钟不再因秒数被丢弃。
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,3 +0,0 @@
PluriWave AAA mockup generated with image_gen.
Visual direction: midnight-ocean glass, teal/cyan audio waves, coral sunrise accents, warm gold broadcast particles, accessible high contrast, no purple-dominant palette.
Launcher/app icon intentionally preserved.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

@@ -1,5 +0,0 @@
# PluriWave award mockup prompt
Generated with built-in image_gen as the visual target for the premium redesign.
Focus: five mobile screens, dark aurora glassmorphism, cyan/violet/magenta gradients, premium iconography, accessible hierarchy, Home/Search/Favorites/Now Playing/Settings.

Some files were not shown because too many files have changed in this diff Show More