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
388 changed files with 174 additions and 75653 deletions
-178
View File
@@ -1,178 +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: 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:
flutter-ci:
name: Test + Build
#runs-on: macos-14
runs-on: [self-hosted, macos, arm64, flutter]
env:
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
runs-on: macmini-flutter
steps:
- name: Checkout
-1
View File
@@ -32,7 +32,6 @@ migrate_working_dir/
.pub/
/build/
/coverage/
.atl/
# Symbolication related
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.
## Features
- **+53.000 emisoras** de 238 países (Radio Browser API)
- **Ecualizador por emisora** — guarda tu preset favorito para cada radio
- **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
- **Timer de auto-apagado** — perfecto para dormir
- **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
- **Favoritos** — acceso rápido a emisoras preferidas
- **Compartir** — envía emisoras a tus amigos
- **UI premium** — Material You, visualizador de audio, animaciones fluidas
- 🌍 **+53.000 emisoras** de 238 países (Radio Browser API)
- 🎛️ **Ecualizador por emisora** — guarda tu preset favorito para cada radio
- 🎵 **Reconocimiento de canciones** — "¿Qué suena?" sin salir de la app
- ⏰ **Timer de auto-apagado** — perfecto para dormir
- 🔊 **Reproducción en segundo plano** — sigue sonando con la pantalla apagada
- ⭐ **Favoritos** — accede rápido a tus emisoras preferidas
- 📤 **Compartir** — envía emisoras a tus amigos
- 🎨 **UI premium** — Material You, visualizador de audio, animaciones fluidas
## Monetización
@@ -22,31 +22,13 @@ Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI p
## Stack
- **Frontend**: Flutter (Android + iOS)
- **Radio API**: Radio Browser (gratis, +53K emisoras)
- **Radio API**: [Radio Browser](https://api.radio-browser.info/) (gratis, +53K emisoras)
- **Audio**: just_audio + audio_service
- **Ecualizador**: just_audio equalizer (Android nativo)
- **Reconocimiento**: AudD API (1000 req/mes free)
- **Ads**: Google AdMob
- **Compras**: in_app_purchase
## CI/CD
Workflow Gitea Actions en `.gitea/workflows/ci.yml`, runner `macmini-flutter`.
**Jobs:** `flutter pub get` → `flutter test` → `build apk --release` → `build appbundle --release`
**Artifacts:** APK y AAB guardados en Gitea con nombre `pluriwave-apk-<sha>` / `pluriwave-aab-<sha>`.
**Notificaciones:** Telegram al completar (éxito ✅ / fallo ❌).
**Secrets necesarios en el repo:**
| Secret | Uso |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Notificaciones CI |
| `TELEGRAM_CHAT_ID` | Canal de destino |
> **Signing**: build de release usa clave debug (válido para CI interno). Para Play Store se requiere keystore como secret adicional.
## Desarrollador
FreeTimeLab — [freetimelab.es](https://freetimelab.es)
@@ -54,4 +36,3 @@ FreeTimeLab — [freetimelab.es](https://freetimelab.es)
## Licencia
MIT
-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:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
cancel_subscriptions: true
close_sinks: true
unawaited_futures: true
prefer_final_locals: true
avoid_dynamic_calls: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+7 -35
View File
@@ -1,21 +1,10 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
import java.util.Properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
fun secret(name: String, propertyName: String): String? =
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
android {
namespace = "es.freetimelab.pluriwave"
compileSdk = flutter.compileSdkVersion
@@ -31,38 +20,21 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "es.freetimelab.pluriwave"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
if (!storeFilePath.isNullOrBlank()) {
storeFile = file(storeFilePath)
}
if (!storePasswordValue.isNullOrBlank()) {
storePassword = storePasswordValue
}
if (!keyAliasValue.isNullOrBlank()) {
keyAlias = keyAliasValue
}
if (!keyPasswordValue.isNullOrBlank()) {
keyPassword = keyPasswordValue
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
+13 -85
View File
@@ -1,38 +1,21 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permisos requeridos para streaming de audio -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<application
android:label="PluriWave"
android:label="pluriwave"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config">
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@@ -42,72 +25,17 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Servicio de audio en background (audio_service) -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback|systemExempted"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveAlarmReceiver"
android:exported="false"
android:directBootAware="true">
<intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.USER_UNLOCKED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.TIME_SET"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/pluriwave_file_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
@@ -1,72 +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"
/** 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")
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 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
@@ -1,838 +1,5 @@
package es.freetimelab.pluriwave
import android.Manifest
import android.app.NotificationManager
import android.content.ClipData
import android.content.Intent
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.File
import io.flutter.embedding.android.FlutterActivity
class MainActivity : AudioServiceActivity() {
private val tag = "PluriWave"
private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions"
private val audioDevicesChannel = "pluriwave/audio_devices"
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private val bluetoothConnectPermissionRequestCode = 4823
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Audio devices channel state
private var audioDevicesSink: EventChannel.EventSink? = null
private var audioDeviceCallback: AudioDeviceCallback? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// --- Audio Devices Channel ---
setupAudioDevicesChannel(flutterEngine)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
pendingSink = events
pendingArgs = arguments as? Map<*, *>
startVisualizerWhenAllowed()
}
override fun onCancel(arguments: Any?) {
stopVisualizer()
pendingSink = null
pendingArgs = null
}
})
val alarmScheduler = AlarmScheduler(this)
alarmMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
alarmChannel
)
alarmMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"scheduleAlarm" -> {
val id = call.argument<String>("id")
val title = call.argument<String>("title") ?: "PluriWave"
val triggerAtMillis = call.argument<Number>("triggerAtMillis")?.toLong()
val preNoticeAtMillis = call.argument<Number>("preNoticeAtMillis")?.toLong() ?: 0L
val stationName = call.argument<String>("stationName")
val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound")
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
val weekdays =
(call.argument<List<Int>>("weekdays") ?: emptyList())
.filter { it in 1..7 }
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
if (id == null || triggerAtMillis == null) {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else {
val scheduled = alarmScheduler.scheduleAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume,
hour = call.argument<Int>("hour"),
minute = call.argument<Int>("minute"),
scheduleType = call.argument<String>("scheduleType"),
weekdays = weekdays,
oneShotDateMillis = call.argument<Number>("oneShotDateMillis")?.toLong(),
snoozeUntilMillis = call.argument<Number>("snoozeUntilMillis")?.toLong(),
snoozeOriginMillis = call.argument<Number>("snoozeOriginMillis")?.toLong(),
lastHandledAtMillis = call.argument<Number>("lastHandledAtMillis")?.toLong(),
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
fallbackStationName = call.argument<String>("fallbackStationName"),
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
)
result.success(scheduled)
}
}
"cancelAlarm" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel cancelAlarm id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
alarmScheduler.cancelAlarm(id)
result.success(null)
}
}
"dismissAlarmNotification" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel dismissAlarmNotification id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.dismissFireNotification(id)
result.success(null)
}
}
"stopNativeAlarmSound" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
}
"diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics")
result.success(
mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"canUseFullScreenIntent" to canUseFullScreenIntent(),
"isIgnoringBatteryOptimizations" to isIgnoringBatteryOptimizations(),
"nativePendingAlarmsCount" to alarmScheduler.pendingAlarmCount(),
"manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT
)
)
}
"requestExactAlarmPermission" -> {
Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(requestExactAlarmPermission())
}
"requestPostNotificationsPermission" -> {
Log.d(tag, "alarm.channel requestPostNotificationsPermission")
result.success(requestPostNotificationsPermission())
}
"requestFullScreenIntentPermission" -> {
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
result.success(requestFullScreenIntentPermission())
}
"requestIgnoreBatteryOptimizations" -> {
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
result.success(requestIgnoreBatteryOptimizations())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
result.success(payload)
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
}
"getHandledAlarmOccurrences" -> {
Log.d(tag, "alarm.channel getHandledAlarmOccurrences")
result.success(alarmScheduler.handledOccurrences())
}
"getNativeSnoozeState" -> {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
AlarmNotificationStrings.save(
this,
args.entries.associate { (k, v) -> k.toString() to v }
)
}
result.success(null)
}
else -> result.notImplemented()
}
}
activeInstance = this
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"openDirectory" -> {
val path = call.argument<String>("path")
Log.d(tag, "file_actions.openDirectory path=$path")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(openDirectory(path))
}
}
"viewDirectory" -> {
val path = call.argument<String>("path")
Log.d(tag, "file_actions.viewDirectory path=$path")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(viewDirectory(path))
}
}
"openFile" -> {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") ?: "audio/*"
Log.d(tag, "file_actions.openFile path=$path mimeType=$mimeType")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(openFile(path, mimeType))
}
}
else -> result.notImplemented()
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val payload = alarmPayload(intent)
if (payload.isNotEmpty()) {
Log.d(tag, "alarm.channel onNewIntent payload=$payload")
alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
private fun alarmPayload(intent: Intent?): Map<String, Any> {
if (intent == null) return emptyMap()
val action = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
?: return emptyMap()
val alarmId = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
?: return emptyMap()
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE)
?: "PluriWave"
return mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to action,
"triggerAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, 0L),
"occurrenceAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, 0L),
"snoozeMinutes" to intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
}
private fun requestExactAlarmPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (alarmManager.canScheduleExactAlarms()) return true
return try {
startActivity(
Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestExactAlarmPermission failed", error)
false
}
}
private fun requestPostNotificationsPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return true
}
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
notificationPermissionRequestCode
)
return true
}
private fun requestFullScreenIntentPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
if (canUseFullScreenIntent()) return true
return try {
startActivity(
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestFullScreenIntentPermission failed", error)
false
}
}
private fun canUseFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return manager.canUseFullScreenIntent()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(packageName)
}
private fun requestIgnoreBatteryOptimizations(): Boolean {
if (isIgnoringBatteryOptimizations()) return true
return try {
startActivity(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestIgnoreBatteryOptimizations failed", error)
false
}
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
Log.w(tag, "file_actions.openDirectory missing path=$path")
return false
}
if (!folder.isDirectory) {
Log.w(tag, "file_actions.openDirectory not directory path=$path")
return false
}
val fileProviderIntent = runCatching {
val uri = FileProvider.getUriForFile(
this,
"$packageName.fileprovider",
folder
)
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}.getOrNull()
val documentIntent = Intent(Intent.ACTION_VIEW).apply {
directoryTreeUri(path)?.let { uri ->
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val opened =
openIntentSafely(fileProviderIntent, "file_actions.openDirectory fileProvider", path) ||
openIntentSafely(documentIntent, "file_actions.openDirectory documents", path)
if (!opened) {
Log.w(tag, "file_actions.openDirectory unable to open path=$path")
}
return opened
}
private fun viewDirectory(path: String): Boolean {
val directory = File(path)
if (!directory.exists()) {
directory.mkdirs()
}
val candidates = mutableListOf<Intent>()
directoryDocumentUri(path)?.let { uri ->
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "vnd.android.document/directory")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setData(uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
}
try {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
} catch (error: Throwable) {
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
}
for (intent in candidates) {
try {
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openFolderTitle(this))
)
Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
} catch (error: Throwable) {
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
}
}
return false
}
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
if (intent == null || intent.data == null) return false
return try {
startActivity(intent)
Log.d(tag, "$origin launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "$origin no activity for path=$path")
false
} catch (error: Throwable) {
Log.e(tag, "$origin failed path=$path", error)
false
}
}
private fun openFile(path: String, mimeType: String): Boolean {
val file = File(path)
if (!file.exists()) {
Log.w(tag, "file_actions.openFile missing path=$path")
return false
}
return try {
val uri = FileProvider.getUriForFile(
this,
"$packageName.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
clipData = ClipData.newUri(contentResolver, "recording", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(
Intent.createChooser(intent, AlarmNotificationStrings.openRecordingTitle(this))
)
Log.d(tag, "file_actions.openFile launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
openDirectory(file.parentFile?.absolutePath ?: path)
} catch (error: Throwable) {
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
openDirectory(file.parentFile?.absolutePath ?: path)
}
}
private fun directoryTreeUri(path: String): Uri? {
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildTreeDocumentUri(
"com.android.externalstorage.documents",
documentId
)
}
private fun directoryDocumentUri(path: String): Uri? {
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
documentId
)
}
private fun startVisualizerWhenAllowed() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(Manifest.permission.RECORD_AUDIO),
visualizerPermissionRequestCode
)
return
}
startVisualizer()
}
private fun startVisualizer() {
val sink = pendingSink ?: return
val args = pendingArgs
val sessionId = (args?.get("sessionId") as? Number)?.toInt() ?: 0
val bands = ((args?.get("bands") as? Number)?.toInt() ?: 26).coerceIn(8, 96)
stopVisualizer()
try {
val captureSize = Visualizer.getCaptureSizeRange()[1]
visualizer = Visualizer(sessionId).apply {
enabled = false
setCaptureSize(captureSize)
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray?,
samplingRate: Int
) {
val data = waveform ?: return
val values = downsample(data, bands)
mainHandler.post { sink.success(values) }
}
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray?,
samplingRate: Int
) = Unit
},
Visualizer.getMaxCaptureRate() / 2,
true,
false
)
enabled = true
}
} catch (error: Throwable) {
sink.error("VISUALIZER_UNAVAILABLE", error.message, null)
stopVisualizer()
}
}
private fun downsample(data: ByteArray, bands: Int): List<Double> {
if (data.isEmpty()) return emptyList()
val bucket = maxOf(1, data.size / bands)
val values = ArrayList<Double>(bands)
var index = 0
while (index < data.size && values.size < bands) {
var sum = 0.0
var count = 0
val end = minOf(index + bucket, data.size)
for (i in index until end) {
val centered = (data[i].toInt() and 0xFF) - 128
sum += kotlin.math.abs(centered) / 128.0
count++
}
values.add(if (count == 0) 0.0 else (sum / count).coerceIn(0.0, 1.0))
index = end
}
while (values.size < bands) values.add(0.0)
return values
}
private fun stopVisualizer() {
try {
visualizer?.enabled = false
visualizer?.release()
} catch (_: Throwable) {
} finally {
visualizer = null
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == notificationPermissionRequestCode) return
if (requestCode == bluetoothConnectPermissionRequestCode) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.requestBluetoothConnect granted -> $device")
audioDevicesSink?.success(device)
}
return
}
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
startVisualizer()
} else {
pendingSink?.error(
"RECORD_AUDIO_DENIED",
"Permiso de audio denegado para visualizar la onda real",
null
)
}
}
// -------------------------------------------------------------------------
// Audio Devices Channel
// -------------------------------------------------------------------------
private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) {
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result ->
when (call.method) {
"getActiveDevice" -> {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.getActiveDevice -> $device")
result.success(device)
}
"requestBluetoothConnect" -> {
Log.d(tag, "audio_devices.requestBluetoothConnect")
result.success(requestBluetoothConnect())
}
else -> result.notImplemented()
}
}
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
audioDevicesSink = events
registerAudioDeviceCallback()
}
override fun onCancel(arguments: Any?) {
unregisterAudioDeviceCallback()
audioDevicesSink = null
}
}
)
}
private fun requestBluetoothConnect(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
) {
return true
}
requestPermissions(
arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
bluetoothConnectPermissionRequestCode
)
return true
}
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
// Emit the current active output device when something connects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesAdded active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
// Emit the new active device after something disconnects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesRemoved active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, mainHandler)
}
private fun unregisterAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
audioDeviceCallback = null
}
/**
* Returns a map describing the current active audio output device.
*
* Device ID format (matches spec):
* - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
* - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
* - "bt_a2dp:<MAC>" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
* - "bt_a2dp:name:<productName>" — TYPE_BLUETOOTH_A2DP (8) fallback when the MAC is absent
* or still the OS placeholder ("02:00:00:00:00:00", seen
* without BLUETOOTH_CONNECT); productName colons are
* sanitized to '-' to preserve the matrix-key delimiter
* - "usb_headset:<address>" — TYPE_USB_HEADSET (14)
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
private fun getActiveAudioDevice(): Map<String, Any> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown
val priorityOrder = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
val best = outputs
.filter { it.isSink }
.sortedBy { device ->
val idx = priorityOrder.indexOf(device.type)
if (idx == -1) Int.MAX_VALUE else idx
}
.firstOrNull()
return deviceToMap(best)
}
private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
return when (device.type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER ->
mapOf("id" to "builtin_speaker", "type" to 2, "name" to (device.productName?.toString() ?: "Speaker"))
AudioDeviceInfo.TYPE_WIRED_HEADSET ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headset"))
AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones"))
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
// The OS reports a placeholder MAC ("02:00:00:00:00:00") when
// BLUETOOTH_CONNECT has not been granted; treat that (and any
// null/blank address) as absent instead of using it as an id.
val mac = device.address?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
val id = if (mac != null) {
"bt_a2dp:$mac"
} else {
val safeProductName = (device.productName?.toString()?.takeIf { it.isNotBlank() } ?: "unknown")
.replace(":", "-")
"bt_a2dp:name:$safeProductName"
}
mapOf(
"id" to id,
"type" to 8,
"name" to (device.productName?.toString() ?: "Bluetooth"),
)
}
AudioDeviceInfo.TYPE_USB_HEADSET -> {
val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
"type" to 14,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
else ->
mapOf(
"id" to "builtin_speaker",
"type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"),
)
}
}
// -------------------------------------------------------------------------
override fun onDestroy() {
if (activeInstance === this) {
activeInstance = null
}
unregisterAudioDeviceCallback()
stopVisualizer()
super.onDestroy()
}
companion object {
private const val STATIC_TAG = "PluriWave"
/** alarmAction reported when the native service snoozed by itself. */
const val ALARM_ACTION_SNOOZED = "snoozed"
/** alarmAction reported when a pending snooze was cancelled natively. */
const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled"
@Volatile
private var activeInstance: MainActivity? = null
/**
* Bridge for components without an activity (PluriWaveAlarmService):
* forwards alarm events through the existing alarmFired MethodChannel
* when the Flutter engine is alive; no-op when dead — the cold-start
* sync (getNativeSnoozeState) covers that case (Decision 2.1).
*/
fun notifyAlarmEvent(payload: Map<String, Any?>) {
val activity = activeInstance
if (activity == null) {
Log.d(STATIC_TAG, "alarm.channel notifyAlarmEvent skipped (engine dead)")
return
}
activity.mainHandler.post {
activity.alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
}
}
class MainActivity : FlutterActivity()
@@ -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,277 +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_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 requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
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 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
}
}
@@ -1,716 +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
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_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")
return
}
activeAlarmId = 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) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
activeAlarmId = null
stopSelf()
return
}
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)
)
return
}
cancelStationFallback()
cancelFadeLoop()
try {
player?.stop()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
player?.release()
player = null
activeAlarmId = null
releaseWakeLock()
abandonAlarmAudioFocus()
if (alarmId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
}
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 {
action = ACTION_STOP
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)
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_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
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 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")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service start failed", error)
}
}
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)
}
}
}
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,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: 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,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,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="files"
path="." />
<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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

