Files
pluriwave/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
T
FreeTLab a8dca83cd9 feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and
PluriWaveAlarmService record a pre-notice that could not be armed, a
refused foreground-service start, and a per-alarm reschedule that failed
after a reboot -- but nothing read them, so all three still ended at
logcat.

EstadoAlarmas now drains them at startup and turns each into a per-alarm
exception, which the card already knows how to mark. An alarm that never
reached the OS stops looking identical to one that did.

The read is deliberately tolerant: a failure to read is logged and
swallowed, never surfaced as an alarm error, so a diagnostics gap cannot
masquerade as a scheduling problem.
2026-07-31 23:24:01 +02:00

1444 lines
65 KiB
Kotlin

package es.freetimelab.pluriwave
import android.Manifest
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
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.media.MediaMetadataRetriever
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 : 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 pickMusicFolderRequestCode = 4824
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
// MIME types DocumentsUI's file browser declares an ACTION_VIEW filter for.
// DocumentsContract only exposes the directory one as a constant.
private val directoryDocumentMimeType = DocumentsContract.Document.MIME_TYPE_DIR
private val rootDocumentMimeType = "vnd.android.document/root"
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())
// Local-music SAF folder picker (android-auto-local-music, static
// review only — see file_actions.pickMusicFolder / onActivityResult):
// the MethodChannel.Result held across the startActivityForResult round
// trip, so the eventual onActivityResult callback can respond to the
// SAME pending Dart call instead of a stale one.
private var pendingMusicFolderResult: MethodChannel.Result? = null
// 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)
}
}
"dismissAlarmNotificationOnly" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel dismissAlarmNotificationOnly id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
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)
}
}
"getActiveRingingAlarmId" -> {
result.success(PluriWaveAlarmService.activeRingingId)
}
"stopActiveAlarm" -> {
try {
// Verified stop (feedback item 1, RISK-1/RES-1/REL-2): the
// id is snapshotted BEFORE stopping, and "stopped" now
// reflects stopActiveVerified's post-teardown check
// instead of a literal true decided before teardown ran.
val activeId = PluriWaveAlarmService.activeRingingId
val stopped = PluriWaveAlarmService.stopActiveVerified(this)
Log.d(tag, "alarm.channel stopActiveAlarm activeId=$activeId stopped=$stopped")
result.success(
mapOf(
"stopped" to stopped,
"wasRinging" to (activeId != null),
"activeAlarmId" to activeId
)
)
} catch (error: Throwable) {
Log.e(tag, "alarm.channel stopActiveAlarm failed", error)
result.error("STOP_FAILED", error.message, 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())
}
"openNotificationSettings" -> {
Log.d(tag, "alarm.channel openNotificationSettings")
result.success(openNotificationSettings())
}
"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())
}
"getNativeSchedulingFailures" -> {
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
result.success(alarmScheduler.scheduleFailures())
}
"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))
}
}
// ---- android-auto-local-music (static review only) ----
"pickMusicFolder" -> {
Log.d(tag, "file_actions.pickMusicFolder launching picker")
// A stale pending call (e.g. the user backgrounded the
// app mid-picker and re-triggered it) resolves as
// cancelled first, so no Dart-side `Future` is left
// dangling and `pendingMusicFolderResult` always points
// at the LATEST call by the time onActivityResult fires.
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = result
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
@Suppress("DEPRECATION")
startActivityForResult(intent, pickMusicFolderRequestCode)
} catch (error: Throwable) {
Log.e(tag, "file_actions.pickMusicFolder launch failed", error)
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = null
}
}
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
// ---- android-auto-local-music-phase2 (static review only) ----
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
else -> result.notImplemented()
}
}
}
/**
* Handles the [pickMusicFolderRequestCode] round trip from
* `file_actions.pickMusicFolder` (android-auto-local-music, static
* review only — no existing `onActivityResult` override existed on this
* Activity before this change). On a successful pick, persists the
* granted read permission via [android.content.ContentResolver.takePersistableUriPermission]
* and resolves the pending [MethodChannel.Result] with the tree URI
* string; on cancel, missing data, or a persistence failure, resolves
* with `null` instead of throwing. Any other request code is delegated
* to `super` untouched.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == pickMusicFolderRequestCode) {
val pending = pendingMusicFolderResult
pendingMusicFolderResult = null
val treeUri = data?.data
if (resultCode == RESULT_OK && treeUri != null) {
try {
contentResolver.takePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
Log.d(tag, "file_actions.pickMusicFolder picked uri=$treeUri")
pending?.success(treeUri.toString())
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.pickMusicFolder takePersistableUriPermission failed",
error
)
pending?.success(null)
}
} else {
Log.d(
tag,
"file_actions.pickMusicFolder cancelled or no data resultCode=$resultCode"
)
pending?.success(null)
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only — Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) — used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission }
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only — Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary — a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only — mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing —
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(this, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable — the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only — Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml:97-105`, `pluriwave_file_paths.xml`'s
* `cache-path path="."` — confirmed present, zero manifest changes
* needed) and trims `pluriwave_art/` via [trimArtCache]. `hash` uses
* SHA-256 hex because a raw `documentId` may contain `:`/`/`, which are
* illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(this, "$packageName.fileprovider", artFile).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop — these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
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
}
}
/**
* Opens the system's per-app notification settings screen directly
* (diagnostics screen, fix/alarmas-fiabilidad). Unlike
* [requestPostNotificationsPermission] -- which shows the runtime
* permission popup and is meant for the FIRST time an alarm is created
* -- this is meant for a user troubleshooting an alarm that already
* failed, where the OS may no longer show that popup at all after a
* prior denial. `ACTION_APP_NOTIFICATION_SETTINGS` only exists from API
* 26; older devices fall back to the app's own details screen, which
* still surfaces the notification toggle. Never throws across the
* channel boundary -- an unresolvable intent on some ROM is caught and
* reported as `false`, same shape as every other `request*`/`open*`
* helper in this class.
*/
private fun openNotificationSettings(): Boolean {
return try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
}
}
startActivity(intent)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel openNotificationSettings 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
}
/**
* Opens [path] in whatever app the system uses to browse folders.
*
* The default recordings folder lives in app-private storage, which NO file
* manager can reach through a `file://` or `FileProvider` URI -- the Android
* sandbox forbids other apps from reading `/data/user/0/<package>/`. That is
* why the old `resource/folder` candidate could never work.
* [RecordingsDocumentsProvider] publishes the folder as a document root
* instead, so the candidates below hand the system a URI it can actually
* resolve without a single byte leaving private storage. A user-configured
* folder on shared storage keeps using the platform's own external-storage
* provider, which the file manager already indexes.
*
* `startActivity` is used directly instead of `Intent.createChooser`,
* because a chooser never throws when nothing can handle the intent (it just
* shows an empty dialog). That swallowed the failure and stopped the
* fallback chain from ever running.
*/
private fun viewDirectory(path: String): Boolean {
val directory = File(path)
if (!directory.exists()) {
directory.mkdirs()
}
// Point the published root at the folder Flutter is really using, so a
// path changed in Settings is what the file manager shows.
RecordingsDocumentsProvider.rememberRoot(this, path)
val candidates = mutableListOf<Pair<String, Intent>>()
// Shared storage: the platform provider already exposes this folder, so
// prefer it when the user picked a public path.
directoryDocumentUri(path)?.let { uri ->
candidates += "externalstorage-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, directoryDocumentMimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
// Our own root: handled by DocumentsUI's file browser on every device
// that ships it, and the only option that works for private storage.
candidates += "recordings-root" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootUri(this@MainActivity),
rootDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
candidates += "recordings-dir" to Intent(Intent.ACTION_VIEW).apply {
setDataAndType(
RecordingsDocumentsProvider.rootDocumentUri(this@MainActivity),
directoryDocumentMimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// Last resort: DocumentsUI always handles OPEN_DOCUMENT_TREE, and
// EXTRA_INITIAL_URI lands it straight on the recordings folder.
candidates += "recordings-tree" to Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
RecordingsDocumentsProvider.rootTreeUri(this@MainActivity)
)
}
}
for ((origin, intent) in candidates) {
if (openIntentSafely(intent, "file_actions.viewDirectory $origin", path, requireData = false)) {
return true
}
}
Log.w(tag, "file_actions.viewDirectory no candidate could be launched path=$path")
return false
}
private fun openIntentSafely(
intent: Intent?,
origin: String,
path: String,
requireData: Boolean = true,
): Boolean {
if (intent == null) return false
if (requireData && 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")
viewDirectory(file.parentFile?.absolutePath ?: path)
} catch (error: Throwable) {
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
viewDirectory(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('/')
// Android 11+ hides Android/data and Android/obb from the document
// framework, so a URI into them resolves to nothing useful.
if (relative.startsWith("Android/data") || relative.startsWith("Android/obb")) return null
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())
}
"getBondedDeviceNames" -> {
val names = bondedDeviceNames()
Log.d(tag, "audio_devices.getBondedDeviceNames count=${names.size}")
result.success(names)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
audioDevicesSink = events
registerAudioDeviceCallback()
// Immediate resync: emit the current active device on every
// (re)subscription. The Flutter engine outlives the Activity
// (AudioServiceActivity), so a recreated Activity installs a
// fresh StreamHandler that never sees a "listen" until Dart
// resubscribes — without this emission the Dart side would
// keep a stale device until the next physical connect event.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onListen -> $device")
events?.success(device)
}
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
}
/**
* Returns MAC -> name for every PAIRED Bluetooth device, connected or not.
*
* `AudioDeviceInfo.productName` only exists while a device is enumerated as
* an active output, so a paired-but-switched-off device can never report its
* own name and its row falls back to the raw id. The bond list is the
* system's own record and is the only source that survives disconnection.
*
* Returns an empty map instead of throwing when the answer is unavailable
* (BLUETOOTH_CONNECT denied, no adapter, device with Bluetooth off): a
* missing name must degrade to the id, never break device resolution.
*/
private fun bondedDeviceNames(): Map<String, String> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED
) {
Log.d(tag, "audio_devices.bondedDeviceNames BLUETOOTH_CONNECT not granted")
return emptyMap()
}
return try {
val manager = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val adapter = manager?.adapter ?: return emptyMap()
adapter.bondedDevices
.orEmpty()
.mapNotNull { device ->
val address = device.address
?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
?: return@mapNotNull null
val name = device.name?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
address.uppercase() to name
}
.toMap()
} catch (error: Throwable) {
Log.w(tag, "audio_devices.bondedDeviceNames failed", error)
emptyMap()
}
}
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Idempotent: a re-listen without a prior cancel must not leak the
// previously registered callback.
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
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.
// AudioManager.getDevices() can still momentarily report a
// just-removed sink (observed on Bluetooth A2DP), so the
// removed ids are excluded explicitly instead of trusting
// getDevices() to already be up to date.
val excludedIds = removedDevices.map { it.id }.toSet()
val device = getActiveAudioDevice(excludeIds = excludedIds)
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 (22)
* - "other:<type>:<address>" — any other external output this build
* does not name individually
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
private fun getActiveAudioDevice(excludeIds: Set<Int> = emptySet()): 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)
// The built-in speaker is the LAST resort, never a peer in this list:
// it is always present, so ranking it alongside the others made any
// output type absent from the list (LE Audio car stereos, car buses,
// docks) sort BELOW it and never win — the car would connect and the
// phone speaker would still be reported as the active device.
val best = outputs
.filter { it.isSink && it.id !in excludeIds }
.minByOrNull { device -> outputPriority(device.type) }
return deviceToMap(best)
}
/**
* Media outputs a user actively connects, in the order they should win when
* several are present. Index IS the priority.
*
* This is an ALLOW list on purpose. Ranking "everything not named here"
* above the built-in speaker looks equivalent and is not: a phone
* permanently exposes internal sinks that are legitimate outputs but never
* where music is playing -- TYPE_FM (14) on this project's Xiaomi test
* device, TYPE_BUILTIN_SPEAKER_SAFE (24) on many others. Those outranked
* the real speaker, got reported as the active device and had a preset row
* persisted for them.
*/
private val externalOutputPriority = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_BLE_BROADCAST,
AudioDeviceInfo.TYPE_HEARING_AID,
AudioDeviceInfo.TYPE_BUS,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_LINE_ANALOG,
AudioDeviceInfo.TYPE_LINE_DIGITAL,
AudioDeviceInfo.TYPE_AUX_LINE,
AudioDeviceInfo.TYPE_DOCK,
AudioDeviceInfo.TYPE_HDMI,
AudioDeviceInfo.TYPE_HDMI_ARC,
)
/**
* Ranks an [AudioDeviceInfo] type as a media output candidate; lower wins.
*
* The built-in speaker is the fallback, so it sits below every external
* output and above everything else — including outputs that physically
* exist but are never where media plays.
*/
private fun outputPriority(type: Int): Int {
val index = externalOutputPriority.indexOf(type)
if (index >= 0) return index
return if (type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) 90 else 99
}
/**
* [AudioDeviceInfo.getAddress] is API 28 while minSdk is 24, so reading it
* unguarded throws NoSuchMethodError on Android 7-8.1. Below API 28 there is
* no address to read and callers fall back to a non-MAC identity (the
* `bt_a2dp:name:` placeholder shape, or the device's session id).
*/
private fun deviceAddress(device: AudioDeviceInfo): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) device.address else null
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 = deviceAddress(device)?.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 = deviceAddress(device)?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
// Send the real constant (22). The hardcoded 14 that used to
// sit here is TYPE_FM, and Dart mirrored the mistake, so a
// phone's own FM sink decoded as a USB headset.
"type" to AudioDeviceInfo.TYPE_USB_HEADSET,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
// NEVER reuse builtin_speaker's id here. An output this build does
// not name individually (LE Audio car stereo, car bus, dock) would
// collide with the phone's own speaker: Dart persisted a device
// entry under that shared id, and from then on every playback
// through the phone speaker matched it, pinning the green
// active-device marker to the wrong row forever. The type is kept
// verbatim so Dart can still tell it apart from a real speaker.
else -> {
val address = deviceAddress(device)
?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder }
?: device.id.toString()
mapOf(
"id" to "other:${device.type}:$address",
"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"
/** alarmAction reported when a fired alarm auto-silenced unattended (Decision 3). */
const val ALARM_ACTION_MISSED = "missed"
@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)
}
}
}
}