feat(app): add onboarding and harden alarms
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user