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.
1444 lines
65 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|
|
}
|