Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07c6e32af0 | ||
|
|
35bb180612 | ||
|
|
43781274ce | ||
|
|
03de273369 | ||
|
|
f1bb54d25a | ||
|
|
38d78fc4f8 | ||
|
|
448fbec354 | ||
|
|
d8e67a5204 | ||
|
|
e84cd2d7ed | ||
|
|
9c7cf4e261 | ||
|
|
597c98d0e7 | ||
|
|
8741cdba5f | ||
|
|
3fd5080cd1 | ||
|
|
41b95fed44 | ||
|
|
a788eabfcb | ||
|
|
6f07e27905 | ||
|
|
8a4a8bd5d7 | ||
|
|
884567beaa | ||
|
|
5bd861d7fb | ||
|
|
bd7f883118 | ||
|
|
836fb44adb | ||
|
|
2e64740b26 | ||
|
|
f73a12ad48 | ||
|
|
86225cbc68 | ||
|
|
e84a41acd4 | ||
|
|
1b1b692f04 | ||
|
|
5f65d068a8 | ||
|
|
3ebb41aa9d | ||
|
|
812922d7f1 | ||
|
|
5291221fc8 | ||
|
|
2c28f1696a | ||
|
|
b294b287c7 | ||
|
|
b7af1064cc | ||
|
|
c65497e58a | ||
|
|
762e740c89 | ||
|
|
48e74e6bfe | ||
|
|
d824ef5c21 | ||
|
|
45b7fc8741 | ||
|
|
2255c18ce8 | ||
|
|
13ad736917 | ||
|
|
a34182fdaf | ||
|
|
65c1ac2085 | ||
|
|
23ab3494a7 | ||
|
|
7eaa87b462 | ||
|
|
f6ea4e64b9 | ||
|
|
efbf289f6b | ||
|
|
0804a612ec | ||
|
|
79f6f8ef38 | ||
|
|
b9539223de | ||
|
|
66a19525bd | ||
|
|
a6e1177752 | ||
|
|
acd903d9a8 | ||
|
|
251d3fd3cd | ||
|
|
43f61d7c21 | ||
|
|
39693ce995 | ||
|
|
159334f997 | ||
|
|
41b35c7f44 | ||
|
|
747738d20a | ||
|
|
0b18540935 | ||
|
|
8cca7c3daa | ||
|
|
158203fee9 | ||
|
|
b17c582572 | ||
|
|
224763bca3 | ||
|
|
aef4e02c1f | ||
|
|
d3763eaec5 | ||
|
|
8f7ca8059b | ||
|
|
bfa95a1e57 | ||
|
|
0ab63731d0 | ||
|
|
a31dc07318 | ||
|
|
bccc5c48b8 | ||
|
|
e5b6d8acb3 | ||
|
|
8f2bf2bdd6 | ||
|
|
c78af4b1e8 | ||
|
|
28b663bbe7 | ||
|
|
a8a2db4b64 | ||
|
|
7cfde24811 | ||
|
|
da9d32849c | ||
|
|
6acbd7ca93 | ||
|
|
cc98f3f331 | ||
|
|
5877c2a4ee | ||
|
|
f5cf261f12 | ||
|
|
ffd09a2179 | ||
|
|
481944815f | ||
|
|
89ff6a3912 | ||
|
|
4ffd73d136 | ||
|
|
58922de6fc | ||
|
|
71978de68f | ||
|
|
d1d4afb88f | ||
|
|
4632d53eb8 | ||
|
|
8f42e67b48 | ||
|
|
f7753c8402 | ||
|
|
8a032e6e62 | ||
|
|
202bef3539 | ||
|
|
52855e75c2 | ||
|
|
0416b301b2 | ||
|
|
0380bbb1e7 | ||
|
|
079e19f0ee | ||
|
|
f3e9487215 | ||
|
|
b5acf97ba4 | ||
|
|
cf9422dff3 | ||
|
|
957615dcd6 | ||
|
|
089b8b4227 | ||
|
|
a5475ce118 | ||
|
|
00fe49c309 | ||
|
|
643ba1eb45 | ||
|
|
7abc8c3b0f | ||
|
|
2e17dfd511 | ||
|
|
d449d8577b | ||
|
|
ffe1c41458 | ||
|
|
d423676623 | ||
|
|
de07316d79 | ||
|
|
c3a22c4658 | ||
|
|
7c7bd64e85 | ||
|
|
2aeef1626c | ||
|
|
1d39293fe3 | ||
|
|
ef4b8ab323 | ||
|
|
20c135a848 | ||
|
|
82f70e2fa3 | ||
|
|
eb23a438b6 | ||
|
|
d45fbe60db | ||
|
|
3640a76253 | ||
|
|
4a00472a83 | ||
|
|
028e2d69b1 | ||
|
|
8f6124fc1a | ||
|
|
6dd045ea42 | ||
|
|
8f77550a05 | ||
|
|
cf994757a4 | ||
|
|
e47c0a88e0 | ||
|
|
f5c2f0a879 | ||
|
|
9a9ef95b07 | ||
|
|
659e6da189 | ||
|
|
eae19e1d70 | ||
|
|
10d18b5064 | ||
|
|
a46a7ede21 | ||
|
|
04a281b80c | ||
|
|
7569a5b020 | ||
|
|
7dceed5dae | ||
|
|
03b56c98e7 | ||
|
|
e447816d3f | ||
|
|
c189078c26 | ||
|
|
18016cc406 | ||
|
|
e5aa1439bd | ||
|
|
42dd64635c | ||
|
|
41bbd0ea17 | ||
|
|
896349ad5f | ||
|
|
27b8fccac9 | ||
|
|
3ab138a4fa | ||
|
|
c8fff0d977 | ||
|
|
cfea818133 | ||
|
|
bc27e7832d | ||
|
|
26078ad49b | ||
|
|
2816a97c93 | ||
|
|
a976b8e797 | ||
|
|
d7277a9274 | ||
|
|
ee09224c13 | ||
|
|
0675750b2e | ||
|
|
a48dd6ddf9 | ||
|
|
eb185231a1 | ||
|
|
809255bd43 | ||
|
|
fde651eee9 | ||
|
|
9ad58898e0 | ||
|
|
6a5fcd8d96 | ||
|
|
b6e66e75ce | ||
|
|
f6a9ba0086 | ||
|
|
157d52996e | ||
|
|
aaeee51233 | ||
|
|
5f35db6352 | ||
|
|
c46d941e6c | ||
|
|
9bd973b327 | ||
|
|
c347ce9d8e | ||
|
|
f667277e35 | ||
|
|
0114e4805e | ||
|
|
8190c4ab8d | ||
|
|
2320dbdc5f | ||
|
|
785a41f0c4 | ||
|
|
30fe6c6667 | ||
|
|
3b0efb641c | ||
|
|
4e22bd4e98 | ||
|
|
6480c56f99 | ||
|
|
116d878a98 | ||
|
|
3f548fd53e | ||
|
|
d85dee6fa8 | ||
|
|
e1d1d6c639 | ||
|
|
0edad1bfcb | ||
|
|
a181cc8e85 | ||
|
|
72f6f4e974 | ||
|
|
4ae93182fa | ||
|
|
d8823a328d | ||
|
|
eeadcc1cc6 | ||
|
|
28067e392d | ||
|
|
a3a648c633 | ||
|
|
7f1874f873 | ||
|
|
fb808ebb60 | ||
|
|
8c2cba093c | ||
|
|
a9202c6eb3 | ||
|
|
dac1b602e2 | ||
|
|
921e972183 | ||
|
|
d0ceaac3f3 | ||
|
|
a6a91af402 | ||
|
|
6aa9a59d7b | ||
|
|
0e18c82292 | ||
|
|
0456850f3d | ||
|
|
ef22454350 | ||
|
|
b23450819c | ||
|
|
1791207bd4 | ||
|
|
fe531a1784 | ||
|
|
6b0faebc7f | ||
|
|
26d8151d7a | ||
|
|
f49d349616 | ||
|
|
37aea7e99f | ||
|
|
ee26c78d82 | ||
|
|
6249ed1b2c | ||
|
|
01135e8a3d | ||
|
|
67fe4413f4 | ||
|
|
be0d6c5a9e | ||
|
|
abea51ba3f | ||
|
|
10520fef48 | ||
|
|
34022e0814 | ||
|
|
7fcd0f544e | ||
|
|
f888153aa9 | ||
|
|
b9cf42b91c | ||
|
|
22e19d1cb0 | ||
|
|
3be59d740c | ||
|
|
2fb794a43b | ||
|
|
d8acf74771 | ||
|
|
eb0ef37c76 | ||
|
|
4bcd86f59c | ||
|
|
9c51454d57 | ||
|
|
c707fc9911 | ||
|
|
f95a8290ae | ||
|
|
40f1d77a40 | ||
|
|
7dc8fbe99d | ||
|
|
d579a0e107 | ||
|
|
2f52a31242 | ||
|
|
922b3b4859 | ||
|
|
bb5937e184 | ||
|
|
a51b8377a2 | ||
|
|
547a667ada | ||
|
|
8a455eb6bb | ||
|
|
ebd26af169 | ||
|
|
933ced76ba | ||
|
|
a8e9c91f9d | ||
|
|
e59ac7d552 | ||
|
|
556151c64d | ||
|
|
8e2c01f626 | ||
|
|
b41a28452d | ||
|
|
a8425d65bc | ||
|
|
0dc554e5fb | ||
|
|
ea4fc369f6 | ||
|
|
47c6505c41 | ||
|
|
23b73bf0e0 | ||
|
|
b13176eaeb | ||
|
|
d97bc06a5b | ||
|
|
2b1f3adb3a | ||
|
|
50088eb94f | ||
|
|
b61b3218fc | ||
|
|
651c4e1360 | ||
|
|
1250f40322 | ||
|
|
b0fdba5119 | ||
|
|
44849986d2 | ||
|
|
c6dad82e8c | ||
|
|
5fd3d6deb9 | ||
|
|
4764266a1a | ||
|
|
9aa881342d |
@@ -0,0 +1,178 @@
|
|||||||
|
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,7 +11,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
flutter-ci:
|
flutter-ci:
|
||||||
name: Test + Build
|
name: Test + Build
|
||||||
runs-on: macmini-flutter
|
#runs-on: macos-14
|
||||||
|
runs-on: [self-hosted, macos, arm64, flutter]
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -32,6 +32,7 @@ migrate_working_dir/
|
|||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
.atl/
|
||||||
|
|
||||||
# Symbolication related
|
# Symbolication related
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog — PluriWave
|
# 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
|
## [0.4.0] — 2026-04-04
|
||||||
|
|
||||||
### Añadido
|
### Añadido
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## Internacionalización AAA
|
||||||
|
|
||||||
|
- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma.
|
||||||
|
- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema.
|
||||||
|
- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma.
|
||||||
|
- [x] Añadir soporte inicial para un conjunto amplio de idiomas muy hablados: inglés, español, chino, hindi, árabe, portugués, francés, ruso, alemán, japonés, indonesio, bengalí e italiano.
|
||||||
|
- [x] Ejecutar escaneo UTF-8 sobre ARB/código tocado y corregir corrupciones visibles en los textos migrados.
|
||||||
|
- [ ] Validar no solo el guardado UTF-8 en código, sino también el render real en la aplicación para acentos, ñ, signos, alfabetos no latinos y direcciones RTL.
|
||||||
|
- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones.
|
||||||
|
- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
|
||||||
|
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`.
|
||||||
|
- [ ] Revisar profesionalmente todas las traducciones nuevas con hablantes nativos o servicio especializado antes de considerarlas definitivas.
|
||||||
|
- [ ] Preparar traducciones adicionales si se decide ampliar más allá del conjunto inicial.
|
||||||
|
- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
|
||||||
|
- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones.
|
||||||
|
|
||||||
|
## UX y accesibilidad visual
|
||||||
|
|
||||||
|
- [x] Revisar los paneles informativos superiores de cada pantalla: recuperar márgenes internos elegantes para que el texto no quede pegado a los bordes.
|
||||||
|
- [x] Añadir comportamiento adaptativo en el header premium para escalas de texto grandes y pantallas estrechas.
|
||||||
|
- [ ] Probar la aplicación con escalas de texto grandes/muy grandes del sistema en dispositivo real o golden tests.
|
||||||
|
- [ ] Diseñar una solución elegante para textos largos en todos los paneles secundarios: reflow, límites razonables, scroll, wraps controlados y jerarquías que mantengan la estética AAA.
|
||||||
|
|
||||||
|
## Grabaciones
|
||||||
|
|
||||||
|
- [x] Añadir en Ajustes un acceso elegante para abrir la carpeta de grabaciones con el gestor de ficheros del sistema mediante intent.
|
||||||
|
- [x] Añadir configuración de tamaño máximo de fichero de grabación; valor por defecto: 500 MB.
|
||||||
|
- [x] Detener automáticamente la grabación si se para o pausa la reproducción.
|
||||||
|
- [x] Detener automáticamente la grabación si se cambia de emisora.
|
||||||
|
- [ ] Probar en Android real que el intent de carpeta funciona con rutas internas y rutas escogidas por el usuario.
|
||||||
|
|
||||||
|
## Búsqueda de emisoras
|
||||||
|
|
||||||
|
- [x] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
|
||||||
|
|
||||||
|
## Favoritos
|
||||||
|
|
||||||
|
- [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
|
||||||
|
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
|
||||||
|
|
||||||
|
## Agrupaciones de favoritos
|
||||||
|
|
||||||
|
- [x] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
|
||||||
|
- [x] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
|
||||||
|
- [x] Gestionar desde la vista Favoritos qué emisoras pertenecen a cada agrupación/lista.
|
||||||
|
- [x] Diseñar migración SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
|
||||||
|
- [x] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
|
||||||
|
- [x] Completar UI en Favoritos para mover emisoras entre listas.
|
||||||
@@ -23,6 +23,11 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
unawaited_futures: true
|
||||||
|
prefer_final_locals: true
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun secret(name: String, propertyName: String): String? =
|
||||||
|
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
|
||||||
|
?: System.getenv(name)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "es.freetimelab.pluriwave"
|
namespace = "es.freetimelab.pluriwave"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -20,21 +31,38 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "es.freetimelab.pluriwave"
|
applicationId = "es.freetimelab.pluriwave"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
|
||||||
|
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
|
||||||
|
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
|
||||||
|
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
|
||||||
|
|
||||||
|
if (!storeFilePath.isNullOrBlank()) {
|
||||||
|
storeFile = file(storeFilePath)
|
||||||
|
}
|
||||||
|
if (!storePasswordValue.isNullOrBlank()) {
|
||||||
|
storePassword = storePasswordValue
|
||||||
|
}
|
||||||
|
if (!keyAliasValue.isNullOrBlank()) {
|
||||||
|
keyAlias = keyAliasValue
|
||||||
|
}
|
||||||
|
if (!keyPasswordValue.isNullOrBlank()) {
|
||||||
|
keyPassword = keyPasswordValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,33 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<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_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.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
|
<application
|
||||||
android:label="PluriWave"
|
android:label="PluriWave"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round">
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
@@ -39,6 +53,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".PluriWaveAlarmService"
|
||||||
|
android:foregroundServiceType="mediaPlayback|systemExempted"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
@@ -48,9 +67,51 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Android Auto discovery (android-auto-media) -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
</application>
|
</application>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,838 @@
|
|||||||
package es.freetimelab.pluriwave
|
package es.freetimelab.pluriwave
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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
|
||||||
|
|
||||||
class MainActivity : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M7,18h2L9,6L7,6v12zM3,14h2v-4L3,10v4zM11,20h2L13,4h-2v16zM19,10v4h2v-4h-2zM15,18h2L17,6h-2v12z" />
|
||||||
|
</vector>
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<automotiveApp>
|
||||||
|
<uses name="media"/>
|
||||||
|
</automotiveApp>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# أهلاً بك في PluriWave
|
||||||
|
|
||||||
|
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
|
||||||
|
|
||||||
|
## راديو مباشر
|
||||||
|
|
||||||
|
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
|
||||||
|
- استكشف المحطات القريبة واكتشف محطات جديدة.
|
||||||
|
- رتّب القوائم حسب الاسم أو الجودة.
|
||||||
|
|
||||||
|
## موسيقى بطريقتك
|
||||||
|
|
||||||
|
- احفظ المفضلات ونظّمها في مجموعات.
|
||||||
|
- اضبط المعادل العام أو إعدادات كل محطة.
|
||||||
|
- استخدم مؤقّت النوم بمدد مخصّصة.
|
||||||
|
|
||||||
|
## التسجيلات
|
||||||
|
|
||||||
|
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
|
||||||
|
- حدّد الحجم الأقصى للملف لتبقى بأمان.
|
||||||
|
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
|
||||||
|
|
||||||
|
## منبّهات موسيقية
|
||||||
|
|
||||||
|
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
|
||||||
|
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
|
||||||
|
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# PluriWave-এ স্বাগতম
|
||||||
|
|
||||||
|
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
|
||||||
|
|
||||||
|
## লাইভ রেডিও
|
||||||
|
|
||||||
|
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
|
||||||
|
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
|
||||||
|
- তালিকা নাম বা মান অনুযায়ী সাজান।
|
||||||
|
|
||||||
|
## আপনার মতো করে সঙ্গীত
|
||||||
|
|
||||||
|
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
|
||||||
|
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
|
||||||
|
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
|
||||||
|
|
||||||
|
## রেকর্ডিং
|
||||||
|
|
||||||
|
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
|
||||||
|
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
|
||||||
|
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
|
||||||
|
|
||||||
|
## মিউজিক অ্যালার্ম
|
||||||
|
|
||||||
|
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
|
||||||
|
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
|
||||||
|
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# PluriWave में आपका स्वागत है
|
||||||
|
|
||||||
|
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
|
||||||
|
|
||||||
|
## लाइव रेडियो
|
||||||
|
|
||||||
|
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
|
||||||
|
- पास के स्टेशन देखें और नए रेडियो खोजें।
|
||||||
|
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
|
||||||
|
|
||||||
|
## संगीत आपके तरीके से
|
||||||
|
|
||||||
|
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
|
||||||
|
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
|
||||||
|
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
|
||||||
|
|
||||||
|
## रिकॉर्डिंग
|
||||||
|
|
||||||
|
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
|
||||||
|
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
|
||||||
|
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
|
||||||
|
|
||||||
|
## संगीत अलार्म
|
||||||
|
|
||||||
|
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
|
||||||
|
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
|
||||||
|
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# PluriWave へようこそ
|
||||||
|
|
||||||
|
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
|
||||||
|
|
||||||
|
## ライブラジオ
|
||||||
|
|
||||||
|
- 名前、国、言語、音質で局を検索できます。
|
||||||
|
- 近くの局を探して新しいラジオを見つけられます。
|
||||||
|
- リストを名前または音質で並べ替えできます。
|
||||||
|
|
||||||
|
## あなた好みの音楽体験
|
||||||
|
|
||||||
|
- お気に入りを保存してグループで整理できます。
|
||||||
|
- 全体イコライザーや局ごとのプリセットを調整できます。
|
||||||
|
- 時間を指定できるスリープタイマーを使えます。
|
||||||
|
|
||||||
|
## 録音
|
||||||
|
|
||||||
|
- 元のストリームを再圧縮せずに録音できます。
|
||||||
|
- 最大ファイルサイズを制限して安全に使えます。
|
||||||
|
- 録音フォルダーを開いて共有・移動・編集できます。
|
||||||
|
|
||||||
|
## 音楽アラーム
|
||||||
|
|
||||||
|
- 1回のみ、毎日、平日のアラームを作成できます。
|
||||||
|
- お気に入り局と安全な内蔵サウンドを選べます。
|
||||||
|
- 休日設定、次回スキップ、スヌーズに対応しています。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Добро пожаловать в PluriWave
|
||||||
|
|
||||||
|
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
|
||||||
|
|
||||||
|
## Прямое радио
|
||||||
|
|
||||||
|
- Ищите станции по названию, стране, языку и качеству.
|
||||||
|
- Изучайте ближайшие станции и открывайте новое радио.
|
||||||
|
- Сортируйте списки по названию или качеству.
|
||||||
|
|
||||||
|
## Музыка по-вашему
|
||||||
|
|
||||||
|
- Сохраняйте избранное и организуйте его по группам.
|
||||||
|
- Настраивайте глобальный эквалайзер или пресеты для станций.
|
||||||
|
- Используйте таймер сна с нужной длительностью.
|
||||||
|
|
||||||
|
## Записи
|
||||||
|
|
||||||
|
- Записывайте радио без повторного сжатия исходного потока.
|
||||||
|
- Ограничивайте максимальный размер файла для безопасности.
|
||||||
|
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
|
||||||
|
|
||||||
|
## Музыкальные будильники
|
||||||
|
|
||||||
|
- Создавайте разовые, ежедневные или будничные будильники.
|
||||||
|
- Выбирайте любимую станцию и безопасный встроенный звук.
|
||||||
|
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 欢迎使用 PluriWave
|
||||||
|
|
||||||
|
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
|
||||||
|
|
||||||
|
## 直播电台
|
||||||
|
|
||||||
|
- 按名称、国家、语言和音质搜索电台。
|
||||||
|
- 探索附近电台,发现新的广播内容。
|
||||||
|
- 按名称或音质排序列表。
|
||||||
|
|
||||||
|
## 按你的方式听音乐
|
||||||
|
|
||||||
|
- 保存收藏并按分组整理。
|
||||||
|
- 调整全局均衡器或单电台预设。
|
||||||
|
- 使用可自定义时长的睡眠定时器。
|
||||||
|
|
||||||
|
## 录音
|
||||||
|
|
||||||
|
- 录制电台时不重新压缩原始流。
|
||||||
|
- 限制最大文件大小,更安全省心。
|
||||||
|
- 打开录音文件夹以分享、移动或编辑文件。
|
||||||
|
|
||||||
|
## 音乐闹钟
|
||||||
|
|
||||||
|
- 创建一次性、每日或工作日闹钟。
|
||||||
|
- 选择喜爱的电台和安全的内置提示音。
|
||||||
|
- 支持假期、跳过下次执行和贪睡。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# v0.1.47 · منبّهات وملفات أكثر موثوقية
|
||||||
|
|
||||||
|
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
|
||||||
|
|
||||||
|
## التحسينات
|
||||||
|
|
||||||
|
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
|
||||||
|
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
|
||||||
|
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
|
||||||
|
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
|
||||||
|
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
|
||||||
|
|
||||||
|
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
|
||||||
|
|
||||||
|
## উন্নতি
|
||||||
|
|
||||||
|
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
|
||||||
|
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
|
||||||
|
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
|
||||||
|
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
|
||||||
|
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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é.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
|
||||||
|
|
||||||
|
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
|
||||||
|
|
||||||
|
## सुधार
|
||||||
|
|
||||||
|
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
|
||||||
|
- Android exact-alarm अनुमति के बेहतर निदान।
|
||||||
|
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
|
||||||
|
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
|
||||||
|
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# v0.1.47 · より信頼できるアラームとファイル
|
||||||
|
|
||||||
|
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
|
||||||
|
|
||||||
|
## 改善点
|
||||||
|
|
||||||
|
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
|
||||||
|
- Android の正確なアラーム権限診断を改善。
|
||||||
|
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
|
||||||
|
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
|
||||||
|
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# v0.1.47 · Более надежные будильники и файлы
|
||||||
|
|
||||||
|
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
|
||||||
|
|
||||||
|
## Улучшения
|
||||||
|
|
||||||
|
- Новая нативная основа будильников с безопасным встроенным звуком.
|
||||||
|
- Улучшена диагностика разрешений Android для точных будильников.
|
||||||
|
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
|
||||||
|
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
|
||||||
|
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# v0.1.47 · 更可靠的闹钟与文件
|
||||||
|
|
||||||
|
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
|
||||||
|
|
||||||
|
## 改进
|
||||||
|
|
||||||
|
- 闹钟采用新的原生基础,配有安全的内置提示音。
|
||||||
|
- 改进 Android 精确闹钟权限诊断。
|
||||||
|
- 同一分钟创建的闹钟不再因秒数被丢弃。
|
||||||
|
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
|
||||||
|
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,5 @@
|
|||||||
|
# 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.
|
||||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 539 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 548 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,3 @@
|
|||||||
|
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.
|
||||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,5 @@
|
|||||||
|
# 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.
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,5 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Llevar PluriWave a Android Auto
|
||||||
|
|
||||||
|
Guía para un desarrollador que **nunca publicó una app en Play Store** y quiere que
|
||||||
|
PluriWave —instalada en el teléfono— se pueda **navegar y controlar desde la pantalla
|
||||||
|
del coche** vía Android Auto.
|
||||||
|
|
||||||
|
> **Alcance de esta guía.** Hablamos de **Android Auto proyectado**: la app corre en el
|
||||||
|
> móvil y se proyecta al coche. NO es *Android Automotive OS* (donde la app se instala
|
||||||
|
> dentro del sistema del vehículo). PluriWave es una app de audio → categoría
|
||||||
|
> **"media app"**. Todo lo de abajo es para esa combinación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR (lo importante primero)
|
||||||
|
|
||||||
|
1. **El 60% ya está hecho.** PluriWave usa `audio_service`, que ya expone un
|
||||||
|
`MediaBrowserService` + `MediaSession` (lo que Android Auto exige). No hay que
|
||||||
|
reescribir el motor de audio.
|
||||||
|
2. **Falta lo que un coche necesita de más que un teléfono:** un **árbol navegable**
|
||||||
|
de emisoras (para que el coche muestre una lista) y una **declaración en el manifest**
|
||||||
|
para que Android Auto descubra la app.
|
||||||
|
3. **Trabajo real de código:** ~1 archivo XML nuevo + 1 línea en el manifest +
|
||||||
|
implementar 3 métodos en `PluriWaveAudioHandler` (`getChildren`, `getMediaItem`,
|
||||||
|
`playFromMediaId`).
|
||||||
|
4. **Publicación:** Android Auto añade una **revisión extra de Google** contra las
|
||||||
|
*car app quality guidelines*. Es más estricta y más lenta que la de una app normal.
|
||||||
|
|
||||||
|
Tiempo realista para un principiante: **1–2 semanas** (código un par de días, el resto
|
||||||
|
es testing con el emulador de coche y la revisión de Google).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 0 — Cómo funciona (modelo mental)
|
||||||
|
|
||||||
|
Un coche con Android Auto **no ejecuta tu UI de Flutter**. En su lugar, le pide a tu app
|
||||||
|
dos cosas a través de un servicio estándar de Android:
|
||||||
|
|
||||||
|
| El coche pregunta | Tu app responde | En Android esto es |
|
||||||
|
|-------------------|-----------------|--------------------|
|
||||||
|
| "¿Qué contenido tenés para mostrar?" | Una lista de ítems (carpetas + emisoras) | `MediaBrowserService` → `getChildren()` |
|
||||||
|
| "El usuario tocó ESTE ítem, reproducilo" | Arrancás el stream | `MediaSession` → `playFromMediaId()` |
|
||||||
|
| "Mostrame play/pausa/título/carátula" | El estado actual | `PlaybackState` + `MediaItem` |
|
||||||
|
|
||||||
|
`audio_service` implementa el `MediaBrowserService` y el `MediaSession` por vos. Tu único
|
||||||
|
trabajo es **rellenar las respuestas** (la lista de emisoras y cómo reproducir cada una).
|
||||||
|
|
||||||
|
La UI del coche la dibuja **Android Auto**, no vos. Vos solo aportás datos y audio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 1 — Qué ya tiene PluriWave (punto de partida)
|
||||||
|
|
||||||
|
Verificado en el código actual:
|
||||||
|
|
||||||
|
| Pieza | Dónde | Estado |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Dependencia `audio_service` `^0.18.15` | `pubspec.yaml` | ✅ |
|
||||||
|
| `MediaBrowserService` declarado en el manifest | `android/app/src/main/AndroidManifest.xml:47-54` | ✅ |
|
||||||
|
| `MediaButtonReceiver` (controles físicos/notificación) | `AndroidManifest.xml:62-68` | ✅ |
|
||||||
|
| Inicialización del handler | `lib/main.dart:38` (`AudioService.init`) | ✅ |
|
||||||
|
| Config del servicio | `lib/main.dart:21` (`AudioServiceConfig`) | ✅ |
|
||||||
|
| Handler propio | `lib/servicios/servicio_audio.dart:127` (`PluriWaveAudioHandler extends BaseAudioHandler`) | ✅ |
|
||||||
|
| Reproducir un ítem | `servicio_audio.dart:433` (`playMediaItem`) | ✅ |
|
||||||
|
| Mapear `MediaItem` ↔ `Emisora` | `servicio_audio.dart:705` (`_emisoraDesdeMediaItem`) | ✅ |
|
||||||
|
| `foregroundServiceType="mediaPlayback"` | `AndroidManifest.xml:49` | ✅ |
|
||||||
|
|
||||||
|
**Ventaja clave de la versión 0.18:** el handler corre en el **mismo isolate** que la app,
|
||||||
|
así que `getChildren()` puede leer directamente tu lista de emisoras/favoritos del estado
|
||||||
|
de la app. No hay que sincronizar entre isolates.
|
||||||
|
|
||||||
|
### Lo que NO está (el hueco a rellenar)
|
||||||
|
|
||||||
|
| Falta | Consecuencia hoy |
|
||||||
|
|-------|------------------|
|
||||||
|
| `res/xml/automotive_app_desc.xml` | Android Auto **no descubre** la app |
|
||||||
|
| `<meta-data com.google.android.gms.car.application>` en el manifest | idem |
|
||||||
|
| Override de `getChildren()` / `getMediaItem()` | El coche no tiene **ninguna lista** que mostrar |
|
||||||
|
| Override de `playFromMediaId()` | Tocar una emisora en el coche **no reproduce** nada |
|
||||||
|
| `MediaItem`s con carátula (`artUri`) por emisora | Google **rechaza** apps de media sin título+thumbnail por ítem |
|
||||||
|
|
||||||
|
Hoy PluriWave solo sabe reproducir un `MediaItem` que le pasa **su propia UI de Flutter**
|
||||||
|
(`playMediaItem`). El coche necesita el camino inverso: **pedir la lista** y **arrancar por id**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 2 — Quick path (los pasos, en orden)
|
||||||
|
|
||||||
|
### Paso 1 · Declarar la app ante Android Auto
|
||||||
|
|
||||||
|
Crear `android/app/src/main/res/xml/automotive_app_desc.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media"/>
|
||||||
|
</automotiveApp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Añadir dentro de `<application>` en `AndroidManifest.xml` (junto al resto de `<meta-data>`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Con esto Android Auto ya "ve" la app, pero seguirá vacía hasta el Paso 2.
|
||||||
|
|
||||||
|
### Paso 2 · Construir el árbol navegable (el trabajo de fondo)
|
||||||
|
|
||||||
|
En `PluriWaveAudioHandler` (`lib/servicios/servicio_audio.dart`) implementar estos métodos.
|
||||||
|
Firmas **verificadas** contra el código de `audio_service`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Devuelve los hijos de una "carpeta". El root usa AudioService.browsableRootId.
|
||||||
|
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||||
|
[Map<String, dynamic>? options]);
|
||||||
|
|
||||||
|
// Metadatos de un ítem concreto (por si el coche los pide sueltos).
|
||||||
|
Future<MediaItem?> getMediaItem(String mediaId);
|
||||||
|
|
||||||
|
// El usuario tocó un ítem en la pantalla del coche → reproducirlo.
|
||||||
|
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]);
|
||||||
|
|
||||||
|
// (Opcional) búsqueda por voz "pon Radio X".
|
||||||
|
Future<List<MediaItem>> playFromSearch(String query, [Map<String, dynamic>? extras]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Diseño de árbol sugerido para PluriWave:
|
||||||
|
|
||||||
|
```
|
||||||
|
root (AudioService.browsableRootId)
|
||||||
|
├── Favoritos (playable: false → carpeta)
|
||||||
|
│ ├── Emisora A (playable: true)
|
||||||
|
│ └── Emisora B (playable: true)
|
||||||
|
├── Todas las emisoras (playable: false)
|
||||||
|
│ └── ...
|
||||||
|
└── Mis emisoras (playable: false) // las custom del usuario
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas de un `MediaItem`:
|
||||||
|
|
||||||
|
| Campo | Carpeta | Emisora reproducible |
|
||||||
|
|-------|---------|----------------------|
|
||||||
|
| `id` | id estable de la categoría | id estable de la emisora |
|
||||||
|
| `title` | nombre visible | nombre de la emisora **(obligatorio)** |
|
||||||
|
| `playable` | `false` | `true` |
|
||||||
|
| `artUri` | opcional | **carátula/logo (obligatorio para pasar la revisión)** |
|
||||||
|
|
||||||
|
Lógica a reutilizar: ya tenés `_emisoraDesdeMediaItem` (`servicio_audio.dart:705`) y
|
||||||
|
`playMediaItem` (`servicio_audio.dart:433`). En `playFromMediaId(id)` resolvés el id →
|
||||||
|
`Emisora` → construís el `MediaItem` real → llamás al mismo `playMediaItem` interno. **No
|
||||||
|
dupliques la lógica de reproducción**, enchufala.
|
||||||
|
|
||||||
|
### Paso 3 · Carátulas accesibles
|
||||||
|
|
||||||
|
`artUri` tiene que ser una URL/҇URI que el sistema pueda cargar (http(s) o `content://`).
|
||||||
|
Las emisoras que ya tienen logo remoto sirven directo. Para emisoras sin logo, definí una
|
||||||
|
carátula por defecto (asset empaquetado servido vía `content://` o un placeholder remoto).
|
||||||
|
|
||||||
|
### Paso 4 · (Opcional pero recomendado) Content style
|
||||||
|
|
||||||
|
Android Auto puede pintar los ítems como **lista** o **grid**. Se controla con hints en el
|
||||||
|
`extras`/config del root (constantes `CONTENT_STYLE_*` de la spec de MediaBrowser).
|
||||||
|
Para una app de radio, **grid** para emisoras (se ven los logos) queda mejor. Es pulido,
|
||||||
|
no bloquea la publicación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 3 · Probar sin coche (DHU — Desktop Head Unit)
|
||||||
|
|
||||||
|
No necesitás un coche para testear. Google da un emulador de la pantalla del coche.
|
||||||
|
|
||||||
|
**Quick path del testeo:**
|
||||||
|
|
||||||
|
1. En **Android Studio → SDK Manager → SDK Tools**, instalá **Android Auto Desktop Head Unit**.
|
||||||
|
2. En el **teléfono**: instalá la app *Android Auto*, entrá en sus ajustes y tocá 10 veces
|
||||||
|
la versión para activar **modo desarrollador**; ahí activá **"Head unit server"**.
|
||||||
|
3. Conectá el teléfono por USB y lanzá el DHU:
|
||||||
|
```bash
|
||||||
|
cd "$ANDROID_HOME/extras/google/auto"
|
||||||
|
./desktop-head-unit # (desktop-head-unit.exe en Windows)
|
||||||
|
```
|
||||||
|
4. En la ventana del DHU deberías ver PluriWave en la sección de **media**. Navegá el árbol
|
||||||
|
y reproducí una emisora.
|
||||||
|
|
||||||
|
**Checklist de humo en el DHU:**
|
||||||
|
|
||||||
|
- [ ] La app aparece en la lista de apps de media del coche.
|
||||||
|
- [ ] Se ve el árbol (Favoritos / Todas / Mis emisoras).
|
||||||
|
- [ ] Cada emisora muestra **título + carátula**.
|
||||||
|
- [ ] Tocar una emisora **arranca el audio**.
|
||||||
|
- [ ] Play / pausa / stop responden desde la pantalla del coche.
|
||||||
|
- [ ] Al pausar en el coche, la app del teléfono refleja el mismo estado (y viceversa).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parte 4 · Publicar en Play Store (lo específico de un primer publicador)
|
||||||
|
|
||||||
|
Publicar una app **con Android Auto** no es igual que una app normal: dispara una
|
||||||
|
**revisión adicional** de Google contra las *car app quality guidelines*.
|
||||||
|
|
||||||
|
### Requisitos de calidad que Google verifica (media apps)
|
||||||
|
|
||||||
|
| Requisito | Qué significa para PluriWave |
|
||||||
|
|-----------|------------------------------|
|
||||||
|
| Integración con MediaSession | Ya lo da `audio_service` ✅ |
|
||||||
|
| Soportar play/pausa **o** stop | Ya lo tenés ✅ |
|
||||||
|
| **Título + thumbnail por cada ítem** | ← esto es lo que hay que asegurar (Paso 3) |
|
||||||
|
| Poder llegar a la vista de reproducción desde el browsing | Se cumple con el árbol bien armado |
|
||||||
|
| **Al menos 1 screenshot real, sin editar, de la experiencia en coche** | Sacala del DHU |
|
||||||
|
|
||||||
|
### Pasos en Play Console
|
||||||
|
|
||||||
|
1. **Cuenta de desarrollador** (pago único de ~25 USD, primera vez).
|
||||||
|
2. Subí primero a un **track de pruebas cerrado**, NO directo a producción.
|
||||||
|
> Importante: si el build va en un track de **testing** y no cumple, Google te avisa
|
||||||
|
> pero **igual lo aprueba** para ese track. Si el mismo build va a **producción** y no
|
||||||
|
> cumple, lo **rechaza**. Por eso: cerrado → arreglás → producción.
|
||||||
|
3. Completá la **ficha de la tienda** + el cuestionario de contenido/privacidad
|
||||||
|
(obligatorio para cualquier app nueva).
|
||||||
|
4. Subí la **screenshot de la experiencia en coche** (del DHU).
|
||||||
|
5. Enviá a revisión. La revisión de coche puede tardar **de unas horas hasta 7 días**
|
||||||
|
(a veces más), bastante más que una app solo-móvil.
|
||||||
|
|
||||||
|
### Gotchas para PluriWave concretamente
|
||||||
|
|
||||||
|
- **Muchos permisos sensibles.** El manifest pide localización, `RECORD_AUDIO`,
|
||||||
|
`SCHEDULE_EXACT_ALARM`, `SYSTEM_EXEMPTED`, etc. La revisión de coche mira con lupa; tené
|
||||||
|
a mano la justificación de cada permiso (la sección de *foreground service* de Play
|
||||||
|
Console te va a pedir el porqué de `mediaPlayback`).
|
||||||
|
- **`targetSdk`** sale de `flutter.targetSdkVersion` (`android/app/build.gradle`). Play
|
||||||
|
exige un target reciente para apps nuevas; verificá que cumpla el mínimo del año antes de
|
||||||
|
subir.
|
||||||
|
- **Streams que fallan.** Google prueba reproducir. Si una emisora del árbol está caída, da
|
||||||
|
mala impresión. Exponé en el árbol emisoras fiables (favoritos del usuario, o un set
|
||||||
|
curado) y manejá el error de stream con gracia (ya tenés `controlador_reconexion.dart`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist maestro
|
||||||
|
|
||||||
|
**Código**
|
||||||
|
- [ ] `res/xml/automotive_app_desc.xml` creado (`<uses name="media"/>`).
|
||||||
|
- [ ] `<meta-data com.google.android.gms.car.application>` en el manifest.
|
||||||
|
- [ ] `getChildren()` devuelve el árbol (carpetas + emisoras).
|
||||||
|
- [ ] `getMediaItem()` resuelve un id suelto.
|
||||||
|
- [ ] `playFromMediaId()` reutiliza `playMediaItem` interno.
|
||||||
|
- [ ] Cada emisora expone `title` + `artUri`.
|
||||||
|
|
||||||
|
**Testing**
|
||||||
|
- [ ] Funciona en el DHU (navegar + reproducir + play/pausa).
|
||||||
|
- [ ] Estado sincronizado coche ↔ teléfono.
|
||||||
|
|
||||||
|
**Publicación**
|
||||||
|
- [ ] Cuenta de desarrollador creada.
|
||||||
|
- [ ] Screenshot de la experiencia en coche subida.
|
||||||
|
- [ ] Subido primero a track cerrado.
|
||||||
|
- [ ] Justificación de permisos preparada.
|
||||||
|
- [ ] Enviado a revisión.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [Media apps for cars — overview (Android Developers)](https://developer.android.com/training/cars/media)
|
||||||
|
- [Add support for Android Auto to your media app](https://developer.android.com/training/cars/media/auto)
|
||||||
|
- [Car app quality guidelines](https://developer.android.com/docs/quality-guidelines/car-app-quality)
|
||||||
|
- [Distribute to cars (Play Console)](https://developer.android.com/training/cars/distribute)
|
||||||
|
- [audio_service (pub.dev)](https://pub.dev/packages/audio_service)
|
||||||
|
- [audio_service — repo y ejemplos (GitHub)](https://github.com/ryanheise/audio_service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximo paso sugerido
|
||||||
|
|
||||||
|
Empezar por el **Paso 1 + Paso 2 con un árbol mínimo** (solo "Favoritos" con 2–3 emisoras
|
||||||
|
hardcodeadas) y verlo en el **DHU**. Cuando eso reproduzca en el emulador de coche, recién
|
||||||
|
ahí ampliar el árbol y pulir carátulas. Es el bucle de feedback más corto para no
|
||||||
|
programar a ciegas.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package_name(ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave")
|
||||||