@@ -1,5 +0,0 @@
# PluriWave Night Ocean mockup prompt
Generated with built-in image_gen after user feedback rejecting purple-heavy futuristic UI.
Direction: Night Ocean Broadcast ? midnight navy and petrol teal base, mint action states, warm amber live/accent, cream text/surfaces, practical radio streaming UX with immediate Now Playing flow.
-27
View File
@@ -1,27 +0,0 @@
# Alarmas Android en PluriWave
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
## Flujo
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
3. `AlarmScheduler` registra:
- alarma principal con `setAlarmClock`;
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
## Permisos
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
## Fallbacks
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
## Vacaciones y omisiones
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
-30
View File
@@ -1,30 +0,0 @@
# Arquitectura de alarmas con pantalla apagada
## Diagnóstico
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
## Decisión
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
## Flujo recomendado
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
5. La UI Flutter se abre por full-screen intent si Android lo permite.
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
## Referencias
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
## Notas
- El audio local interno es el fallback más fiable para pantalla apagada.
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
-32
View File
@@ -1,32 +0,0 @@
# Notas de grabación y visualización real de audio
Referencia interna: este archivo vive en `docs/` y no está listado en
`flutter.assets`, así que no se compila dentro de la aplicación.
## Decisiones aplicadas
- La grabación de radio se hace leyendo el stream HTTP original de la emisora y
escribiendo sus bytes a disco. No se graba micrófono ni salida del sistema.
- La ventaja es que se conserva la calidad original del stream y se evita
recomprimir audio.
- La forma de onda real se intenta capturar en Android con
`android.media.audiofx.Visualizer` usando el `androidAudioSessionId` expuesto
por `just_audio`.
- Si Android deniega permisos o el dispositivo no permite capturar esa sesión,
la UI cae al visualizador animado anterior para no bloquear el reproductor.
## Fuentes consultadas
- `just_audio` expone `androidAudioSessionIdStream` para enlazar efectos o
visualizadores Android a la sesión activa:
https://pub.dev/packages/just_audio/versions/0.10.4
- Android `Visualizer` permite capturar waveform de contenido en reproducción y
requiere permiso `RECORD_AUDIO`:
https://www.android-doc.com/reference/android/media/audiofx/Visualizer.html
- Radio Browser permite ordenar búsquedas por `bitrate` y expone campos
`codec`/`bitrate`:
https://stations.radioss.app/
- El paquete `audio_visualizer` existe, pero se descartó como dependencia
inmediata porque duplicaría reproducción con su propio player; PluriWave ya
usa `audio_service` + `just_audio` y acabamos de estabilizar ese flujo:
https://pub.dev/packages/audio_visualizer
-31
View File
@@ -1,31 +0,0 @@
# Notas de reproducción de radio con just_audio/audio_service
Referencia interna para futuras correcciones del reproductor de PluriWave. Este archivo está en `docs/` y no se incluye en `flutter.assets`, por lo que no compila dentro de la app.
## Hallazgos útiles
- `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”.
- Las versiones antiguas de PluriWave que sí cambiaban de emisora usaban el flujo simple de `audio_service` + `just_audio`: `stop() -> setUrl() -> play()` dentro de `playMediaItem`.
- La regresión apareció cuando mezclamos dos responsabilidades: usar `handler.emisoraActual` como estado técnico de audio y también como estado visual inmediato para mostrar el mini reproductor.
- El logcat de 2026-05-21 mostró la media session de PluriWave atascada en `CONNECTING` sin `PlayerException`, con metadata de la emisora anterior (`Track FM`). Eso apunta a un player/ExoPlayer reutilizado que queda colgado entre `stop/setUrl/play`, no a un error HTTP visible.
## Decisión aplicada en PluriWave
- Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler.
- No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión.
- No esperar `play()` como operación de finalización para radio en vivo.
- Al cambiar emisora, recrear el `AudioPlayer`/ExoPlayer para matar completamente la reproducción anterior antes de `setUrl(...)`.
- Proteger `EstadoRadio.reproducir` con revisión para que una operación vieja no aplique presets/clicks encima de una nueva.
## Intentos descartados
- `setAudioSource(..., preload: false)`: teóricamente razonable, pero en PluriWave rompió la primera conexión.
- Hacer que el handler publique `emisoraActual` antes de que el flujo histórico de audio avance: arregla el mini reproductor, pero cambia la semántica que tenían las versiones viejas.
- Reutilizar siempre el mismo `AudioPlayer` con `stop()`: logcat mostró estado `CONNECTING` persistente sin excepción al cambiar/reintentar.
## Fuentes consultadas
- just_audio `AudioPlayer.play()` API: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer/play.html
- just_audio `AudioPlayer` API general: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer-class.html
- Ejemplo de radio en vivo de just_audio: https://gist.github.com/scysys/7f700cd49f09ba788021504e8d3477aa
- Discusión sobre cambiar fuente con audio_service + just_audio: https://stackoverflow.com/questions/70526156/changing-audio-source-in-audio-service-and-just-audio-flutter
-110
View File
@@ -1,110 +0,0 @@
# PluriWave · Guía de publicación automática en Google Play
> Estado: en preparación
> Última revisión: 2026-05-27
## Objetivo
Dejar **PluriWave** con un flujo de publicación lo más automático posible:
- `main` → desarrollo diario, pruebas y artefactos internos
- `PRO` → publicación automática a **Google Play Internal Testing**
## Estrategia acordada
### Ramas
- **`main`**
- desarrollo diario
- análisis, tests y builds internos
- NO publica en Google Play
- **`PRO`**
- rama de release permanente
- al subir cambios aquí, se genera el **AAB release firmado**
- publica automáticamente en **Google Play · Prueba interna**
### Publicación
1. Bootstrap manual inicial en Play Console
2. Configuración correcta del keystore de subida
3. Integración con Google Play Developer API
4. Automatización desde Gitea Actions
## Estado actual del proyecto
### Verificado en el repositorio
- Existe workflow en `.gitea/workflows/build.yml`
- Actualmente compila y firma correctamente en CI
- Genera:
- APK release
- AAB release
- Publica artefactos internos en `ftl-builds`
- Ya existe soporte para keystore release desde `android/key.properties`
### Verificado en Play Console
- La app ya está creada
- Nombre: `PluriWave`
- Package: `es.freetimelab.pluriwave`
- Ya se ha subido manualmente un **AAB** al canal de **prueba interna**
- Producción sigue bloqueada por el requisito de:
- prueba cerrada
- 12 testers
- 14 días
## Automatización prevista en CI
### `main`
- `flutter pub get`
- `flutter analyze`
- build release
- publicación de APK/AAB en infraestructura interna
### `PRO`
- `flutter pub get`
- `flutter analyze`
- build release firmado
- publicación de APK/AAB en infraestructura interna
- subida automática del `.aab` a Google Play **track internal**
## Secretos necesarios en Gitea
### Ya usados por firma
- `PLURIWAVE_KEYSTORE_PASSWORD`
- `GITEA_TOKEN`
### Necesarios para Play Store
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
> Debe contener el JSON completo de una **Service Account** con acceso concedido en Play Console a esta aplicación.
## Ficheros implicados
- `.gitea/workflows/build.yml`
- `fastlane/Fastfile`
- `fastlane/Appfile`
- `android/app/build.gradle.kts`
## Siguiente validación manual
Cuando la automatización quede desplegada:
1. crear la rama `PRO` en remoto
2. configurar `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
3. hacer push a `PRO`
4. comprobar que:
- compila
- firma
- genera AAB
- sube a Google Play Internal Testing
## Notas importantes
- El canal automatizado inicial será **internal testing**, no producción
- La primera publicación manual en Play Console ya quedó hecha
- La automatización NO elimina el requisito posterior de closed testing antes de producción
-1
View File
@@ -1 +0,0 @@
package_name(ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave")
-25
View File
@@ -1,25 +0,0 @@
default_platform(:android)
platform :android do
desc "Sube el AAB actual al track internal de Google Play"
lane :upload_internal do
json_key_path = ENV["PLAY_JSON_KEY_PATH"]
aab_path = ENV["PLAY_AAB_PATH"] || "build/app/outputs/bundle/release/app-release.aab"
package_name = ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave"
UI.user_error!("Falta PLAY_JSON_KEY_PATH") if json_key_path.to_s.empty?
UI.user_error!("No existe el AAB en #{aab_path}") unless File.exist?(aab_path)
upload_to_play_store(
json_key: json_key_path,
package_name: package_name,
aab: aab_path,
track: ENV["PLAY_TRACK"] || "internal",
release_status: ENV["PLAY_RELEASE_STATUS"] || "completed",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_changelogs: true
)
end
end

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