feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s

This commit is contained in:
2026-05-23 01:22:37 +02:00
parent 27b8fccac9
commit 896349ad5f
44 changed files with 1772 additions and 241 deletions
+13
View File
@@ -6,6 +6,7 @@
<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"/>
@@ -73,6 +74,18 @@
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<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"
@@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject
class AlarmScheduler(private val context: Context) {
private val tag = "PluriWave"
@@ -22,7 +23,7 @@ class AlarmScheduler(private val context: Context) {
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
): Boolean {
Log.d(
tag,
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
@@ -55,10 +56,25 @@ class AlarmScheduler(private val context: Context) {
)
val mainScheduled = scheduleMainAlarm(id, triggerAtMillis, showIntent, alarmIntent)
if (mainScheduled) {
saveScheduledAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume
)
} else {
removeScheduledAlarm(id)
}
val now = System.currentTimeMillis()
if (!mainScheduled) {
Log.w(tag, "alarm.schedule main alarm fallback failed or degraded id=$id")
return false
}
if (preNoticeAtMillis > now) {
@@ -94,6 +110,7 @@ class AlarmScheduler(private val context: Context) {
} else {
Log.d(tag, "alarm.schedule preNotice skipped id=$id")
}
return true
}
private fun scheduleMainAlarm(
@@ -123,6 +140,12 @@ class AlarmScheduler(private val context: Context) {
alarmIntent
)
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Log.e(
tag,
"alarm.schedule exact permission missing; refusing inexact fallback id=$id"
)
return false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
@@ -147,16 +170,10 @@ class AlarmScheduler(private val context: Context) {
fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id")
for (slot in 1..3) {
alarmManager.cancel(
PendingIntent.getBroadcast(
context,
requestCode(id, slot),
Intent(context, PluriWaveAlarmReceiver::class.java),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) ?: continue
)
}
removeScheduledAlarm(id)
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
NotificationManagerCompat.from(context).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
@@ -176,5 +193,119 @@ class AlarmScheduler(private val context: Context) {
alarmManager.canScheduleExactAlarms()
}
fun reschedulePersistedAlarms() {
val now = System.currentTimeMillis()
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: continue
try {
val data = JSONObject(raw)
val triggerAt = data.optLong("triggerAtMillis", 0L)
if (triggerAt <= now) {
Log.d(tag, "alarm.reschedule skip stale id=$id triggerAt=$triggerAt")
removeScheduledAlarm(id)
continue
}
scheduleAlarm(
id = id,
title = data.optString("title", "PluriWave"),
triggerAtMillis = triggerAt,
preNoticeAtMillis = data.optLong("preNoticeAtMillis", 0L),
stationName = data.optString("stationName").takeIf { it.isNotBlank() },
stationUrl = data.optString("stationUrl").takeIf { it.isNotBlank() },
fallbackSound = data.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = data.optDouble("volume", 0.85).toFloat()
)
Log.d(tag, "alarm.reschedule OK id=$id")
} catch (error: Throwable) {
Log.e(tag, "alarm.reschedule failed id=$id", error)
}
}
}
private fun saveScheduledAlarm(
id: String,
title: String,
triggerAtMillis: Long,
preNoticeAtMillis: Long,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
val data = JSONObject().apply {
put("title", title)
put("triggerAtMillis", triggerAtMillis)
put("preNoticeAtMillis", preNoticeAtMillis)
put("stationName", stationName)
put("stationUrl", stationUrl)
put("fallbackSound", fallbackSound)
put("volume", volume)
}
prefs().edit()
.putStringSet(KEY_IDS, ids)
.putString("$KEY_ALARM_PREFIX$id", data.toString())
.apply()
}
private fun removeScheduledAlarm(id: String) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
prefs().edit()
.putStringSet(KEY_IDS, ids)
.remove("$KEY_ALARM_PREFIX$id")
.apply()
}
private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
if (pendingIntent == null) {
Log.d(tag, "alarm.cancel $name no pending intent")
return
}
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(tag, "alarm.cancel $name OK")
}
private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
requestCode(id, 1),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getActivity(
context,
requestCode(id, 2),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
requestCode(id, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
companion object {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_IDS = "scheduled_alarm_ids"
private const val KEY_ALARM_PREFIX = "scheduled_alarm_"
}
}
@@ -7,6 +7,8 @@ import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
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
@@ -73,7 +75,7 @@ class MainActivity : AudioServiceActivity() {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else {
alarmScheduler.scheduleAlarm(
val scheduled = alarmScheduler.scheduleAlarm(
id,
title,
triggerAtMillis,
@@ -83,7 +85,7 @@ class MainActivity : AudioServiceActivity() {
fallbackSound,
volume
)
result.success(null)
result.success(scheduled)
}
}
"cancelAlarm" -> {
@@ -92,7 +94,6 @@ class MainActivity : AudioServiceActivity() {
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.cancelAlarm(id)
result.success(null)
}
@@ -129,6 +130,10 @@ class MainActivity : AudioServiceActivity() {
)
)
}
"requestExactAlarmPermission" -> {
Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(requestExactAlarmPermission())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -153,6 +158,15 @@ class MainActivity : AudioServiceActivity() {
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/*"
@@ -193,25 +207,120 @@ class MainActivity : AudioServiceActivity() {
)
}
private fun openDirectory(path: String): Boolean {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
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")
}
)
directoryTreeUri(path)?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) }
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestExactAlarmPermission 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, "Abrir carpeta"))
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, "file_actions.openDirectory launched path=$path")
Log.d(tag, "$origin launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openDirectory no activity for path=$path")
Log.w(tag, "$origin no activity for path=$path")
false
} catch (error: Throwable) {
Log.e(tag, "file_actions.openDirectory failed path=$path", error)
Log.e(tag, "$origin failed path=$path", error)
false
}
}
@@ -257,6 +366,18 @@ class MainActivity : AudioServiceActivity() {
)
}
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)
@@ -234,7 +234,8 @@ class PluriWaveAlarmService : Service() {
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
}
try {
context.startService(intent)
context.stopService(intent)
Log.d(TAG, "alarm.service stop requested id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
}
@@ -0,0 +1,26 @@
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_BOOT_COMPLETED,
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"
}
}